string) | undefined> = {
+ podcast: (media) => `/podcasts/episodes/${media.id}/position`,
+ track: undefined,
+ radio: undefined,
+ }
+ if (!endpoints[media.type]) return 0
+ const endpoint = endpoints[media.type]!(media)
+ try {
+ const response = await axios.get(endpoint)
+ const position = response.data.position || 0
+ if (position > 0) {
+ awaitingSeekResume.value = true
+ }
+ return position
+ } catch (err) {
+ console.warn('Failed to get start position:', err)
+ return 0
+ }
+}
+
+async function playMedia(media: Playable) {
+ const index = queue.value.findIndex(item => item.id === media.id && item.type === media.type)
if (index !== -1) {
currentIndex.value = index
@@ -24,56 +124,38 @@ function playMusic(media: Media) {
currentIndex.value = queue.value.length - 1
}
- const newSrc = axios.defaults.baseURL + `/music/tracks/${media.id}/stream`
+ const src = getStreamUrl(media)
- if (audio.src !== newSrc) {
- audio.pause()
- audio.src = newSrc
- audio.load()
- }
-
- audio
- .play()
- .then(() => {
- isPlaying.value = true
- })
- .catch(err => {
- console.error('Playback failed:', err)
- isPlaying.value = false
- })
-}
-
-function playRadioStation(uuid: string) {
- clearQueue()
- const src = axios.defaults.baseURL + `/radio/${uuid}/stream`
if (audio.src !== src) {
audio.pause()
+ resumePosition.value = await getStartPosition(media)
audio.src = src
audio.load()
+ currentTime.value = resumePosition.value
+ } else {
+ resumePosition.value = 0
+ awaitingSeekResume.value = false
}
+
+ duration.value = typeof media.duration === 'number' ? media.duration : 0
+
audio
.play()
- .then(() => {
- isPlaying.value = true
- })
.catch(err => {
console.error('Playback failed:', err)
- isPlaying.value = false
})
}
function playIndex(index: number) {
if (queue.value[index]) {
currentIndex.value = index
- playMusic(queue.value[index])
+ playMedia(queue.value[index])
}
}
function next() {
if (currentIndex.value + 1 < queue.value.length) {
playIndex(currentIndex.value + 1)
- } else {
- console.warn('No next track in queue')
}
}
@@ -93,36 +175,18 @@ function togglePlay() {
if (audio.paused) {
audio
.play()
- .then(() => (isPlaying.value = true))
- .catch(err => {
- console.error('Toggle play failed:', err)
- })
+ .catch(err => console.error('Toggle play failed:', err))
} else {
pause()
}
}
-function addToQueue(media: Media | Media[]) {
- if (Array.isArray(media)) {
- queue.value.push(...media)
- } else {
- queue.value.push(media)
- }
+function addToQueue(media: Playable | Playable[]) {
+ const items = Array.isArray(media) ? media : [media]
+ queue.value.push(...items)
}
-function removeFromQueue(media: Media) {
- const index = queue.value.findIndex(item => item.id === media.id)
- if (index !== -1) {
- queue.value.splice(index, 1)
- if (currentIndex.value >= index) {
- currentIndex.value = Math.max(currentIndex.value - 1, -1)
- }
- } else {
- console.warn('Media not found in queue:', media)
- }
-}
-
-function addAsNext(media: Media) {
+function addAsNext(media: Playable) {
if (currentIndex.value >= 0) {
queue.value.splice(currentIndex.value + 1, 0, media)
} else {
@@ -130,28 +194,35 @@ function addAsNext(media: Media) {
}
}
-function clearQueue() {
- queue.value = []
- currentIndex.value = -1
-}
-
-function overwriteQueue(newQueue: Media[], startIndex = 0) {
- queue.value.splice(0, queue.value.length, ...newQueue)
- currentIndex.value = startIndex
-
- if (queue.value[startIndex]) {
- playMusic(queue.value[startIndex])
- } else {
- console.warn('No valid track at startIndex', startIndex)
+function removeFromQueue(media: Playable) {
+ const index = queue.value.findIndex(item => item.id === media.id && item.type === media.type)
+ if (index !== -1) {
+ queue.value.splice(index, 1)
+ if (currentIndex.value >= index) {
+ currentIndex.value = Math.max(currentIndex.value - 1, -1)
+ }
}
}
-function playFromQueue(media: Media) {
- const index = queue.value.findIndex(item => item.id === media.id)
+function clearQueue() {
+ queue.value = []
+ currentIndex.value = -1
+ audio.pause()
+ audio.src = ''
+}
+
+function overwriteQueue(newQueue: Playable[], startIndex = 0) {
+ queue.value.splice(0, queue.value.length, ...newQueue)
+ currentIndex.value = startIndex
+ if (queue.value[startIndex]) {
+ playMedia(queue.value[startIndex])
+ }
+}
+
+function playFromQueue(media: Playable) {
+ const index = queue.value.findIndex(item => item.id === media.id && item.type === media.type)
if (index !== -1) {
playIndex(index)
- } else {
- console.warn('Media not found in queue:', media)
}
}
@@ -161,51 +232,109 @@ function setSeek(percent: number) {
}
audio.addEventListener('play', () => {
+ // console.log('[audio event] play', { resumePosition: resumePosition.value, currentTime: currentTime.value, awaitingSeekResume: awaitingSeekResume.value, currentMedia: currentMedia.value });
isPlaying.value = true
-})
+ if (awaitingSeekResume.value) return
+ trackAction(currentMedia.value!, currentTime.value > 0 ? 'resume' : 'play')
+})
audio.addEventListener('pause', () => {
+ // console.log('[audio event] pause', { currentMedia: currentMedia.value });
isPlaying.value = false
+ trackAction(currentMedia.value!, 'pause')
})
-
audio.addEventListener('ended', () => {
+ // console.log('[audio event] ended', { currentMedia: currentMedia.value });
isPlaying.value = false
+ trackAction(currentMedia.value!, 'complete')
next()
})
-
+audio.addEventListener('waiting', () => {
+ // console.log('[audio event] waiting');
+ loading.value = true
+})
+audio.addEventListener('seeking', () => {
+ // console.log('[audio event] seeking');
+ loading.value = true
+})
+audio.addEventListener('seeked', () => {
+ // console.log('[audio event] seeked');
+ loading.value = false
+})
audio.addEventListener('timeupdate', () => {
+ // console.log('[audio event] timeupdate', { currentTime: audio.currentTime, duration: audio.duration });
currentTime.value = audio.currentTime
- duration.value = audio.duration || 0
+ if (!duration.value && audio.duration) {
+ duration.value = audio.duration || 0
+ }
+})
+audio.addEventListener('loadedmetadata', () => {
+ // console.log('[audio event] loadedmetadata', { duration: audio.duration });
+ if (!duration.value && audio.duration) {
+ duration.value = audio.duration
+ }
+
+ if (awaitingSeekResume.value && resumePosition.value > 0) {
+ audio.currentTime = resumePosition.value
+ currentTime.value = resumePosition.value
+ }
+})
+audio.addEventListener('loadstart', () => {
+ // console.log('[audio event] loadstart');
+ loading.value = true
})
-audio.addEventListener('loadedmetadata', () => {
- duration.value = audio.duration
+audio.addEventListener('canplay', () => {
+ // console.log('[audio event] canplay', { resumePosition: resumePosition.value, currentTime: currentTime.value, awaitingSeekResume: awaitingSeekResume.value, currentMedia: currentMedia.value });
+ loading.value = false
+
+ audio.play().catch(err => {
+ console.warn('Resume playback after seek failed:', err)
+ })
+
+ if (awaitingSeekResume.value) {
+ trackAction(currentMedia.value!, 'resume')
+ awaitingSeekResume.value = false
+ resumePosition.value = 0
+ }
+})
+
+audio.addEventListener('error', () => {
+ // console.log('[audio event] error');
+ loading.value = false
+})
+
+watch(currentMedia, (_newMedia, oldMedia) => {
+ if (oldMedia && oldMedia.type === 'podcast') {
+ trackAction(oldMedia, 'pause')
+ }
})
function usePlayback() {
return {
- playMusic,
- playRadioStation,
- pause,
- togglePlay,
- next,
- prev,
isPlaying,
currentMedia,
queue,
+ loading,
currentIndex,
- addToQueue,
- removeFromQueue,
- playFromQueue,
- addAsNext,
- clearQueue,
- overwriteQueue,
currentTime,
duration,
seek,
+ playMedia,
+ addToQueue,
+ addAsNext,
+ removeFromQueue,
+ playFromQueue,
+ overwriteQueue,
+ clearQueue,
+ playIndex,
+ next,
+ prev,
+ togglePlay,
+ pause,
setSeek,
}
}
-const playback = usePlayback()
+export const playback = usePlayback()
export default playback
diff --git a/src/models/media.ts b/src/models/media.ts
index 3a77707..4c1b5b5 100644
--- a/src/models/media.ts
+++ b/src/models/media.ts
@@ -1,4 +1,4 @@
-export interface Media {
+export interface Track {
id: number
path: string
title: string | null
@@ -23,6 +23,32 @@ export interface Media {
language: string | null
}
+export interface PodcastSubscription {
+ id: number
+ subscription_id: number | null
+ title: string | null
+ author: string | null
+ description: string | null
+ url: string | null
+ user_id: string | null
+ image: string | null
+ subscribed: boolean
+ updated: string
+}
+
+export interface PodcastEpisode {
+ id: number
+ action_id: number | null
+ subscription_data_id: number
+ title: string | null
+ guid: string | null
+ pub_date: string | null
+ duration: number | null
+ media_url: string | null
+ description: string | null
+ user_id: string | null
+}
+
export interface RadioStation {
id: number
remoteUuid: string
@@ -47,7 +73,7 @@ export interface Album {
year: number | null
cover: string | null
genre: string | null
- tracks: Media[]
+ tracks: Track[]
}
export interface Artist {
@@ -55,5 +81,5 @@ export interface Artist {
cover: string | null
genre: string | null
albums: Album[]
- tracks: Media[]
+ tracks: Track[]
}
diff --git a/src/router/index.ts b/src/router/index.ts
index 82080a8..b5dd656 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -7,7 +7,8 @@ const routes: RouteRecordRaw[] = [
{ path: '/albums/:artist/:album', component: () => import('@/views/AlbumView.vue') },
{ path: '/artists', component: () => import('@/views/ArtistsView.vue') },
{ path: '/artists/:id', component: () => import('@/views/ArtistView.vue') },
- // { path: '/podcasts', component: () => import('@/views/PodcastsView.vue') },
+ { path: '/podcasts', component: () => import('@/views/PodcastsView.vue') },
+ { path: '/podcasts/:id', component: () => import('@/views/PodcastView.vue') },
// { path: '/audiobooks', component: () => import('@/views/AudiobooksView.vue') },
// { path: '/videos', component: () => import('@/views/VideosView.vue') },
// { path: '/genres', component: () => import('@/views/GenresView.vue') },
diff --git a/src/utils/routing.ts b/src/utils/routing.ts
index 633270b..ade476d 100644
--- a/src/utils/routing.ts
+++ b/src/utils/routing.ts
@@ -16,6 +16,10 @@ export function getRadioStationPath(uuid: string): string {
return `/radio/${uuid}`;
}
+export function getPodcastPath(id: number): string {
+ return `/podcasts/${id}`;
+}
+
export function useGoToRoute() {
const router = useRouter()
@@ -38,3 +42,8 @@ export function useGoToRadioStation() {
const goToRoute = useGoToRoute()
return (uuid: string) => goToRoute(getRadioStationPath(uuid))
}
+
+export function useGoToPodcast() {
+ const goToRoute = useGoToRoute()
+ return (id: number) => goToRoute(getPodcastPath(id))
+}
diff --git a/src/views/AlbumView.vue b/src/views/AlbumView.vue
index 39061d7..e609f39 100644
--- a/src/views/AlbumView.vue
+++ b/src/views/AlbumView.vue
@@ -30,13 +30,13 @@
import { defineComponent, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { axios } from '@/axios'
-import type { Album, Media } from '@/models/media'
+import type { Album, Track } from '@/models/media'
import { useGoToArtist } from '@/utils/routing'
import Page from '@/components/Page.vue'
import MediaListItem from '@/components/media/MediaListItem.vue'
import Music from '@icons/Music.vue'
-import playback from '@/composables/usePlayback'
+import playback, { trackToPlayable } from '@/composables/usePlayback'
import NcButton from '@nextcloud/vue/components/NcButton'
export default defineComponent({
@@ -61,11 +61,11 @@ export default defineComponent({
}
})
- const handlePlay = (track: Media) => {
+ const handlePlay = (track: Track) => {
if (album.value) {
const index = album.value.tracks.findIndex(t => t.id === track.id)
if (index !== -1) {
- overwriteQueue([...album.value.tracks], index)
+ overwriteQueue(album.value.tracks.map(trackToPlayable), index)
}
}
}
diff --git a/src/views/AlbumsView.vue b/src/views/AlbumsView.vue
index 6a53c34..af2660b 100644
--- a/src/views/AlbumsView.vue
+++ b/src/views/AlbumsView.vue
@@ -12,11 +12,11 @@
+
+
diff --git a/src/views/PodcastsView.vue b/src/views/PodcastsView.vue
new file mode 100644
index 0000000..a959438
--- /dev/null
+++ b/src/views/PodcastsView.vue
@@ -0,0 +1,156 @@
+
+
+
+ Podcasts
+
+
+
+
+
+
+
Recently Played / Latest Episodes
+
+
Coming soon…
+
+
+
+
+
+ My Podcasts
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
No podcast subscriptions found. Add some to get started!
+
+
+
+
+ Add
+
+
+
+
+
+
+
+
diff --git a/src/views/RadioStationsView.vue b/src/views/RadioStationsView.vue
index 117681b..a332abb 100644
--- a/src/views/RadioStationsView.vue
+++ b/src/views/RadioStationsView.vue
@@ -11,8 +11,7 @@
Favorites
+ @click="playStation(station)" @unfavorite="setFavorite(station, false)" @remove="removeStation(station)" />
@@ -29,8 +28,8 @@
+ @click="playStation(station)" @favorite="setFavorite(station, true)" @unfavorite="setFavorite(station, false)"
+ @remove="removeStation(station)" />
@@ -56,7 +55,7 @@ import SearchRadioStationModal from '@/components/media/SearchRadioStationModal.
import Plus from '@icons/Plus.vue'
import type { RadioStation } from '@/models/media'
// import { useGoToRadioStation } from '@/utils/routing'
-import playback from '@/composables/usePlayback'
+import playback, { radioStationToPlayable } from '@/composables/usePlayback'
export default defineComponent({
name: 'RadioStationsView',
@@ -73,10 +72,8 @@ export default defineComponent({
const isSearchModalOpen = ref(false)
const isLoading = ref(true)
- // const playStation = useGoToRadioStation()
-
- const playStation = (remoteUuid: string) => {
- playback.playRadioStation(remoteUuid)
+ const playStation = (station: RadioStation) => {
+ playback.playMedia(radioStationToPlayable(station))
}
const fetchFavorites = async () => {
diff --git a/src/views/TracksView.vue b/src/views/TracksView.vue
index 1e89384..b9af6ba 100644
--- a/src/views/TracksView.vue
+++ b/src/views/TracksView.vue
@@ -11,17 +11,17 @@