Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • petre/nextcloud-app-podcast
  • onny/nextcloud-app-podcast
2 results
Show changes
Showing
with 10626 additions and 2273 deletions
......@@ -4,7 +4,7 @@
* Podcast App
*
* @author Jonas Heinrich
* @copyright 2020 Jonas Heinrich <onny@project-insanity.org>
* @copyright 2021 Jonas Heinrich <onny@project-insanity.org>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
......
......@@ -4,7 +4,7 @@
* Podcast App
*
* @author Jonas Heinrich
* @copyright 2020 Jonas Heinrich <onny@project-insanity.org>
* @copyright 2021 Jonas Heinrich <onny@project-insanity.org>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
......@@ -66,29 +66,32 @@ class ShowService {
}
}
public function create($title, $htmlURL, $smallImageURL, $categories, $lastpub, $description, $author, $userId) {
public function create($id, $imgurl, $author, $title, $lastpub, $dateadded,
$homepage, $feedurl, $userId) {
$show = new Show();
$show->setId($id);
$show->setImgurl($imgurl);
$show->setAuthor($author);
$show->setTitle($title);
$show->setHtmlURL($htmlURL);
$show->setSmallImageURL($smallImageURL);
$show->setCategories($categories);
$show->setLastpub($lastpub);
$show->setDescription($description);
$show->setAuthor($author);
$show->setDateadded($dateadded);
$show->setHomepage($homepage);
$show->setFeedurl($feedurl);
$show->setUserId($userId);
return $this->mapper->insert($show);
}
public function update($id, $title, $htmlURL, $smallImageURL, $categories, $lastpub, $description, $author, $userId) {
public function update($id, $imgurl, $author, $title, $lastpub, $dateadded,
$homepage, $feedurl, $userId) {
try {
$show = $this->mapper->find($id, $userId);
$show->setImgurl($imgurl);
$show->setAuthor($author);
$show->setTitle($title);
$show->setHtmlURL($htmlURL);
$show->setSmallImageURL($smallImageURL);
$show->setCategories($categories);
$show->setLastpub($lastpub);
$show->setDescription($description);
$show->setAuthor($author);
$show->setDateadded($dateadded);
$show->setHomepage($homepage);
$show->setFeedurl($feedurl);
return $this->mapper->update($show);
} catch (Exception $e) {
$this->handleException($e);
......
This diff is collapsed.
{
"name": "podcast",
"description": "Listen to your favorite podcast shows in Nextcloud",
"version": "0.0.1",
"version": "0.4.0",
"author": "Jonas Heinrich <onny@project-insanity.org>",
"contributors": [
"Jonas Heinrich <onny@project-insanity.org>"
......@@ -30,27 +30,34 @@
"stylelint:fix": "stylelint src --fix"
},
"dependencies": {
"@nextcloud/axios": "^1.5.0",
"@nextcloud/dialogs": "^3.1.1",
"@babel/eslint-parser": "^7.15.8",
"@nextcloud/auth": "^1.3.0",
"@nextcloud/axios": "^1.7.0",
"@nextcloud/dialogs": "^3.1.2",
"@nextcloud/l10n": "^1.4.1",
"@nextcloud/moment": "^1.1.1",
"@nextcloud/router": "^1.2.0",
"@nextcloud/vue": "^2.9.0",
"@nextcloud/vue-dashboard": "^1.0.1",
"autoprefixer": "^9.8.6",
"howler": "^2.2.1",
"javascript-time-ago": "^2.3.3",
"music-metadata": "^7.5.0",
"postcss-loader": "^4.1.0",
"style-loader": "^2.0.0",
"vue": "^2.6.12",
"vue-blurhash": "^0.1.2",
"vue-clipboard2": "^0.3.1",
"vue-content-loader": "^0.2.3",
"vue-resize-observer": "^1.0.32",
"vue-router": "^3.4.9",
"@nextcloud/router": "^2.0.0",
"@nextcloud/vue": "^4.2.0",
"@nextcloud/vue-dashboard": "^2.0.1",
"autoprefixer": "^10.3.7",
"eslint-plugin-jsdoc": "^36.1.1",
"eslint-webpack-plugin": "^3.0.1",
"howler": "^2.2.3",
"javascript-time-ago": "^2.3.9",
"linkify-string": "^3.0.3",
"music-metadata": "^7.11.4",
"node-polyfill-webpack-plugin": "^1.1.4",
"postcss-loader": "^6.2.0",
"splitpanes": "^2.3.8",
"style-loader": "^3.3.0",
"vue": "^2.6.14",
"vue-content-loader": "^2.0.0",
"vue-lazyload": "^1.3.3",
"vue-material-design-icons": "^4.13.0",
"vue-resize-directive": "^1.2.0",
"vue-router": "^3.5.2",
"vue-show-more-text": "^2.0.2",
"vuex": "^3.5.1"
"vuex": "^3.6.2"
},
"browserslist": [
"extends @nextcloud/browserslist-config"
......@@ -59,38 +66,35 @@
"node": ">=10.0.0"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/core": "^7.15.8",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "^7.12.1",
"@nextcloud/browserslist-config": "^1.0.0",
"@nextcloud/eslint-config": "^2.2.0",
"@nextcloud/eslint-plugin": "^1.5.0",
"@nextcloud/webpack-vue-config": "^1.4.1",
"@vue/test-utils": "^1.1.1",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.2.1",
"css-loader": "^3.6.0",
"eslint": "^6.8.0",
"eslint-config-standard": "^14.1.1",
"eslint-import-resolver-webpack": "^0.13.0",
"eslint-loader": "^4.0.2",
"eslint-plugin-html": "^6.1.1",
"eslint-plugin-import": "^2.22.1",
"@babel/preset-env": "^7.15.8",
"@nextcloud/browserslist-config": "^2.2.0",
"@nextcloud/eslint-config": "^6.1.0",
"@nextcloud/eslint-plugin": "^2.0.0",
"@nextcloud/webpack-vue-config": "^4.1.0",
"@vue/test-utils": "^1.2.2",
"babel-loader": "^8.2.2",
"css-loader": "^6.4.0",
"eslint": "^8.0.1",
"eslint-config-standard": "^16.0.3",
"eslint-import-resolver-webpack": "^0.13.1",
"eslint-plugin-html": "^6.2.0",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.1.0",
"eslint-plugin-vue": "^6.2.2",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-vue": "^7.19.1",
"file-loader": "^6.2.0",
"node-sass": "^4.14.1",
"sass-loader": "^8.0.2",
"stylelint": "^13.8.0",
"stylelint-config-recommended-scss": "^4.2.0",
"stylelint-scss": "^3.18.0",
"stylelint-webpack-plugin": "^2.1.1",
"vue-loader": "^15.9.5",
"vue-template-compiler": "^2.6.12",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.4.0"
"node-sass": "^6.0.1",
"sass-loader": "^12.2.0",
"stylelint": "^13.13.1",
"stylelint-config-recommended-scss": "^4.3.0",
"stylelint-scss": "^3.21.0",
"stylelint-webpack-plugin": "^3.0.1",
"vue-loader": "^15.9.8",
"vue-template-compiler": "^2.6.14",
"webpack": "^5.58.2",
"webpack-cli": "^4.9.0",
"webpack-merge": "^5.8.0"
}
}
This diff is collapsed.
screenshot-thumbnail.jpg

18.5 KiB

screenshot.png

176 KiB

<!--
- @copyright Copyright (c) 2020 Jonas Heinrich
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
......@@ -26,6 +26,10 @@
<AppContent>
<router-view />
</AppContent>
<Player />
<!-- <div class="floatControl">
Skip intro
</div> -->
</Content>
</template>
......@@ -33,6 +37,7 @@
import Content from '@nextcloud/vue/dist/Components/Content'
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
import Navigation from './components/Navigation'
import Player from './components/Player'
export default {
name: 'App',
......@@ -40,19 +45,11 @@ export default {
Content,
Navigation,
AppContent,
Player,
},
created() {
this.loadSettings()
},
methods: {
loadSettings() {
// axios.defaults.headers.common = {
// 'User-Agent': 'Nextcloud Podcast App/' + this.$version,
// }
this.$store.dispatch('getVolumeState')
},
this.$store.dispatch('loadVolume')
this.$store.dispatch('loadEpisodes')
},
}
</script>
......@@ -65,4 +62,35 @@ export default {
}
}
.app-content {
overflow: scroll;
height: calc(100vh - 98px);
}
.app-navigation {
bottom: 80px;
}
.floatControl {
position: fixed;
bottom: 75px;
right: 25px;
height: 45px;
width: 120px;
background: rgba(66, 66, 66, 0.48);
z-index: 8001;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
border: 1px solid #464646;
cursor: pointer;
&:hover {
// background: rgba(161, 161, 161, 0.48);
background: rgba(66, 66, 66, 0.7);
}
}
</style>
<!--
- @copyright Copyright (c) 2020 Jonas Heinrich
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
......@@ -36,7 +36,9 @@
{{ t('podcast', 'Show') }}
</div>
</div>
<div v-show="showContent">
<div
v-show="showContent"
class="boxContent">
<slot />
</div>
</div>
......@@ -58,6 +60,7 @@ export default {
</script>
<style lang="scss">
.episodeContent {
.episodeContentHeader {
......@@ -66,19 +69,31 @@ export default {
padding: 10px 30px;
z-index: 60;
position: sticky;
top: 50px;
top: 0px;
background: white;
display: flex;
align-items: center;
margin-bottom: -1px;
}
h2 {
flex-grow: 1;
margin-bottom: 0px;
}
.menuToggle {
color: #1976d2;
cursor: pointer;
}
.boxContent {
a {
color: #1976d2;
}
}
}
</style>
<!--
- @copyright Copyright (c) 2020 Jonas Heinrich
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
......@@ -42,10 +42,9 @@ import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import { DashboardWidget } from '@nextcloud/vue-dashboard'
import { getRequestToken } from '@nextcloud/auth'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
const requesttoken = axios.defaults.headers.requesttoken
export default {
name: 'Dashboard',
......@@ -100,7 +99,7 @@ export default {
methods: {
fetchNotifications() {
const req = {}
axios.defaults.headers.requesttoken = requesttoken
axios.defaults.headers.requesttoken = getRequestToken()
axios.get(generateUrl('/apps/podcast/api/favorites'), req).then((response) => {
this.processNotifications(response.data)
this.state = 'ok'
......
<!--
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="podcastSectionHeader">
<h1>{{ title }}</h1>
<slot />
</div>
</template>
<script>
export default {
name: 'Header',
props: {
title: {
type: String,
default: '',
},
},
}
</script>
<style lang="scss">
.podcastSectionHeader {
padding: 0px 20px 0px 65px;
display: flex;
align-items: center;
background-image: var(--icon-podcast-header-000);
background-repeat: no-repeat;
background-position: 30px 50%;
h1 {
flex-grow: 1;
font-size: 1.6em;
margin: 25px 0px 25px 0px;
}
a {
color: #1976d2;
cursor: pointer;
}
}
</style>
<!--
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="podcastSection">
<div class="grid">
<div
v-for="(podcast, idx) in podcasts"
:key="idx"
class="podcastCard">
<router-link :to="{ path: `/browse/show/${podcast.id}`}">
<div
v-lazy:background-image="podcast.smallImageURL"
class="podcastImage" />
<span class="title">
{{ podcast.title }}
</span>
<span class="subtitle">
{{ podcast.author }}
</span>
</router-link>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ItemGrid',
props: {
podcasts: {
type: Array,
default() { return [] },
},
},
}
</script>
<style lang="scss">
.podcastSection {
margin-bottom: 20px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
grid-gap: 15px;
}
.podcastCard {
height: 220px;
background: rgba(241, 241, 241, 0.6);
border-radius: 3px;
padding: 15px;
transition: all 0.2s ease-in-out;
* {
cursor: pointer;
}
&:hover {
background: rgb(236, 236, 236);
}
.podcastImage {
background-size: cover;
background-position: center;
box-shadow: 1px 1px 2px rgba(0,0,0,.5);
border: 1px solid rgba(0,0,0,.5);
border-radius: 5px;
width: 140px;
height: 140px;
margin-bottom: 5px;
transition: opacity .4s ease;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
&.title {
font-size: 1em;
}
&.subtitle {
font-size: 0.9em;
color: #b5b1b1;
}
}
}
</style>
<!--
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div v-show="podcasts.length"
class="podcastSection">
<div class="podcastSliderWrapper">
<div
v-show="showPrev"
class="navSlider navPrev"
@click="moveSlider('left')" />
<div
ref="slider"
class="podcastSlider"
:class="[showPrev ? '' : 'hideBefore', showNext ? '' : 'hideAfter']">
<div
v-for="(podcast, idx) in podcasts"
:key="idx"
class="podcastCard">
<router-link :to="{ path: `/browse/show/${podcast.id}`}">
<div v-lazy:background-image="podcast.smallImageURL"
class="podcastImage" />
<span class="title">
{{ podcast.title }}
</span>
<span class="subtitle">
{{ podcast.author }}
</span>
</router-link>
</div>
</div>
<div
v-show="showNext"
class="navSlider navNext"
@click="moveSlider('right')" />
</div>
</div>
</template>
<script>
export default {
name: 'ItemSlider',
props: {
podcasts: {
type: Array,
default() { return [] },
},
},
data: () => ({
showPrev: false,
showNext: true,
}),
watch: {
podcasts(newValue, oldValue) {
const slider = this.$refs.slider
console.log(slider.scrollLeft, slider.scrollWidth, slider.clientWidth)
if (slider.scrollWidth > slider.clientWidth) {
this.showNext = true
}
},
},
mounted() {
const slider = this.$refs.slider
const vm = this
slider.addEventListener('scroll', function() {
if (slider.scrollLeft > 0) {
vm.showPrev = true
} else {
vm.showPrev = false
}
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
if (slider.scrollLeft === maxScrollLeft) {
vm.showNext = false
} else {
vm.showNext = true
}
})
},
methods: {
moveSlider(direction) {
const slider = this.$refs.slider
const sliderPos = slider.scrollLeft
if (direction === 'right') {
slider.scrollLeft = sliderPos + 350
} else {
slider.scrollLeft = sliderPos - 350
}
},
},
}
</script>
<style lang="scss">
.podcastSection {
margin-bottom: 20px;
padding: 0 30px;
}
.podcastSliderWrapper {
position: relative;
}
.podcastSlider {
width: 100%;
display: flex;
overflow: hidden;
overflow-x: auto;
scrollbar-width: none;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
&:before {
content: '';
display: block;
position: absolute;
left: 0px;
height: 220px;
width: 100px;
background-image: linear-gradient(to right, white 0%, rgba(255,255,255,0) 100%);
}
&:after {
content: '';
display: block;
position: absolute;
right: 0px;
height: 220px;
width: 100px;
background-image: linear-gradient(to left, white 0%, rgba(255,255,255,0) 100%);
}
&::-webkit-scrollbar {
display: none;
}
&.hideBefore:before {
display: none;
}
&.hideAfter:after {
display: none;
}
}
.navSlider {
position: absolute;
top: 60px;
height: 100px;
width: 40px;
z-index: 100;
background: rgba(241, 241, 241, 1);
border: 1px solid #ddd;
background-repeat: no-repeat;
background-position: center center;
background-size: 80%;
&:hover {
background: rgb(236, 236, 236);
background-image: var(--icon-triangle-e-000);
background-repeat: no-repeat;
background-position: center center;
background-size: 80%;
}
}
.navNext {
right: -0px;
background-image: var(--icon-triangle-e-000);
}
.navPrev {
left: -5px;
background-image: var(--icon-triangle-e-000);
transform: rotate(180deg);
}
.podcastCard {
width: 170px;
height: 220px;
margin-right: 15px;
flex-shrink: 0;
background: rgba(241, 241, 241, 0.6);
border-radius: 3px;
padding: 15px;
transition: all 0.2s ease-in-out;
* {
cursor: pointer;
}
.podcastImage {
background-size: cover;
background-position: center;
box-shadow: 1px 1px 2px rgba(0,0,0,.5);
border: 1px solid rgba(0,0,0,.5);
border-radius: 5px;
width: 140px;
height: 140px;
margin-bottom: 5px;
transition: opacity .4s ease;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&.title {
font-size: 1em;
}
&.subtitle {
font-size: 0.9em;
color: #b5b1b1;
}
&:hover {
background: rgb(236, 236, 236);
}
}
</style>
<!--
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div v-resize:debounce="onResize">
<slot />
<EmptyContent
v-show="page !== null"
icon="icon-loading"
class="tableLoading" />
</div>
</template>
<script>
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import resize from 'vue-resize-directive'
export default {
name: 'LoadMore',
components: {
EmptyContent,
},
directives: {
resize,
},
props: {
page: {
type: Number,
default: 0,
},
},
mounted() {
document.getElementById('app-content-vue').addEventListener('scroll', this.handleScroll)
},
destroyed() {
document.getElementById('app-content-vue').removeEventListener('scroll', this.handleScroll)
},
methods: {
preFill() {
if (this.page === null) {
document.getElementById('app-content-vue').removeEventListener('scroll', this.handleScroll)
} else {
const yFill = document.getElementsByClassName('tableLoading')[0].getBoundingClientRect().bottom
const playerPosY = document.getElementsByClassName('player')[0].getBoundingClientRect().top
if (yFill > 0 && yFill < playerPosY) {
this.$emit('load-more')
}
}
},
/**
* On scroll event, load more shows if bottom reached
*/
handleScroll() {
if (this.page === null) {
document.getElementById('app-content-vue').removeEventListener('scroll', this.handleScroll)
} else {
const appContent = document.getElementById('app-content-vue')
if (appContent.scrollTop === (appContent.scrollHeight - appContent.clientHeight)) {
this.$emit('load-more')
}
}
},
onResize() {
this.preFill()
},
},
}
</script>
<style lang="scss">
.empty-content.tableLoading {
margin-top: 10px !important;
.empty-content__icon {
margin-bottom: 0px !important;
}
}
</style>
<!--
- @copyright Copyright (c) 2020 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<Content app-name="podcast">
<Navigation
:station-data="tableData" />
<AppContent>
<Table
v-show="!pageLoading && tableData.length > 0"
v-resize="onResize"
:station-data="tableData"
:favorites="favorites"
@doPlay="doPlay"
@doFavor="doFavor" />
<EmptyContent
v-if="pageLoading"
icon="icon-loading" />
<EmptyContent
v-if="tableData.length === 0 && !pageLoading"
:icon="emptyContentIcon">
{{ emptyContentMessage }}
<template #desc>
{{ emptyContentDesc }}
</template>
</EmptyContent>
</AppContent>
</Content>
</template>
<script>
import Content from '@nextcloud/vue/dist/Components/Content'
import AppContent from '@nextcloud/vue/dist/Components/AppContent'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import Navigation from './Navigation'
import Table from './Table'
import { Howl, Howler } from 'howler'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
let audioPlayer = null
const requesttoken = axios.defaults.headers.requesttoken
export default {
name: 'Main',
components: {
Navigation,
Content,
AppContent,
Table,
EmptyContent,
},
data: () => ({
tableData: [],
pageLoading: false,
favorites: [],
queryParams: {},
}),
computed: {
player() {
return this.$store.state.player
},
emptyContentMessage() {
if (this.$route.name === 'FAVORITES') {
return t('podcast', 'No favorites yet')
} else if (this.$route.name === 'RECENT') {
return t('podcast', 'No recent stations yet')
} else if (this.$route.name === 'SEARCH') {
return t('podcast', 'No search results')
}
return 'No stations here'
},
emptyContentIcon() {
if (this.$route.name === 'FAVORITES') {
return 'icon-star'
} else if (this.$route.name === 'RECENT') {
return 'icon-recent'
} else if (this.$route.name === 'SEARCH') {
return 'icon-search'
}
return 'icon-podcast'
},
emptyContentDesc() {
if (this.$route.name === 'FAVORITES') {
return t('podcast', 'Stations you mark as favorite will show up here')
} else if (this.$route.name === 'RECENT') {
return t('podcast', 'Stations you recently played will show up here')
} else if (this.$route.name === 'SEARCH') {
return t('podcast', 'No stations were found matching your search term')
}
return t('podcast', 'No stations here')
},
},
watch: {
$route: 'onRoute',
'player.volume'(newVolume, oldVolume) {
if (audioPlayer !== null) {
audioPlayer.volume(newVolume)
}
},
'player.isPaused'(newState, oldState) {
if (newState === true && audioPlayer !== null) {
audioPlayer.pause()
} else if (newState === false && audioPlayer !== null) {
audioPlayer.play()
}
},
},
created() {
this.loadSettings()
this.loadFavorites()
},
mounted() {
this.onRoute()
this.scroll()
},
methods: {
onResize({ width, height }) {
const contentHeight = document.getElementById('app-content-vue').scrollHeight
const tableHeight = height
if (tableHeight < contentHeight) {
this.preFill()
}
},
preFill() {
const route = this.$route
this.loadStations(route.name)
},
async onRoute() {
this.tableData = []
this.pageLoading = true
const route = this.$route
this.loadStations(route.name)
},
/**
* Favor a new station by sending the information to the server
* @param {Object} station Station object
*/
async doFavor(station) {
if (this.favorites.flat().includes(station.stationuuid)) {
let stationid = null
try {
for (let i = 0, len = this.favorites.length; i < len; i++) {
if (station.stationuuid === this.favorites[i][1]) {
stationid = this.favorites[i][0]
}
}
axios.defaults.headers.requesttoken = requesttoken
await axios
.delete(generateUrl(`/apps/podcast/api/favorites/${stationid}`))
.then(response => {
this.favorites = this.favorites.filter(item => item[1] !== station.stationuuid)
})
} catch (error) {
showError(t('podcast', 'Could not remove station from favorites'))
}
} else {
try {
let stationSrc = ''
if (!station.url_resolved) {
stationSrc = station.urlresolved
} else {
stationSrc = station.url_resolved
}
const stationMap = {
id: -1,
name: station.name.toString(),
urlresolved: stationSrc.toString(),
favicon: station.favicon.toString(),
stationuuid: station.stationuuid.toString(),
bitrate: station.bitrate.toString(),
country: station.country.toString(),
language: station.language.toString(),
homepage: station.homepage.toString(),
codec: station.codec.toString(),
tags: station.tags.toString(),
}
axios.defaults.headers.requesttoken = requesttoken
await axios
.post(generateUrl('/apps/podcast/api/favorites'), stationMap)
.then(response => {
this.favorites.push([response.data.id, station.stationuuid])
})
} catch (error) {
showError(t('podcast', 'Could not favor station'))
}
}
},
/**
* Start playing a podcast station and counting the playback
* @param {Object} station Station object
*/
async doPlay(station) {
const vm = this
vm.$store.dispatch('isBuffering', true)
if (audioPlayer !== null) {
audioPlayer.fade(vm.player.volume, 0, 500)
}
vm.$store.dispatch('setTitle', station.name)
let stationSrc = ''
if (!station.url_resolved) {
stationSrc = station.urlresolved
} else {
stationSrc = station.url_resolved
}
Howler.unload()
audioPlayer = new Howl({
src: stationSrc,
html5: true,
volume: vm.player.volume,
onplay() {
vm.$store.dispatch('isPlaying', true)
vm.$store.dispatch('isBuffering', false)
},
onpause() {
vm.$store.dispatch('isPlaying', false)
vm.$store.dispatch('isBuffering', false)
},
onend() {
showError(t('podcast', 'Lost connection to podcast episode stream, retrying ...'))
vm.$store.dispatch('isPlaying', false)
vm.$store.dispatch('isBuffering', true)
},
})
audioPlayer.unload()
audioPlayer.play()
audioPlayer.fade(0, vm.player.volume, 500)
/* Count click */
try {
delete axios.defaults.headers.requesttoken
axios.get(this.$apiUrl + '/json/url/' + station.stationuuid)
} catch (error) {
showError(t('podcast', 'Unable to count play on remote API'))
}
/* Put into recent stations */
try {
let stationSrc = ''
if (!station.url_resolved) {
stationSrc = station.urlresolved
} else {
stationSrc = station.url_resolved
}
const stationMap = {
id: -1,
name: station.name.toString(),
urlresolved: stationSrc.toString(),
favicon: station.favicon.toString(),
stationuuid: station.stationuuid.toString(),
bitrate: station.bitrate.toString(),
country: station.country.toString(),
language: station.language.toString(),
homepage: station.homepage.toString(),
codec: station.codec.toString(),
tags: station.tags.toString(),
}
axios.defaults.headers.requesttoken = requesttoken
await axios
.post(generateUrl('/apps/podcast/api/recent'), stationMap)
} catch (error) {
showError(t('podcast', 'Could not add station to recent list'))
}
},
async loadStations(menuState = 'TOP') {
const vm = this
const queryBase = this.$apiUrl + '/json/stations'
let queryURI = queryBase
let sortBy = 'clickcount'
if (vm.$route.name === 'CATEGORIES') {
if (vm.$route.path === '/categories') {
vm.tableData = [
{
name: t('podcast', 'Countries'),
type: 'folder',
path: '/categories/countries',
},
{
name: t('podcast', 'States'),
type: 'folder',
path: '/categories/states',
},
{
name: t('podcast', 'Languages'),
type: 'folder',
path: '/categories/languages',
},
{
name: t('podcast', 'Tags'),
type: 'folder',
path: '/categories/tags',
},
]
vm.pageLoading = false
return true
} else if (vm.$route.params.category === 'tags') {
if (vm.$route.params.query) {
queryURI = this.$apiUrl + '/json/stations/search?tag=' + vm.$route.params.query + '&tagExact=true'
} else {
queryURI = this.$apiUrl + '/json/tags'
}
} else if (vm.$route.params.category === 'countries') {
if (vm.$route.params.query) {
queryURI = this.$apiUrl + '/json/stations/search?country=' + vm.$route.params.query + '&countryExact=true'
} else {
queryURI = this.$apiUrl + '/json/countries'
}
} else if (vm.$route.params.category === 'states') {
if (vm.$route.params.query) {
queryURI = this.$apiUrl + '/json/stations/search?state=' + vm.$route.params.query + '&stateExact=true'
} else {
queryURI = this.$apiUrl + '/json/states'
}
} else if (vm.$route.params.category === 'languages') {
if (vm.$route.params.query) {
queryURI = this.$apiUrl + '/json/stations/search?language=' + vm.$route.params.query + '&languageExact=true'
} else {
queryURI = this.$apiUrl + '/json/languages'
}
}
}
// Skip loading more stations on certain sites
if (vm.tableData.length > 0
&& (vm.$route.name === 'FAVORITES'
|| vm.$route.name === 'RECENT'
|| vm.$route.name === 'CATEGORIES')) {
return true
}
if (menuState === 'TOP') {
sortBy = 'clickcount'
} else if (menuState === 'NEW') {
sortBy = 'lastchangetime'
} else if (menuState === 'SEARCH') {
const searchQuery = vm.$route.params.query
queryURI = queryBase + '/byname/' + searchQuery
} else if (menuState === 'FAVORITES') {
queryURI = generateUrl('/apps/podcast/api/favorites')
} else if (menuState === 'RECENT') {
queryURI = generateUrl('/apps/podcast/api/recent')
}
if (menuState !== 'CATEGORIES') {
vm.queryParams = {
limit: 20,
order: sortBy,
reverse: true,
offset: vm.tableData.length,
}
} else {
vm.queryParams = {}
}
try {
if (menuState === 'FAVORITES' || menuState === 'RECENT') {
axios.defaults.headers.requesttoken = requesttoken
} else {
delete axios.defaults.headers.requesttoken
}
await axios.get(queryURI, {
params: vm.queryParams,
})
.then(function(response) {
for (let i = 0; i < response.data.length; i++) {
const obj = response.data[i]
if (!obj.stationuuid) {
response.data[i].type = 'folder'
response.data[i].path = vm.$route.path + '/' + obj.name
}
}
vm.tableData = vm.tableData.concat(response.data)
vm.pageLoading = false
})
} catch (error) {
showError(t('podcast', 'Could not fetch stations from remote API'))
}
},
/**
* On scroll event, load more stations if bottom reached
*/
scroll() {
window.onscroll = () => {
if ((window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
const route = this.$route
this.loadStations(route.name)
}
}
},
loadSettings() {
// axios.defaults.headers.common = {
// 'User-Agent': 'Nextcloud Podcast App/' + this.$version,
// }
this.$store.dispatch('getVolumeState')
},
async loadFavorites() {
const vm = this
try {
axios.defaults.headers.requesttoken = requesttoken
await axios.get(generateUrl('/apps/podcast/api/favorites'))
.then(function(response) {
const favorites = []
for (let i = 0, len = response.data.length; i < len; i++) {
favorites.push([response.data[i].id, response.data[i].stationuuid])
}
vm.favorites = favorites
})
} catch (error) {
showError(t('podcast', 'Unable to load favorites'))
}
},
},
}
</script>
<style>
@media only screen and (min-width: 1024px) {
.app-navigation-toggle {
display: none;
}
}
</style>
<!--
- @copyright Copyright (c) 2020 Jonas Heinrich
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
......@@ -20,21 +20,23 @@
-
-->
<template>
<div
class="podcastHeaderBg"
:style="{ backgroundImage: `url(${imgurl})` }">
<div :style="{ backgroundImage: `url(${imgurl})` }"
class="podcastHeaderBg">
<div class="podcastHeader">
<div
class="podcastImage"
:style="{ backgroundImage: `url(${imgurl})` }" />
<div :style="{ backgroundImage: `url(${imgurl})` }"
class="podcastImage" />
<div class="podcastDescription">
<h1>{{ title }}</h1>
<div class="podcastAuthor">
by <a :href="htmlURL" target="_blank">{{ author }}</a>
{{ t('podcast', 'by') }} <a :href="htmlurl" :target="isshow ? '_blank' : ''">{{ author }}</a>
</div>
<div class="podcastControls">
<button class="icon-add-white podcastButton button primary new-button">
{{ t('podcast', 'Subscribe') }}
<div
v-show="isshow"
class="podcastControls">
<button class="icon-add-white podcastButton button new-button"
:class="dateadded ? 'icon-delete' : 'icon-add-white primary'"
@click="doSubscribe()">
{{ getSubscribeText }}
</button>
<ul class="podcastCategory">
<a v-for="(category, idx) in categories"
......@@ -48,6 +50,8 @@
</div>
<vue-show-more-text
:text="description"
:more-text="t('podcast', 'Show more')"
:less-text="t('podcast', 'Show less')"
additional-container-css="padding: 0px;" />
<slot />
</div>
......@@ -57,7 +61,9 @@
<script>
import vueShowMoreText from 'vue-show-more-text'
import { getCategoryName } from '../services/podcastApi'
import { ShowApi } from './../services/ShowApi'
const apiClient = new ShowApi()
export default {
name: 'MediaHeader',
......@@ -65,6 +71,10 @@ export default {
vueShowMoreText,
},
props: {
podcastid: {
type: Number,
default: null,
},
imgurl: {
type: String,
default: '',
......@@ -77,7 +87,7 @@ export default {
type: String,
default: '',
},
htmlURL: {
htmlurl: {
type: String,
default: '',
},
......@@ -91,10 +101,30 @@ export default {
type: String,
default: '',
},
isshow: {
type: Boolean,
default: false,
},
dateadded: {
type: Number,
default: null,
},
},
computed: {
getSubscribeText() {
if (this.dateadded) {
return t('podcast', 'Unsubscribe')
} else {
return t('podcast', 'Subscribe')
}
},
},
methods: {
doSubscribe() {
this.$emit('do-subscribe')
},
getCategoryName(categoryid) {
return getCategoryName(categoryid)
return apiClient.getCategoryName(categoryid)
},
},
}
......@@ -106,6 +136,7 @@ export default {
background-size: cover;
background-position: center center;
background-attachment: fixed;
transition: opacity .4s ease;
}
.podcastHeader {
......@@ -122,6 +153,7 @@ export default {
.button {
text-shadow: 0px 0px 0px;
flex-shrink: 0;
}
.podcastImage {
......@@ -133,10 +165,11 @@ export default {
box-shadow: 0 4px 60px rgba(0,0,0,.5);
margin-right: 25px;
border-radius: 5px;
transition: opacity .4s ease;
}
.podcastDescription {
max-width: 500px;
max-width: 700px;
width: 100%;
color: #ddd;
......@@ -148,7 +181,7 @@ export default {
}
.podcastAuthor {
padding-bottom: 5px;
padding-bottom: 10px;
a {
color: white;
......@@ -162,25 +195,32 @@ export default {
.button {
margin-right: 10px;
margin-bottom: 5px;
margin-top: 0px;
align-self: start;
}
}
ul.podcastCategory li {
display: inline;
border: 1px solid var(--color-text-maxcontrast);
border-radius: var(--border-radius);
margin-right: 5px;
cursor: pointer;
color: var(--color-text-maxcontrast);
padding: 3px 6px;
}
ul.podcastCategory {
li {
display: inline-block;
border: 1px solid var(--color-text-maxcontrast);
border-radius: var(--border-radius);
margin-right: 5px;
cursor: pointer;
color: var(--color-text-maxcontrast);
padding: 0px 7px;
margin-bottom: 5px;
&:hover {
border: 1px solid white;
color: white;
}
}
ul.podcastCategory li:hover {
border: 1px solid white;
color: white;
}
}
}
.podcastButton {
......
<!--
- @copyright Copyright (c) 2020 Jonas Heinrich
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
......@@ -47,10 +47,6 @@
</AppNavigationCounter>
</AppNavigationItem>
</template>
<template #footer>
<Player
:pinned="true" />
</template>
</AppNavigation>
</template>
......@@ -58,7 +54,6 @@
import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation'
import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter'
import Player from './Player'
export default {
name: 'Navigation',
......@@ -66,7 +61,6 @@ export default {
AppNavigation,
AppNavigationItem,
AppNavigationCounter,
Player,
},
props: {
stationData: {
......
<!--
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div
class="eq-animation"
:class="{ paused: paused}">
<div class="-amp-video-eq-col">
<div class="-amp-video-eq-1-1" />
<div class="-amp-video-eq-1-2" />
</div>
<div class="-amp-video-eq-col">
<div class="-amp-video-eq-2-1" />
<div class="-amp-video-eq-2-2" />
</div>
<div class="-amp-video-eq-col">
<div class="-amp-video-eq-3-1" />
<div class="-amp-video-eq-3-2" />
</div>
<div class="-amp-video-eq-col">
<div class="-amp-video-eq-4-1" />
<div class="-amp-video-eq-4-2" />
</div>
</div>
</template>
<script>
export default {
name: 'PlayAnimation',
props: {
paused: {
type: Boolean,
default: false,
},
},
}
</script>
<style lang="scss">
.eq-animation {
align-items: flex-end;
display: flex;
width: 20px;
height: 12px;
overflow: hidden;
opacity: 0.8;
position: relative;
float: left;
top: 5px;
margin-right: 5px;
.-amp-video-eq-col {
flex: 1;
position: relative;
height: 100%;
margin-right: 1px;
div {
animation-name: amp-video-eq-animation;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: alternate;
background-color: rgb(0, 130, 201);
position: absolute;
width: 100%;
height: 100%;
transform: translateY(100%);
will-change: transform;
}
}
&.paused .-amp-video-eq-col div {
animation-name: unset;
transform: translateY(65%);
}
}
.-amp-video-eq-1-1 {
animation-duration: 0.3s;
}
.-amp-video-eq-1-2 {
animation-duration: 0.45s;
}
.-amp-video-eq-2-1 {
animation-duration: 0.5s;
}
.-amp-video-eq-2-2 {
animation-duration: 0.4s;
}
.-amp-video-eq-3-1 {
animation-duration: 0.3s;
}
.-amp-video-eq-3-2 {
animation-duration: 0.35s;
}
.-amp-video-eq-4-1 {
animation-duration: 0.4s;
}
.-amp-video-eq-4-2 {
animation-duration: 0.25s;
}
@keyframes amp-video-eq-animation {
0% {
transform: translateY(100%);
}
100% {
transform: translateY(0);
}
}
</style>
<!--
- @copyright Copyright (c) 2020 Jonas Heinrich
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
......@@ -21,146 +21,370 @@
-->
<template>
<div id="app-settings">
<div
class="wrap"
:class="{ buffering: player.isBuffering }">
<div class="player">
<div class="playerControls">
<button
class="player"
:class="player.isPlaying ? 'pause' : 'play'"
@click="togglePlay" />
class="primary icon-play-previous-white"
@click="seekEpisode(currentSeek - 10)" />
<div
class="wrap"
:class="{ buffering: isBuffering }">
<button
class="primary big"
:class="isPlaying ? 'icon-pause-white' : 'icon-play-white'"
@click="doTogglePlay" />
</div>
<button
class="primary icon-play-next-white"
@click="seekEpisode(currentSeek + 10)" />
</div>
<div class="metaControls">
<div
v-lazy:background-image="getEpisode.imgURL"
class="playerThumb"
@click="$router.push(`/browse/show/${getEpisode.podcast_id}/${getEpisode.id}`)" />
<div class="text">
<span class="title">
<a :href="`#/browse/show/${getEpisode.podcast_id}/${getEpisode.id}`">{{ getEpisode.title }}</a>
</span>
<span>
<a :href="`#/browse/show/${getEpisode.podcast_id}`">{{ getPodcastName }}</a>
</span>
</div>
</div>
<div class="playbackControls">
<span>{{ secondsToHHMMSS(currentSeek) }} / {{ secondsToHHMMSS( getEpisode.duration) }}</span>
<input
type="range"
class="rangeStyle"
min="0"
:max="getEpisode.duration"
step=".5"
:value="currentSeek"
:disabled="!getSeek"
@input="restyleInput(); changeTempSeek($event.target.value)"
@change="seekLocked = false; seekEpisode($event.target.value)">
</div>
<div
class="volumeIcon"
:class="player.volume == 0 ? 'volumeMute' : 'volumeFull'"
@click="toggleMute" />
<input
class="volume"
type="range"
name="volume"
min="0"
max="1"
step=".05"
:value="player.volume"
@input="changeVolume($event)"
@change="saveVolume($event)">
<div class="playerMetadata">
{{ player.title }}
<div class="volumeControls">
<div
class="volumeIcon"
:class="getVolume == 0 ? 'volumeMute' : 'volumeFull'"
@click="toggleMute(); restyleInput()" />
<input
class="volume rangeStyle"
type="range"
name="volume"
min="0"
max="1"
step=".05"
:value="getVolume"
@input="restyleInput(); setVolume($event.target.value)">
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
export default {
name: 'Player',
data: () => ({
tmpSeek: 0,
seekLocked: false,
}),
computed: {
player() {
return this.$store.state.player
...mapGetters([
'getVolume',
'getSeek',
'isBuffering',
'isPlaying',
'isPaused',
'getEpisode',
'getPodcastName',
'getPaused',
]),
currentSeek() {
if (this.seekLocked) {
return this.tmpSeek
} else {
return this.getSeek
}
},
},
watch: {
currentSeek() {
const vm = this
setTimeout(function() {
vm.restyleInput()
}, 1000)
},
},
mounted() {
this.restyleInput()
},
methods: {
changeVolume() {
this.$store.dispatch('changeVolume', event.target.value)
...mapActions([
'setVolume',
'seekEpisode',
'toggleMute',
'togglePlay',
'setSeek',
]),
changeTempSeek(value) {
this.seekLocked = true
this.tmpSeek = value
},
saveVolume() {
this.$store.dispatch('setVolumeState', event.target.value)
secondsToHHMMSS(seconds) {
if (!seconds) {
seconds = 0
}
let hhmmss = new Date(seconds * 1000).toISOString().substr(11, 8)
hhmmss = hhmmss.replace(/^(00:)/, '')
return hhmmss
},
toggleMute() {
this.$store.dispatch('toggleMute')
restyleInput() {
const element0 = document.getElementsByClassName('rangeStyle')[0]
let value = (element0.value - element0.min) / (element0.max - element0.min) * 100
element0.style.background = 'linear-gradient(to right, #fff 0%, #fff ' + value + '%, #70bbe4 ' + value + '%, #70bbe4 100%)'
const element1 = document.getElementsByClassName('rangeStyle')[1]
value = (element1.value - element1.min) / (element1.max - element1.min) * 100
element1.style.background = 'linear-gradient(to right, #fff 0%, #fff ' + value + '%, #70bbe4 ' + value + '%, #70bbe4 100%)'
},
togglePlay() {
this.$store.dispatch('togglePlay')
doTogglePlay() {
this.togglePlay()
this.seekEpisode(this.currentSeek)
},
},
}
</script>
<style>
<style lang="scss">
.wrap {
background: var(--color-main-background);
border: 3px solid #0082c9;
float: left;
border-radius: 50%;
margin: 10px;
.player {
position:fixed;
bottom:0px;
left:0px;
right:0px;
height:60px;
margin-bottom:0px;
background-color: #0082c9;
background-image: linear-gradient(40deg, #0082c9 0%, #30b6ff 100%);
z-index: 8001;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
width: 100%;
// gap: 20px; FIXME NOT YET SUPPORTED IN CHROME
}
.player > * {
margin-right: 20px;
}
.playerControls {
display: flex;
align-items: center;
justify-content: space-between;
button {
min-width: 34px;
min-height: 34px;
&.big {
background-position: 57% 50%;
min-width: 40px;
min-height: 40px;
background-size: 50%;
}
&.big.icon-pause-white {
background-position: 50% 50%;
}
&.icon-play-previous-white {
background-position: 45% 50%;
}
&.icon-play-next-white {
background-position: 55% 50%;
}
}
}
.player{
height:50px;
.wrap {
background: var(--color-main-background);
border: 3px solid #0082c9;
border-radius: 50%;
margin: 5px;
padding-left: 3px;
}
.buffering {
border: 3px solid #0082c9;
animation: buffering 2s infinite linear;
}
@keyframes buffering {
0% {
border-color: #0082c9;
}
50% {
border-color: var(--color-main-background);
}
100% {
border-color: #0082c9;
}
}
.metaControls {
display: flex;
align-items: center;
justify-content: space-between;
// gap: 20px; FIXME NOT YET SUPPORTED IN CHROME
.playerThumb {
width: 50px;
background-color: #0082c9;
mask-repeat: no-repeat;
mask-size: 55%;
mask-position: 70% 50%;
height: 50px;
background-size: cover;
background-position: center;
margin-right: 20px;
cursor: pointer;
}
.text {
display: flex;
flex-direction: column;
color: white;
span {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.title {
font-weight: bold;
margin-bottom: -5px;
}
a {
color: white;
&:hover {
text-decoration: underline;
}
}
}
}
.play{
mask-image: var(--icon-play-000);
transition: mask-image 0.4s ease-in-out;
}
.playbackControls {
display: flex;
align-items: center;
justify-content: space-between;
flex-grow: 1;
max-width: 700px;
span {
color: white;
white-space: nowrap;
margin-right: 10px;
}
.pause{
mask-image: var(--icon-pause-000);
mask-position: 58% 50%;
transition: mask-image 0.4s ease-in-out;
input {
width: 100%;
}
.buffering {
border: 3px solid #0082c9;
animation: buffering 2s infinite linear;
}
input[type=range] {
-webkit-appearance: none;
width: 100%;
height: 5px;
min-height: 5px;
background: linear-gradient(to right, #fff 0%, #fff 0%, #70bbe4 0%, #70bbe4 100%);
border-radius: 3px;
outline: none;
&:focus {
outline: none;
}
@keyframes buffering {
0% {
border-color: #0082c9;
}
50% {
border-color: var(--color-main-background);
}
100% {
border-color: #0082c9;
}
&::-moz-range-progress {
background: white;
}
.playerMetadata{
position: relative;
left: 5px;
top: -20px;
width: 203px;
height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&::-moz-range-track {
background: #70bbe4;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
border: none;
height: 15px;
width: 15px;
border-radius: 50%;
background: white;
}
&::-moz-range-thumb {
-webkit-appearance: none;
border: none;
height: 15px;
width: 15px;
border-radius: 50%;
background: white;
}
}
.volumeControls {
display: flex;
align-items: center;
justify-content: space-between;
.volumeIcon {
width: 25px;
width: 30px;
height: 25px;
position: relative;
left: 85px;
top: 20px;
cursor: pointer;
}
margin-right: 10px;
.volumeFull {
background-color: #0082c9;
mask-repeat: no-repeat;
mask-size: 100%;
mask-image: var(--icon-sound-000);
&.volumeFull {
background-color: white;
mask-repeat: no-repeat;
mask-size: 100%;
mask-image: var(--icon-sound-000);
}
&.volumeMute {
background-color: white;
mask-repeat: no-repeat;
mask-size: 100%;
mask-image: var(--icon-sound-off-000);
}
}
}
.volumeMute {
background-color: #0082c9;
mask-repeat: no-repeat;
mask-size: 100%;
mask-image: var(--icon-sound-off-000);
@media only screen and (max-width: 1000px) {
.volumeControls {
display: none;
}
.player {
padding-right: 10px;
}
}
.volume{
width: 165px;
display: inline-block;
position: relative;
left: 40px;
top: -12px;
@media only screen and (max-width: 800px) {
.metaControls {
.text {
display: none;
}
.playerThumb {
margin-right: 0px;
}
}
}
</style>
<!--
- @copyright Copyright (c) 2020 Jonas Heinrich
- @copyright Copyright (c) 2021 Jonas Heinrich
-
- @author Jonas Heinrich <onny@project-insanity.org>
-
......@@ -40,18 +40,20 @@
<tbody>
<tr
v-for="(episode, idx) in episodes"
:key="idx"
:class="{ selected: idx === activeItem}">
:key="idx">
<td class="iconColumn">
<div>
<blur-hash-image
hash="00PZr%"
:src="episode.imgURL" />
<div v-lazy:background-image="episode.imgURL || episode.imgurl"
class="episodeImage"
@click="doPlay(episode)">
<div :class="episodePlaying(episode.id) ? 'pause' : 'play'" />
</div>
</td>
<td
class="nameColumn"
@click="changeRoute(`/browse/show/${episode.podcast_id}/${episode.id}`)">
@click="changeRoute(`/browse/show/${podcastid(episode)}/${episode.id}`)">
<PlayAnimation
v-show="episodeLoaded(episode.id)"
:paused="isPaused(episode.id)" />
<b>{{ episode.title }}</b>
<vue-show-more-text
:text="escapedEpisodeDescription(episode.description)"
......@@ -62,16 +64,23 @@
<td class="actionColumn">
<Actions>
<ActionButton
icon="icon-play"
:icon="playButtonIcon(episode.id)"
:close-after-click="true"
@click="doPlay(idx, episode)">
{{ t('podcast', 'Play') }}
@click="doPlay(episode)">
{{ playButtonText(episode.id) }}
</ActionButton>
<ActionButton
icon="icon-info"
:close-after-click="true"
@click="changeRoute(`/browse/show/${episode.podcast_id}/${episode.id}`)">
{{ t('podcast', 'Show') }}
@click="changeRoute(`/browse/show/${podcastid(episode)}/${episode.id}`)">
{{ t('podcast', 'Go to episode') }}
</ActionButton>
<ActionButton
v-show="extended"
icon="icon-info"
:close-after-click="true"
@click="changeRoute(`/browse/show/${podcastid(episode)}`)">
{{ t('podcast', 'Go to show') }}
</ActionButton>
<ActionButton
icon="icon-download"
......@@ -84,17 +93,26 @@
:close-after-click="true">
{{ t('podcast', 'Share') }}
</ActionButton>
<ActionButton
v-show="extended"
icon="icon-delete"
:close-after-click="true"
@click="removeEpisode(episode)">
{{ t('podcast', 'Remove from queue') }}
</ActionButton>
</Actions>
</td>
<td
class="durationColumn"
@click="changeRoute(`/browse/show/${episode.podcast_id}/${episode.id}`)">
{{ episode.duration_string }}
@click="changeRoute(`/browse/show/${podcastid(episode)}/${episode.id}`)">
{{ secondsToHHMMSS(episode.duration) }}
</td>
<td
class="dateColumn"
@click="changeRoute(`/browse/show/${episode.podcast_id}/${episode.id}`)">
{{ readableTime(episode.inserted) }}
@click="changeRoute(`/browse/show/${podcastid(episode)}/${episode.id}`)">
<span :title="readableDate(episode.pubdate)">
{{ readableTimeAgo(episode.pubdate) }}
</span>
</td>
</tr>
</tbody>
......@@ -107,6 +125,8 @@ import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en'
import vueShowMoreText from 'vue-show-more-text'
import PlayAnimation from './PlayAnimation'
import { mapGetters, mapActions } from 'vuex'
TimeAgo.addDefaultLocale(en)
const timeAgo = new TimeAgo('en-US')
......@@ -117,34 +137,91 @@ export default {
Actions,
ActionButton,
vueShowMoreText,
PlayAnimation,
},
props: {
episodes: {
type: Array,
default() { return [] },
},
extended: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters([
'episodeLoaded',
'isPaused',
'episodePlaying',
]),
},
data: () => ({
activeItem: null,
}),
methods: {
...mapActions([
'removeEpisode',
'seekEpisode',
]),
playButtonText(episodeId) {
if (this.episodeLoaded(episodeId)) {
if (this.isPaused(episodeId)) {
return t('podcast', 'Resume')
} else {
return t('podcast', 'Pause')
}
} else {
return t('podcast', 'Play')
}
},
playButtonIcon(episodeId) {
if (this.episodeLoaded(episodeId)) {
if (this.isPaused(episodeId)) {
return 'icon-play'
} else {
return 'icon-pause'
}
} else {
return 'icon-play'
}
},
escapedEpisodeDescription(episodeDescription) {
episodeDescription = episodeDescription.replace(/\n/g, '')
episodeDescription = episodeDescription
.replaceAll(/\n/g, ' ')
.replaceAll('<br/>', ' ')
.replaceAll('<br />', ' ')
.replaceAll('<br>', ' ')
.replaceAll('<ul>', ' ')
.replaceAll('<li>', '')
.replaceAll('</ul>', ' ')
.replaceAll('</li>', '')
return episodeDescription
},
readableTime(datetime) {
readableDate(datetime) {
const date = new Date(datetime)
return date.toDateString()
},
readableTimeAgo(datetime) {
return timeAgo.format(Date.parse(datetime), 'twitter-minute-now')
},
downloadFile(episodeURL) {
window.open(episodeURL, 'download')
window.open(episodeURL, '_blank')
},
doPlay(idx, episode) {
this.activeItem = idx
this.$emit('doPlay', episode)
doPlay(episode) {
this.$emit('do-play', episode)
this.seekEpisode(episode.playtime)
},
changeRoute(path) {
this.$router.push({ path })
},
secondsToHHMMSS(seconds) {
return new Date(seconds * 1000).toISOString().substr(11, 8)
},
podcastid(episode) {
if (episode.podcast_id) {
return episode.podcast_id
} else {
return episode.podcastid
}
},
},
}
</script>
......@@ -169,34 +246,34 @@ table.episodeTable {
background-color: var(--color-main-background-translucent);
z-index: 60;
position: sticky;
top: 50px;
top: 0px;
th {
border-bottom: 1px solid var(--color-border);
padding: 15px;
height: 50px;
color: var(--color-text-maxcontrast);
}
th.iconColumn {
padding: 0px;
width: 115px;
}
&.iconColumn {
padding: 0px;
width: 115px;
}
th.nameColumn {
width: 100%;
}
&.nameColumn {
width: 100%;
}
th.actionColumn {
width: 72px;
}
&.actionColumn {
width: 72px;
}
th.durationColumn {
width: 90px;
}
&.durationColumn {
width: 90px;
}
th.dateColumn {
width: 130px;
&.dateColumn {
width: 130px;
}
}
}
......@@ -209,6 +286,7 @@ table.episodeTable {
transition: opacity 500ms ease 0s;
.selected {
// FIXME: Does this apply?
background: var(--color-primary-light);
}
......@@ -216,46 +294,60 @@ table.episodeTable {
cursor: pointer;
}
&:hover td {
background: var(--color-background-hover);
}
}
td {
padding: 0 15px;
font-style: normal;
border-bottom: 1px solid var(--color-border);
}
td.iconColumn {
padding-right: 0px;
padding-left: 35px;
&.iconColumn {
padding-right: 0px;
padding-left: 35px;
div {
width: 74px;
height: 74px;
.episodeImage {
width: 74px;
height: 74px;
background: #ccc;
background-size: cover;
background-position: center;
transition: opacity .4s ease;
border-radius: 5px;
}
.episodeImage:hover div {
background: rgba(0, 0, 0, 0.6);
width: 100%;
height: 100%;
border-radius: 5px;
background-position: center;
background-image: var(--icon-play-fff);
background-size: 40%;
background-repeat: no-repeat;
}
.episodeImage:hover div.pause {
background-image: var(--icon-pause-fff);
}
}
}
td.nameColumn {
overflow: hidden;
text-overflow: ellipsis;
padding-right: 0px;
span {
display: block;
max-height: 45px;
white-space: normal;
text-overflow: ellipsis;
&.nameColumn {
overflow: hidden;
color: #657786;
}
b {
color: var(--color-main-text);
user-select: none;
cursor: pointer;
font-size: 1.05em;
}
}
text-overflow: ellipsis;
padding-right: 0px;
tr:hover td {
background: var(--color-background-hover);
b {
color: var(--color-main-text);
user-select: none;
cursor: pointer;
font-size: 1.05em;
}
}
}
}
......@@ -264,37 +356,49 @@ table.episodeTable {
@media only screen and (max-width: 500px) {
table {
thead {
th.iconColumn {
width: 85px;
}
th.nameColumn {
padding-left: 0px;
}
th.actionColumn {
padding-left: 5px;
padding-right: 5px;
width: 50px;
}
th.durationColumn, th.dateColumn {
display: none;
th {
&.iconColumn {
width: 85px;
}
&.nameColumn {
padding-left: 0px;
}
&.actionColumn {
padding-left: 5px;
padding-right: 5px;
width: 50px;
}
&.durationColumn, &.dateColumn {
display: none;
}
}
}
tbody {
td.iconColumn {
padding-left: 10px;
}
td.nameColumn {
padding-left: 0px;
}
td.actionColumn {
padding-left: 5px;
padding-right: 5px;
width: 50px;
}
td.durationColumn, td.dateColumn {
display: none;
td {
&.iconColumn {
padding-left: 10px;
}
&.nameColumn {
padding-left: 0px;
}
&.actionColumn {
padding-left: 5px;
padding-right: 5px;
width: 50px;
}
&.durationColumn, &.dateColumn {
display: none;
}
}
}
}
}
[lazy=loading] {
opacity: 0;
}
[lazy=loaded] {
opacity: 1;
}
</style>