Merge branch 'master' into landscape-player

This commit is contained in:
advplyr
2026-07-31 16:42:19 -05:00
55 changed files with 2273 additions and 1524 deletions
+30 -25
View File
@@ -85,22 +85,22 @@
<!-- Playback controls - jump buttons, play/pause, chapter navigation -->
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
<span class="material-symbols text-3xl leading-none">replay</span>
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
</div>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<span v-if="!showLoadingState" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
<span class="material-symbols text-3xl leading-none">forward_media</span>
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
</div>
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
@@ -111,7 +111,7 @@
<div class="flex-grow" />
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div>
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
@@ -133,7 +133,7 @@
import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor'
import { Dialog } from '@capacitor/dialog'
import { FastAverageColor } from 'fast-average-color'
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
import WrappingMarquee from '@/assets/WrappingMarquee.js'
import jumpLabelMixin from '@/mixins/jumpLabel'
@@ -176,6 +176,7 @@ export default {
lockUi: false
},
isLoading: false,
isCheckingServerProgress: false,
isDraggingCursor: false,
draggingTouchStartX: 0,
draggingTouchStartTime: 0,
@@ -289,6 +290,9 @@ export default {
return 190 * heightScale
}
},
showLoadingState() {
return this.isLoading || this.isCheckingServerProgress
},
showCastBtn() {
return this.$store.state.isCastAvailable
},
@@ -427,17 +431,14 @@ export default {
},
async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return
const fac = new FastAverageColor()
fac
.getColorAsync(fullCoverUrl)
.then((color) => {
this.coverRgb = color.rgba
this.coverBgIsLight = color.isLight
})
.catch((e) => {
console.log(e)
})
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
if (!avg) {
this.coverRgb = 'rgb(55, 56, 56)'
this.coverBgIsLight = false
} else {
this.coverRgb = avg.rgba
this.coverBgIsLight = avg.isLight
}
},
clickTitleAndAuthor() {
if (!this.showFullscreen) return
@@ -480,13 +481,13 @@ export default {
},
async jumpNextChapter() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
if (!this.nextChapter) return
this.seek(this.nextChapter.start)
},
async jumpChapterStart() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
if (!this.currentChapter) {
return this.restart()
}
@@ -516,12 +517,12 @@ export default {
},
async jumpBackwards() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
},
async jumpForward() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
},
setStreamReady() {
@@ -625,7 +626,7 @@ export default {
}
},
seek(time) {
if (this.isLoading) return
if (this.showLoadingState) return
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
@@ -634,7 +635,8 @@ export default {
this.seekedTime = time
this.seekLoading = true
AbsAudioPlayer.seek({ value: Math.floor(time) })
// Pass fractional seconds so seeks to non-integer chapter starts don't truncate
AbsAudioPlayer.seek({ value: time })
if (this.$refs.playedTrack) {
const perc = time / this.totalDuration
@@ -657,11 +659,14 @@ export default {
},
async playPauseClick() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
this.isEnded = false
},
setIsCheckingServerProgress(value) {
this.isCheckingServerProgress = !!value
},
play() {
AbsAudioPlayer.playPlayer()
this.startPlayInterval()
+126 -40
View File
@@ -48,6 +48,9 @@ export default {
},
isIos() {
return this.$platform === 'ios'
},
currentPlaybackSession() {
return this.$store.state.currentPlaybackSession
}
},
methods: {
@@ -226,7 +229,7 @@ export default {
console.log('Already streaming item', startTime)
if (startTime !== undefined && startTime !== null) {
// seek to start time
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
AbsAudioPlayer.seek({ value: startTime })
} else if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.play()
}
@@ -304,48 +307,129 @@ export default {
this.$refs.audioPlayer?.seek(currentTime)
},
/**
* When device gains focus then refresh the timestamps in the audio player
* Fetch the current user's media progress from the server for a given library item / episode.
* Returns the server media progress object, or null if the request fails, times out, or the
* response doesn't match the requested library item.
*
* The audio player's loading state is shown while the request is in flight so the user
* doesn't tap play before we have a chance to update the timestamps. The request timeout
* is 7 seconds so a slow/unresponsive server doesn't block the user for long.
*/
deviceFocused(hasFocus) {
if (!this.$store.state.currentPlaybackSession) return
async getServerMediaProgressForCurrentSession() {
if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null
const libraryItemId = this.currentPlaybackSession?.libraryItemId
const episodeId = this.currentPlaybackSession?.episodeId
if (!libraryItemId) return null
if (hasFocus) {
if (this.$refs.audioPlayer?.isCheckingServerProgress) {
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
return null
}
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
try {
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
if (!data || data.libraryItemId !== libraryItemId) return null
return data
} catch (error) {
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
return null
} finally {
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
}
},
getLocalMediaProgressForCurrentSession() {
if (!this.currentPlaybackSession) return null
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
},
/**
* Sync the server media progress with the local media progress
*/
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
try {
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
localMediaProgressId,
mediaProgress: serverMediaProgress
})
if (newLocalMediaProgress?.id) {
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
}
} catch (error) {
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
}
},
/**
* Check if the server media progress is more recent than the local media progress and sync if so
*/
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
if (!localMediaProgress) return
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
// Use seek() so the native audio player's current session is updated
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
}
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
},
/**
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
* we fetch the server media progress and sync it if it is more recent than the local progress
*
* If there is no socket connection we may have missed external progress updates
*/
async socketReconnected() {
if (!this.currentPlaybackSession) return
// dont update timestamps if player is playing
if (this.$refs.audioPlayer?.isPlaying) return
if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
if (!localMediaProgress) {
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
return
}
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
}
},
/**
* When device re-gains focus then refresh the timestamps in the audio player
* if local item is open then fetch the server media progress and update if more recent
*/
async deviceFocused(hasFocus) {
if (!this.currentPlaybackSession || !hasFocus) return
// dont update timestamps if player is playing
if (this.$refs.audioPlayer?.isPlaying) return
if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
if (!localMediaProgress) {
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
return
}
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
this.$refs.audioPlayer.timeupdate()
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
} else {
// server item so fetch server media progress and update player time
console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player')
const data = await this.getServerMediaProgressForCurrentSession()
if (!data) return
if (!this.$refs.audioPlayer?.isPlaying) {
const playbackSession = this.$store.state.currentPlaybackSession
if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localLibraryItemId = playbackSession.localLibraryItem?.id
const localEpisodeId = playbackSession.localEpisodeId
if (!localLibraryItemId) {
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
return
}
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
return mp.localLibraryItemId === localLibraryItemId
})
if (localMediaProgress) {
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
this.$refs.audioPlayer.timeupdate()
} else {
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
}
} else {
const libraryItemId = playbackSession.libraryItemId
const episodeId = playbackSession.episodeId
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
this.$nativeHttp
.get(url)
.then((data) => {
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = data.currentTime
this.$refs.audioPlayer.timeupdate()
}
})
.catch((error) => {
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
})
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
// Only seek if the difference is greater than 1 second
if (Math.abs(data.currentTime - this.currentTime) > 1) {
// Use seek() so the native audio player's current session is updated
this.$refs.audioPlayer.seek(data.currentTime)
}
}
}
@@ -368,6 +452,7 @@ export default {
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$on('device-focus-update', this.deviceFocused)
this.$eventBus.$on('socket-reconnected', this.socketReconnected)
},
beforeDestroy() {
this.onLocalMediaProgressUpdateListener?.remove()
@@ -383,6 +468,7 @@ export default {
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$off('device-focus-update', this.deviceFocused)
this.$eventBus.$off('socket-reconnected', this.socketReconnected)
}
}
</script>
+2 -2
View File
@@ -484,7 +484,7 @@ export default {
const { value } = await Dialog.confirm({
title: this.$strings.HeaderConfirm,
message: this.$strings.MessageConfirmDeleteServerConfig,
message: this.$strings.MessageConfirmDeleteServerConfig
})
if (value) {
this.processing = true
@@ -843,7 +843,7 @@ export default {
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
if (!user) return
console.log('Successfully logged in', JSON.stringify(user))
console.log('Successfully logged in: ' + user.username)
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
@@ -12,7 +12,8 @@ export default {
return {
downloadItemListener: null,
completeListener: null,
itemPartUpdateListener: null
itemPartUpdateListener: null,
queueChangedListener: null
}
},
computed: {
@@ -76,17 +77,22 @@ export default {
},
onDownloadItemPartUpdate(itemPart) {
this.$store.commit('globals/updateDownloadItemPart', itemPart)
},
onQueueChanged(data) {
if (!data.hasWork) this.$store.commit('globals/clearItemDownloads')
}
},
async mounted() {
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
this.queueChangedListener = await AbsDownloader.addListener('onQueueChanged', (data) => this.onQueueChanged(data))
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
},
beforeDestroy() {
this.downloadItemListener?.remove()
this.completeListener?.remove()
this.itemPartUpdateListener?.remove()
this.queueChangedListener?.remove()
}
}
</script>