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 1165 additions and 793 deletions
......@@ -60,6 +60,7 @@ export default {
</script>
<style lang="scss">
.episodeContent {
.episodeContentHeader {
......@@ -68,24 +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>
......@@ -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'
......
......@@ -19,23 +19,44 @@
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<EmptyContent icon="icon-error">
Error loading site
<template #desc>
Feature not yet implemented
</template>
</EmptyContent>
<div class="podcastSectionHeader">
<h1>{{ title }}</h1>
<slot />
</div>
</template>
<script>
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
export default {
name: 'NotImplemented',
components: {
EmptyContent,
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>
......@@ -21,21 +21,15 @@
-->
<template>
<div class="podcastSection">
<div class="podcastSectionHeader">
<h1>{{ title }}</h1>
</div>
<div class="grid">
<div
v-for="(podcast, idx) in podcasts"
:key="idx"
class="podcastCard">
<router-link :to="{ path: `/browse/show/${podcast.id}`}">
<div v-show="podcast.smallImageURL"
<div
v-lazy:background-image="podcast.smallImageURL"
class="podcastImage" />
<div v-show="podcast.imgurl"
v-lazy:background-image="podcast.imgurl"
class="podcastImage" />
<span class="title">
{{ podcast.title }}
</span>
......@@ -49,16 +43,13 @@
</template>
<script>
export default {
name: 'ItemGrid',
props: {
title: {
type: String,
default: '',
},
podcasts: {
type: Object,
default() { return {} },
type: Array,
default() { return [] },
},
},
}
......@@ -70,31 +61,14 @@ export default {
margin-bottom: 20px;
}
.podcastSectionHeader {
padding: 10px 0px;
z-index: 60;
position: sticky;
top: 50px;
background: white;
display: flex;
align-items: center;
margin-bottom: 10px;
h1 {
flex-grow: 1;
font-size: 1.6em;
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
grid-gap: 15px;
}
.podcastCard {
height: 220px;
margin-right: 15px;
flex-shrink: 0;
background: rgba(241, 241, 241, 0.6);
border-radius: 3px;
padding: 15px;
......@@ -104,6 +78,10 @@ export default {
cursor: pointer;
}
&:hover {
background: rgb(236, 236, 236);
}
.podcastImage {
background-size: cover;
background-position: center;
......@@ -121,17 +99,17 @@ export default {
text-overflow: ellipsis;
white-space: nowrap;
display: block;
&.title {
font-size: 1em;
}
&.subtitle {
font-size: 0.9em;
color: #b5b1b1;
}
}
span.title {
font-size: 1em;
}
span.subtitle {
font-size: 0.9em;
color: #b5b1b1;
}
}
.podcastCard:hover {
background: rgb(236, 236, 236);
}
</style>
......@@ -20,18 +20,15 @@
-
-->
<template>
<div class="podcastSection">
<div class="podcastSectionHeader">
<h1>{{ title }}</h1>
<a :href="showallurl">Show all</a>
</div>
<div v-show="podcasts.length"
class="podcastSection">
<div class="podcastSliderWrapper">
<div
v-show="showPrev"
class="navSlider navPrev"
@click="moveSlider('left')" />
<div
id="slider"
ref="slider"
class="podcastSlider"
:class="[showPrev ? '' : 'hideBefore', showNext ? '' : 'hideAfter']">
<div
......@@ -63,14 +60,6 @@
export default {
name: 'ItemSlider',
props: {
title: {
type: String,
default: '',
},
showallurl: {
type: String,
default: '',
},
podcasts: {
type: Array,
default() { return [] },
......@@ -80,8 +69,17 @@ export default {
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.$el.querySelector('#slider')
const slider = this.$refs.slider
const vm = this
slider.addEventListener('scroll', function() {
if (slider.scrollLeft > 0) {
......@@ -99,7 +97,7 @@ export default {
},
methods: {
moveSlider(direction) {
const slider = this.$el.querySelector('#slider')
const slider = this.$refs.slider
const sliderPos = slider.scrollLeft
if (direction === 'right') {
slider.scrollLeft = sliderPos + 350
......@@ -116,26 +114,7 @@ export default {
.podcastSection {
margin-bottom: 20px;
}
.podcastSectionHeader {
padding: 10px 0px;
z-index: 60;
position: sticky;
top: 50px;
background: white;
display: flex;
align-items: center;
margin-bottom: 10px;
h1 {
flex-grow: 1;
font-size: 1.6em;
}
a {
color: #1976d2;
cursor: pointer;
}
padding: 0 30px;
}
.podcastSliderWrapper {
......@@ -149,6 +128,7 @@ export default {
overflow-x: auto;
scrollbar-width: none;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
&:before {
content: '';
......@@ -169,14 +149,18 @@ export default {
width: 100px;
background-image: linear-gradient(to left, white 0%, rgba(255,255,255,0) 100%);
}
}
.podcastSlider.hideBefore:before {
display: none;
}
&::-webkit-scrollbar {
display: none;
}
&.hideBefore:before {
display: none;
}
.podcastSlider.hideAfter:after {
display: none;
&.hideAfter:after {
display: none;
}
}
.navSlider {
......@@ -190,14 +174,14 @@ export default {
background-repeat: no-repeat;
background-position: center center;
background-size: 80%;
}
.navSlider:hover {
background: rgb(236, 236, 236);
background-image: var(--icon-triangle-e-000);
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 {
......@@ -243,17 +227,19 @@ export default {
white-space: nowrap;
display: block;
}
span.title {
&.title {
font-size: 1em;
}
span.subtitle {
&.subtitle {
font-size: 0.9em;
color: #b5b1b1;
}
}
.podcastCard:hover {
background: rgb(236, 236, 236);
&: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>
......@@ -20,21 +20,21 @@
-
-->
<template>
<div v-lazy:background-image="imgurl"
<div :style="{ backgroundImage: `url(${imgurl})` }"
class="podcastHeaderBg">
<div class="podcastHeader">
<div v-lazy:background-image="imgurl"
<div :style="{ backgroundImage: `url(${imgurl})` }"
class="podcastImage" />
<div class="podcastDescription">
<h1>{{ title }}</h1>
<div class="podcastAuthor">
by <a :href="htmlurl" :target="isshow ? '_blank' : ''">{{ author }}</a>
{{ t('podcast', 'by') }} <a :href="htmlurl" :target="isshow ? '_blank' : ''">{{ author }}</a>
</div>
<div
v-show="isshow"
class="podcastControls">
<button class="icon-add-white podcastButton button new-button"
:class="isSubscribed ? 'icon-delete' : 'icon-add-white primary'"
:class="dateadded ? 'icon-delete' : 'icon-add-white primary'"
@click="doSubscribe()">
{{ getSubscribeText }}
</button>
......@@ -50,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>
......@@ -103,13 +105,14 @@ export default {
type: Boolean,
default: false,
},
dateadded: {
type: Number,
default: null,
},
},
computed: {
isSubscribed() {
return this.$store.getters.showExists(this.podcastid)
},
getSubscribeText() {
if (this.isSubscribed) {
if (this.dateadded) {
return t('podcast', 'Unsubscribe')
} else {
return t('podcast', 'Subscribe')
......@@ -118,7 +121,7 @@ export default {
},
methods: {
doSubscribe() {
this.$emit('doSubscribe')
this.$emit('do-subscribe')
},
getCategoryName(categoryid) {
return apiClient.getCategoryName(categoryid)
......@@ -166,7 +169,7 @@ export default {
}
.podcastDescription {
max-width: 500px;
max-width: 700px;
width: 100%;
color: #ddd;
......@@ -198,23 +201,26 @@ export default {
}
}
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;
}
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 {
......
......@@ -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>
......@@ -21,263 +21,370 @@
-->
<template>
<div id="app-settings">
<div
v-lazy:background-image="getEpisodeImage"
class="playerThumb" />
<input
v-model="seek"
class="seek"
type="range"
name="seek"
min="0"
:max="player.duration"
step=".5"
@change="seekEpisode($event.target.value)">
<div class="playbackInfo">
<span>{{ seekHHMMSS }}</span>
<span>{{ durationHHMMSS }}</span>
</div>
<span class="playerTitle">{{ getEpisodeTitle }}</span>
<div class="player">
<div class="playerControls">
<div
class="seekButton seekPrev"
@click="seekEpisode(seek - 10)" />
<button
class="primary icon-play-previous-white"
@click="seekEpisode(currentSeek - 10)" />
<div
class="wrap"
:class="{ buffering: player.isBuffering }">
:class="{ buffering: isBuffering }">
<button
class="player"
:class="player.isPlaying ? 'pause' : 'play'"
@click="togglePlay" />
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
class="seekButton seekNext"
@click="seekEpisode(seek + 10)" />
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="volumeControls">
<div
class="volumeIcon"
:class="player.volume == 0 ? 'volumeMute' : 'volumeFull'"
@click="toggleMute" />
:class="getVolume == 0 ? 'volumeMute' : 'volumeFull'"
@click="toggleMute(); restyleInput()" />
<input
class="volume"
class="volume rangeStyle"
type="range"
name="volume"
min="0"
max="1"
step=".05"
:value="player.volume"
@input="changeVolume($event)"
@change="saveVolume($event)">
:value="getVolume"
@input="restyleInput(); setVolume($event.target.value)">
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { mapGetters, mapActions } from 'vuex'
export default {
name: 'Player',
data: () => ({
tmpSeek: 0,
seekLocked: false,
}),
computed: {
...mapGetters([
'episodeTitle',
'episodeImage',
'playerVolume',
'getVolume',
'getSeek',
'isBuffering',
'isPlaying',
'isPaused',
'getEpisode',
'getPodcastName',
'getPaused',
]),
player() {
return this.$store.state.player
},
seek: {
get() {
return this.$store.state.player.seek
},
set(value) {
this.$store.state.player.seek = value
},
},
getEpisodeTitle() {
const title = this.episodeTitle
return title
},
getEpisodeImage() {
const image = this.episodeImage
return image
},
getPlayerVolume() {
const volume = this.playerVolume
return volume
currentSeek() {
if (this.seekLocked) {
return this.tmpSeek
} else {
return this.getSeek
}
},
durationHHMMSS() {
const seconds = this.player.duration
return new Date(seconds * 1000).toISOString().substr(11, 8)
},
seekHHMMSS() {
const seconds = this.seek
return new Date(seconds * 1000).toISOString().substr(11, 8)
},
watch: {
currentSeek() {
const vm = this
setTimeout(function() {
vm.restyleInput()
}, 1000)
},
},
mounted() {
this.restyleInput()
},
methods: {
changeVolume() {
this.$store.dispatch('setVolume', event.target.value)
},
saveVolume() {
this.$store.dispatch('saveVolume', event.target.value)
...mapActions([
'setVolume',
'seekEpisode',
'toggleMute',
'togglePlay',
'setSeek',
]),
changeTempSeek(value) {
this.seekLocked = true
this.tmpSeek = value
},
seekEpisode(time) {
this.$store.dispatch('seekEpisode', time)
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">
#app-settings {
display: flex;
flex-direction: column;
align-items: center;
.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%;
}
}
}
.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: 200px;
height: 200px;
background: #ddd;
width: 50px;
height: 50px;
background-size: cover;
background-position: center;
margin-right: 20px;
cursor: pointer;
}
.seek {
width: 200px;
}
.playbackInfo {
.text {
display: flex;
width: 200px;
margin-top: -5px;
}
flex-direction: column;
color: white;
.playbackInfo span {
width: 100%;
}
span {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.playbackInfo span:nth-child(2) {
text-align: right;
}
&.title {
font-weight: bold;
margin-bottom: -5px;
}
.playerTitle {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: bold;
}
a {
color: white;
.playerControls {
display: flex;
align-items: center;
justify-content: center;
&:hover {
text-decoration: underline;
}
}
}
}
.seekButton {
width: 35px;
height: 35px;
border: 2px solid #0082c9;
border-radius: 50%;
cursor: pointer;
background-repeat: no-repeat;
background-position: 40% 55%;
}
}
.seekNext {
background-image: var(--icon-play-next-000);
background-position: 50% 55%;
}
.playbackControls {
display: flex;
align-items: center;
justify-content: space-between;
flex-grow: 1;
max-width: 700px;
.seekPrev {
background-image: var(--icon-play-previous-000);
span {
color: white;
white-space: nowrap;
margin-right: 10px;
}
.wrap {
background: var(--color-main-background);
border: 3px solid #0082c9;
float: left;
border-radius: 50%;
margin: 10px;
input {
width: 100%;
}
.player{
height:50px;
width: 50px;
background-color: #0082c9;
mask-repeat: no-repeat;
mask-size: 55%;
mask-position: 70% 50%;
}
}
.play{
mask-image: var(--icon-play-000);
transition: mask-image 0.4s ease-in-out;
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;
}
.pause{
mask-image: var(--icon-pause-000);
mask-position: 58% 50%;
transition: mask-image 0.4s ease-in-out;
&::-moz-range-progress {
background: white;
}
.buffering {
border: 3px solid #0082c9;
animation: buffering 2s infinite linear;
&::-moz-range-track {
background: #70bbe4;
}
@keyframes buffering {
0% {
border-color: #0082c9;
}
50% {
border-color: var(--color-main-background);
}
100% {
border-color: #0082c9;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
border: none;
height: 15px;
width: 15px;
border-radius: 50%;
background: white;
}
.volumeControls {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
&::-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;
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;
@media only screen and (max-width: 800px) {
.metaControls {
.text {
display: none;
}
.playerThumb {
margin-right: 0px;
}
}
}
</style>
......@@ -40,33 +40,20 @@
<tbody>
<tr
v-for="(episode, idx) in episodes"
:key="idx"
:class="{ selected: isPlaying(episode.id)}">
:key="idx">
<td class="iconColumn">
<div v-lazy:background-image="episode.imgURL"
class="episodeImage" />
<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}`)">
<div class="eq-animation">
<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>
@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)"
......@@ -77,7 +64,7 @@
<td class="actionColumn">
<Actions>
<ActionButton
:icon="isPlaying(episode.id) ? 'icon-pause' : 'icon-play'"
:icon="playButtonIcon(episode.id)"
:close-after-click="true"
@click="doPlay(episode)">
{{ playButtonText(episode.id) }}
......@@ -85,8 +72,15 @@
<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"
......@@ -99,18 +93,25 @@
: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}`)">
<span :title="readableDate(episode.inserted)">
{{ readableTimeAgo(episode.inserted) }}
@click="changeRoute(`/browse/show/${podcastid(episode)}/${episode.id}`)">
<span :title="readableDate(episode.pubdate)">
{{ readableTimeAgo(episode.pubdate) }}
</span>
</td>
</tr>
......@@ -124,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')
......@@ -134,31 +137,62 @@ export default {
Actions,
ActionButton,
vueShowMoreText,
PlayAnimation,
},
props: {
episodes: {
type: Array,
default() { return [] },
},
extended: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters([
'episodeLoaded',
'isPaused',
'episodePlaying',
]),
},
methods: {
...mapActions([
'removeEpisode',
'seekEpisode',
]),
playButtonText(episodeId) {
if (this.isPlaying(episodeId)) {
return t('podcast', 'Pause')
} else if (this.isPaused(episodeId)) {
return t('podcast', 'Resume')
if (this.episodeLoaded(episodeId)) {
if (this.isPaused(episodeId)) {
return t('podcast', 'Resume')
} else {
return t('podcast', 'Pause')
}
} else {
return t('podcast', 'Play')
}
},
isPlaying(episodeId) {
return this.$store.getters.isPlaying(episodeId)
},
isPaused(episodeId) {
return this.$store.getters.isPaused(episodeId)
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
},
readableDate(datetime) {
......@@ -169,14 +203,25 @@ export default {
return timeAgo.format(Date.parse(datetime), 'twitter-minute-now')
},
downloadFile(episodeURL) {
window.open(episodeURL, 'download')
window.open(episodeURL, '_blank')
},
doPlay(episode) {
this.$emit('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>
......@@ -201,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;
}
}
}
......@@ -241,6 +286,7 @@ table.episodeTable {
transition: opacity 500ms ease 0s;
.selected {
// FIXME: Does this apply?
background: var(--color-primary-light);
}
......@@ -248,42 +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;
.episodeImage {
width: 74px;
height: 74px;
background: #ccc;
background-size: cover;
background-position: center;
transition: opacity .4s ease;
&.iconColumn {
padding-right: 0px;
padding-left: 35px;
.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;
b {
color: var(--color-main-text);
user-select: none;
cursor: pointer;
font-size: 1.05em;
}
}
&.nameColumn {
overflow: hidden;
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;
}
}
}
}
......@@ -292,118 +356,44 @@ 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;
}
}
}
}
}
.eq-animation {
align-items: flex-end;
display: none;
width: 20px;
height: 12px;
overflow: hidden;
opacity: 0.8;
position: relative;
float: left;
top: 5px;
margin-right: 5px;
}
.eq-animation .-amp-video-eq-col {
flex: 1;
position: relative;
height: 100%;
margin-right: 1px;
}
.eq-animation .-amp-video-eq-col 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;
}
.-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);
}
}
table.episodeTable tr.selected .eq-animation {
display: flex;
}
[lazy=loading] {
opacity: 0;
}
......
......@@ -28,10 +28,8 @@ import { translate, translatePlural } from '@nextcloud/l10n'
import App from './App'
import VueResizeObserver from 'vue-resize-observer'
import VueLazyload from 'vue-lazyload'
Vue.use(VueResizeObserver)
Vue.use(VueLazyload)
Vue.prototype.t = translate
......@@ -39,7 +37,7 @@ Vue.prototype.n = translatePlural
Vue.prototype.OC = window.OC
Vue.prototype.OCA = window.OCA
Vue.prototype.$apiUrl = 'https://api.fyyd.de/0.2'
Vue.prototype.$version = '0.0.1'
Vue.prototype.$version = '0.3.1'
export default new Vue({
el: '#content',
......
......@@ -29,7 +29,7 @@ import BrowseAll from './views/BrowseAll'
import Show from './views/Show'
import Episode from './views/Episode'
import Browse from './views/Browse'
import NotImplemented from './views/NotImplemented'
import Listening from './views/Listening'
Vue.use(Router)
......@@ -43,7 +43,7 @@ const router = new Router({
},
{
path: '/listening',
component: NotImplemented,
component: Listening,
name: 'LISTENING',
},
{
......@@ -59,12 +59,12 @@ const router = new Router({
{
path: '/browse/:category',
component: BrowseAll,
name: 'BROWSE',
name: 'BROWSE_CATEGORY',
},
{
path: '/browse/category/:categoryId',
component: BrowseAll,
name: 'BROWSE',
name: 'BROWSE_CATEGORYID',
},
{
path: '/browse/show/:id',
......
......@@ -23,8 +23,6 @@
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
const requesttoken = axios.defaults.headers.requesttoken
export class EpisodeApi {
url(url) {
......@@ -35,11 +33,16 @@ export class EpisodeApi {
addEpisode(episode) {
episode = {
id: episode.id,
imgurl: episode.smallImageURL,
imgURL: episode.imgURL,
title: episode.title,
author: episode.author,
pubdate: episode.pubdate,
duration: episode.duration,
playtime: 0,
lastplayed: Date.now(),
enclosure: episode.enclosure,
description: episode.description.substring(0, 100), // Trim string description, because of MySQL max length field
podcast_id: episode.podcast_id,
}
axios.defaults.headers.requesttoken = requesttoken
return axios.post(this.url('/episodes'), episode)
.then(
(response) => {
......@@ -55,7 +58,6 @@ export class EpisodeApi {
}
removeEpisode(episode) {
axios.defaults.headers.requesttoken = requesttoken
return axios.delete(this.url(`/episodes/${episode.id}`))
.then(
(response) => {
......@@ -70,9 +72,71 @@ export class EpisodeApi {
})
}
loadEpisodes(episode) {
axios.defaults.headers.requesttoken = requesttoken
return axios.get(this.url('/episodes'))
queryEpisodes(podcastId = null, page = 0, sortBy = 'lastplayed') {
let params = {}
if (podcastId) {
params = {
podcast_id: podcastId,
count: 20,
page,
}
} else {
params = {
count: 20,
page,
sortBy,
}
}
return axios.get(this.url('/episodes'), {
params,
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
}
)
.catch((err) => {
return Promise.reject(err)
})
}
queryEpisode(episodeId) {
return axios.get(this.url('/episodes'), {
params: {
episode_id: episodeId,
},
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
}
)
.catch((err) => {
return Promise.reject(err)
})
}
updateEpisode({ episode, playtime } = {}) {
episode = {
id: episode.id,
imgURL: episode.imgURL,
title: episode.title,
pubdate: episode.pubdate,
duration: episode.duration,
playtime,
lastplayed: Date.now(),
enclosure: episode.enclosure,
description: episode.description.substring(0, 100), // Trim string description, because of MySQL max length field
podcast_id: episode.podcast_id,
}
return axios.put(this.url(`/episodes/${episode.id}`), episode)
.then(
(response) => {
return Promise.resolve(response.data)
......
/*
* @copyright Copyright (c) 2021 Jonas Heinrich <onny@project-insanity.org>
*
* @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/>.
*
*/
import axios from '@nextcloud/axios'
export class FyydApi {
url() {
const url = 'https://api.fyyd.de/0.2'
return url
}
queryPodcast(podcastId) {
delete axios.defaults.headers.requesttoken
return axios.get(this.url() + '/podcast', {
params: {
podcast_id: podcastId,
},
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
}
)
.catch((err) => {
return Promise.reject(err)
})
}
queryEpisodes(podcastId, page) {
delete axios.defaults.headers.requesttoken
return axios.get(this.url() + '/podcast/episodes', {
params: {
podcast_id: podcastId,
count: 20,
page,
},
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
}
)
.catch((err) => {
return Promise.reject(err)
})
}
queryEpisode(episodeId) {
delete axios.defaults.headers.requesttoken
return axios.get(this.url() + '/episode', {
params: {
episode_id: episodeId,
},
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
}
)
.catch((err) => {
return Promise.reject(err)
})
}
queryList(listName, count = 10) {
delete axios.defaults.headers.requesttoken
let queryURL = ''
if (listName === 'hot') {
queryURL = this.url() + '/feature/podcast/hot'
} else if (listName === 'latest') {
queryURL = this.url() + '/podcast/latest'
} else {
queryURL = this.url() + '/category?category_id=' + listName
}
return axios.get(queryURL, {
params: {
count,
},
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
}
)
.catch((err) => {
return Promise.reject(err)
})
}
}
......@@ -21,17 +21,16 @@
*/
import { Howl, Howler } from 'howler'
import { showError } from '@nextcloud/dialogs'
import store from './../store/main.js'
let audioPlayer = null
export class Player {
doPlay(src) {
load(src) {
if (audioPlayer !== null) {
audioPlayer.fade(store.state.player.volume, 0, 500)
audioPlayer.fade(store.getters.getVolume, 0, 500)
Howler.unload()
} else {
this.updateSeek()
......@@ -40,46 +39,39 @@ export class Player {
audioPlayer = new Howl({
src,
html5: true,
volume: store.state.player.volume,
volume: store.getters.getVolume,
onLoad() {
store.dispatch('setBuffering', true)
},
onplay() {
const duration = audioPlayer.duration()
store.dispatch('setPlaying', true)
store.dispatch('setBuffering', false)
store.dispatch('setDuration', duration)
store.dispatch('setPausing', false)
},
onpause() {
store.dispatch('setPlaying', false)
store.dispatch('setPausing', true)
store.dispatch('setBuffering', false)
},
onend() {
showError(t('podcast', 'Lost connection to podcast station, retrying ...'))
store.dispatch('setPlaying', false)
store.dispatch('setBuffering', true)
},
})
audioPlayer.unload()
audioPlayer.play()
audioPlayer.fade(0, store.state.player.volume, 500)
}
updateSeek() {
const vm = this
setTimeout(function() {
if (audioPlayer !== null) {
const seek = audioPlayer.seek()
store.dispatch('setSeek', seek)
vm.updateSeek()
}
}, 1000)
play() {
audioPlayer.play()
audioPlayer.fade(0, store.getters.getVolume, 500)
}
doPause() {
pause() {
audioPlayer.pause()
}
doResume() {
resume() {
audioPlayer.play()
}
......@@ -87,8 +79,32 @@ export class Player {
audioPlayer.seek(startTime)
}
setVolume(volume) {
audioPlayer.volume(volume)
volume(volume) {
if (audioPlayer !== null) {
audioPlayer.volume(volume)
}
}
state() {
return audioPlayer
}
getSeek() {
return audioPlayer.seek()
}
updateSeek() {
const vm = this
setTimeout(function() {
if (audioPlayer !== null) {
if (audioPlayer.state() === 'loaded') {
const seek = audioPlayer.seek()
store.dispatch('setSeek', seek)
store.dispatch('storePlaybacktime', seek)
}
vm.updateSeek()
}
}, 1000)
}
}
......@@ -23,7 +23,6 @@
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
const requesttoken = axios.defaults.headers.requesttoken
const categories = require('../assets/categories.json')
export class ShowApi {
......@@ -36,11 +35,14 @@ export class ShowApi {
addShow(show) {
show = {
id: show.id,
imgurl: show.smallImageURL,
smallImageURL: show.smallImageURL,
title: show.title,
author: show.author,
lastpub: show.lastpub,
dateadded: Date.now(),
homepage: show.htmlURL,
feedurl: show.xmlURL,
}
axios.defaults.headers.requesttoken = requesttoken
return axios.post(this.url('/shows'), show)
.then(
(response) => {
......@@ -56,7 +58,6 @@ export class ShowApi {
}
removeShow(show) {
axios.defaults.headers.requesttoken = requesttoken
return axios.delete(this.url(`/shows/${show.id}`))
.then(
(response) => {
......@@ -71,9 +72,35 @@ export class ShowApi {
})
}
loadShows(show) {
axios.defaults.headers.requesttoken = requesttoken
return axios.get(this.url('/shows'))
queryShow(podcastId) {
return axios.get(this.url('/shows'), {
params: {
podcast_id: podcastId,
},
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
}
)
.catch((err) => {
return Promise.reject(err)
})
}
queryShows(page) {
return axios.get(this.url('/shows'), {
params: {
count: 20,
page,
},
})
.then(
(response) => {
return Promise.resolve(response.data)
......@@ -85,6 +112,30 @@ export class ShowApi {
.catch((err) => {
return Promise.reject(err)
})
}
queryCategory(category, count = 20, page = 0) {
return axios.get(this.url('/shows'), {
params: {
category: String(category),
count,
page,
},
})
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
}
)
.catch((err) => {
return Promise.reject(err)
})
}
getCategoryName(categoryid) {
......
......@@ -22,28 +22,21 @@
import { EpisodeApi } from './../services/EpisodeApi'
const apiClient = new EpisodeApi()
const episodeApiClient = new EpisodeApi()
export default {
state: {
episodes: [],
},
getters: {
episodesQueue: state => {
getEpisodes: state => {
return state.episodes
},
episodeById: state => (id) => {
return state.episodes.find((episode) => episode.id === id)
},
episodeExists: state => (id) => {
return state.episodes.some((episode) => episode.id === id)
},
},
mutations: {
addEpisode(state, episode) {
state.episodes.push(episode)
},
removeEpisode(state, episode) {
const existingIndex = state.episodes.findIndex(_episode => _episode.id === episode.id)
if (existingIndex !== -1) {
......@@ -53,23 +46,50 @@ export default {
setEpisodes(state, episodes) {
state.episodes = episodes
},
updateEpisode(state, { episode, playtime } = {}) {
const existingIndex = state.episodes.findIndex(_episode => _episode.id === episode.id)
if (existingIndex >= 0) {
state.episodes[existingIndex].playtime = playtime
state.episodes[existingIndex].lastplayed = Date.now()
}
},
},
actions: {
async loadEpisodes({ commit }) {
const episodes = await apiClient.loadEpisodes()
commit('setEpisodes', episodes)
async loadEpisodes(context) {
const episodes = await episodeApiClient.queryEpisodes(null, 0)
if (episodes.data.episodes.length) {
context.dispatch('loadEpisode', episodes.data.episodes[0])
}
},
addEpisode({ commit }, episode) {
apiClient.addEpisode(episode)
.then((episode) => {
commit('addEpisode', episode)
})
addEpisode({ commit, getters }, episode) {
episode.lastplayed = Date.now()
episodeApiClient.addEpisode(episode)
},
removeEpisode({ commit }, episode) {
apiClient.removeEpisode(episode)
episodeApiClient.removeEpisode(episode)
.then((episode) => {
commit('removeEpisode', episode)
})
},
updateEpisode({ commit, getters }, { episode, playtime } = {}) {
episodeApiClient.updateEpisode({ episode, playtime })
.then((episode) => {
commit('updateEpisode', { episode, playtime })
})
},
async queryEpisodes({ commit, getters }, { page, sortBy, podcastId } = {}) {
const response = await episodeApiClient.queryEpisodes(podcastId, page, sortBy)
const episodes = getters.getEpisodes.concat(response.data.episodes)
commit('setEpisodes', episodes)
if (response.meta.paging.next_page === null) {
return false
} else {
return true
}
},
clearEpisodes({ commit }) {
commit('setEpisodes', [])
},
},
}
......@@ -21,7 +21,7 @@
*/
import Vue from 'vue'
import Vuex from 'vuex'
import Vuex, { Store } from 'vuex'
import show from './show'
import player from './player'
......@@ -29,7 +29,7 @@ import episode from './episode'
Vue.use(Vuex)
export default new Vuex.Store({
export default new Store({
modules: {
show,
player,
......
......@@ -20,11 +20,11 @@
*
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { Player } from './../services/Player'
import { ShowApi } from './../services/ShowApi'
const player = new Player()
const requesttoken = axios.defaults.headers.requesttoken
const showApiClient = new ShowApi()
export default {
state: {
......@@ -33,69 +33,86 @@ export default {
isMute: false,
isPaused: false,
volume: 0.5,
oldVolume: 0,
title: '',
image: '',
episodeId: null,
duration: 0,
oldVolume: null,
seek: 0,
lastplaytimeupdate: null,
podcastName: '',
episode: {
id: null,
podcast_id: null,
title: null,
imgURL: null,
playtime: null,
duration: null,
enclosure: null,
lastplayed: null,
},
},
getters: {
episodeTitle: state => {
return state.title
getSeek: state => {
if (state.seek) {
return state.seek
} else {
return 0
}
},
episodeImage: state => {
return state.image
getVolume: state => {
return state.volume
},
isPlaying: state => (id) => {
return (state.episodeId === id && state.isPlaying)
isPlaying: state => {
return state.isPlaying
},
isBuffering: state => {
return state.isBuffering
},
isPaused: state => (id) => {
return (state.episodeId === id && state.isPaused)
return (state.episode.id === id && state.isPaused)
},
episodeLoaded: state => (id) => {
return (state.episode.id === id)
},
episodePlaying: state => (id) => {
return (state.episode.id === id && state.isPlaying)
},
getEpisode: state => {
return state.episode
},
getPodcastName: state => {
return state.podcastName
},
},
mutations: {
setVolume(state, volume) {
console.log(volume)
state.volume = volume
localStorage.setItem('podcast.volume', state.volume)
player.volume(state.volume)
},
toggleMute(state) {
if (state.isMute) {
state.volume = state.oldVolume
state.isMute = false
} else {
state.oldVolume = state.volume
state.volume = 0
state.isMute = true
if (state.volume === 0) {
state.volume = 1
} else {
state.oldVolume = state.volume
state.volume = 0
state.isMute = true
}
}
player.setVolume(state.volume)
},
setTitle(state, title) {
state.title = title
},
setDuration(state, duration) {
duration = Math.round(duration)
state.duration = duration
localStorage.setItem('podcast.volume', state.volume)
player.volume(state.volume)
},
setSeek(state, seek) {
seek = Math.round(seek)
state.seek = seek
if (seek) {
state.seek = seek
}
},
saveVolume(state, volumeState) {
axios.defaults.headers.requesttoken = requesttoken
axios.post(generateUrl('/apps/podcast/settings/volumeState'), {
volumeState,
})
},
loadVolume(state) {
axios.defaults.headers.requesttoken = requesttoken
axios
.get(generateUrl('/apps/podcast/settings/volumeState'))
.then(async response => {
const {
data: { volumeState: value },
} = response
state.volume = value
})
seekEpisode(state, seek) {
player.seek(seek)
seek = Math.round(seek)
state.seek = seek
},
setPlaying(state, playerState) {
state.isPlaying = playerState
......@@ -106,11 +123,11 @@ export default {
setBuffering(state, bufferingState) {
state.isBuffering = bufferingState
},
setImage(state, image) {
state.image = image
setPodcastName(state, name) {
state.podcastName = name
},
setEpisodeId(state, episodeId) {
state.episodeId = episodeId
setEpisode(state, episode) {
state.episode = episode
},
},
actions: {
......@@ -122,66 +139,77 @@ export default {
},
setVolume(context, volume) {
context.commit('setVolume', volume)
player.setVolume(volume)
},
loadVolume(context) {
const volume = localStorage.getItem('podcast.volume')
if (typeof volume === 'string') {
context.commit('setVolume', volume)
}
},
toggleMute(context) {
context.commit('toggleMute')
},
saveVolume(context, volumeState) {
context.commit('saveVolume', volumeState)
},
loadVolume(context) {
context.commit('loadVolume')
loadEpisode(context, episode) {
context.commit('setEpisode', episode)
context.commit('setPausing', true)
context.dispatch('setPodcastName', episode.podcast_id)
if (episode.playtime) {
context.commit('setSeek', episode.playtime)
}
player.load(episode.enclosure)
},
playEpisode(context, episode) {
if (context.state.isPaused && episode.id === context.state.episodeId) {
context.commit('setPausing', false)
context.commit('setBuffering', true)
player.doResume()
if (context.state.isPaused && episode.id === context.state.episode.id) { // FIXME: use getter: episodeIsLoaded
player.play()
} else {
context.commit('setBuffering', true)
context.commit('setTitle', episode.title)
context.commit('setImage', episode.imgURL)
context.commit('setEpisodeId', episode.id)
context.commit('setPausing', false)
player.doPlay(episode.enclosure)
context.dispatch('loadEpisode', episode)
if (!episode.lastplayed) {
context.dispatch('addEpisode', episode)
}
player.play()
}
},
pauseEpisode(context) {
context.commit('setBuffering', false)
context.commit('setPlaying', false)
context.commit('setPausing', true)
player.doPause()
player.pause()
},
seekEpisode(context, startTime) {
context.commit('setBuffering', true)
player.seek(startTime)
setSeek(context, startTime) {
context.commit('setSeek', startTime)
},
togglePlay(context) {
if (context.state.episodeId === null) {
if (context.state.episode.id === null) {
return true
}
if (context.state.isPaused) {
context.commit('setPlaying', true)
context.commit('setBuffering', true)
context.commit('setPausing', false)
player.doResume()
player.play()
} else {
context.commit('setBuffering', false)
context.commit('setPausing', true)
context.commit('setPausing', true)
player.doPause()
player.pause()
}
},
setDuration(context, duration) {
context.commit('setDuration', duration)
seekEpisode(context, startTime) {
if (Math.abs(startTime - player.getSeek() > 1)) {
context.commit('seekEpisode', startTime)
}
},
setPausing(context, state) {
context.commit('setPausing', state)
},
storePlaybacktime(context, position) {
const episode = context.state.episode
const dateNow = Date.now()
if (dateNow - episode.lastplayed >= 5000) {
context.dispatch('updateEpisode', { episode, playtime: position })
context.state.episode.lastplayed = Date.now()
}
},
setSeek(context, seek) {
context.commit('setSeek', seek)
async setPodcastName(context, podcastId) {
const podcast = await showApiClient.queryShow(podcastId)
context.commit('setPodcastName', podcast.data.title)
},
},
}