mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-28 21:19:42 +00:00
When "Use chapter track" is enabled, the Media Session API now reports duration and position relative to the current chapter instead of the full audiobook. This makes the OS lock screen/notification scrubber span only the current chapter. - Call setPositionState() with chapter-relative values - Map OS seek requests back to absolute file position - Update metadata with chapter title on chapter change - Fall back to full-file behavior when setting is disabled
497 lines
18 KiB
Vue
497 lines
18 KiB
Vue
<template>
|
|
<div class="w-full max-w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
|
|
<div class="w-screen h-screen absolute inset-0 pointer-events-none" style="background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)"></div>
|
|
<div class="absolute inset-0 w-screen h-dvh flex items-center justify-center z-10">
|
|
<div class="w-full p-2 sm:p-4 md:p-8">
|
|
<div v-if="!isMobileLandscape" :style="{ width: coverWidth + 'px', height: coverHeight + 'px' }" class="mx-auto overflow-hidden rounded-xl my-2">
|
|
<img ref="coverImg" :src="coverUrl" class="object-contain w-full h-full" @load="coverImageLoaded" />
|
|
</div>
|
|
<p class="text-2xl lg:text-3xl font-semibold text-center mb-1 line-clamp-2">{{ mediaItemShare.playbackSession.displayTitle || 'No title' }}</p>
|
|
<p v-if="mediaItemShare.playbackSession.displayAuthor" class="text-lg lg:text-xl text-slate-400 font-semibold text-center mb-1 truncate">{{ mediaItemShare.playbackSession.displayAuthor }}</p>
|
|
|
|
<div class="w-full pt-16">
|
|
<player-ui ref="audioPlayer" :chapters="chapters" :current-chapter="currentChapter" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" />
|
|
</div>
|
|
|
|
<ui-tooltip v-if="mediaItemShare.isDownloadable" direction="bottom" :text="$strings.LabelDownload" class="absolute top-0 left-0 m-4">
|
|
<button aria-label="Download" class="text-gray-300 hover:text-white" @click="downloadShareItem"><span class="material-symbols text-2xl sm:text-3xl">download</span></button>
|
|
</ui-tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import LocalAudioPlayer from '../../players/LocalAudioPlayer'
|
|
import { FastAverageColor } from 'fast-average-color'
|
|
|
|
export default {
|
|
layout: 'blank',
|
|
async asyncData({ params, error, app, query }) {
|
|
let endpoint = `/public/share/${params.slug}`
|
|
if (query.t && !isNaN(query.t)) {
|
|
endpoint += `?t=${query.t}`
|
|
}
|
|
const mediaItemShare = await app.$axios.$get(endpoint, { timeout: 10000 }).catch((error) => {
|
|
console.error('Failed', error)
|
|
return null
|
|
})
|
|
if (!mediaItemShare) {
|
|
return error({ statusCode: 404, message: 'Media item not found or expired' })
|
|
}
|
|
|
|
return {
|
|
mediaItemShare: mediaItemShare
|
|
}
|
|
},
|
|
data() {
|
|
return {
|
|
localAudioPlayer: new LocalAudioPlayer(),
|
|
playerState: null,
|
|
playInterval: null,
|
|
hasLoaded: false,
|
|
totalDuration: 0,
|
|
windowWidth: 0,
|
|
windowHeight: 0,
|
|
listeningTimeSinceSync: 0,
|
|
coverRgb: null,
|
|
coverBgIsLight: false,
|
|
currentTime: 0,
|
|
currentPlaybackRate: 1
|
|
}
|
|
},
|
|
computed: {
|
|
playbackSession() {
|
|
return this.mediaItemShare.playbackSession
|
|
},
|
|
coverUrl() {
|
|
if (!this.playbackSession.coverPath) return this.$store.getters['globals/getPlaceholderCoverSrc']
|
|
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
|
|
},
|
|
downloadUrl() {
|
|
return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download`
|
|
},
|
|
audioTracks() {
|
|
return (this.playbackSession.audioTracks || []).map((track) => {
|
|
track.relativeContentUrl = track.contentUrl
|
|
return track
|
|
})
|
|
},
|
|
isPlaying() {
|
|
return this.playerState === 'PLAYING'
|
|
},
|
|
isPaused() {
|
|
return !this.isPlaying
|
|
},
|
|
chapters() {
|
|
return this.playbackSession.chapters || []
|
|
},
|
|
currentChapter() {
|
|
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
|
},
|
|
useChapterTrack() {
|
|
const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
|
return this.chapters.length ? _useChapterTrack : false
|
|
},
|
|
coverAspectRatio() {
|
|
const coverAspectRatio = this.playbackSession.coverAspectRatio
|
|
return coverAspectRatio === this.$constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1
|
|
},
|
|
isMobileLandscape() {
|
|
return this.windowWidth > this.windowHeight && this.windowHeight < 450
|
|
},
|
|
coverWidth() {
|
|
const availableCoverWidth = Math.min(450, this.windowWidth - 32)
|
|
const availableCoverHeight = Math.min(450, this.windowHeight - 250)
|
|
|
|
const mostCoverHeight = availableCoverWidth * this.coverAspectRatio
|
|
if (mostCoverHeight > availableCoverHeight) {
|
|
return availableCoverHeight / this.coverAspectRatio
|
|
}
|
|
return availableCoverWidth
|
|
},
|
|
coverHeight() {
|
|
return this.coverWidth * this.coverAspectRatio
|
|
}
|
|
},
|
|
methods: {
|
|
mediaSessionPlay() {
|
|
console.log('Media session play')
|
|
this.play()
|
|
},
|
|
mediaSessionPause() {
|
|
console.log('Media session pause')
|
|
this.pause()
|
|
},
|
|
mediaSessionStop() {
|
|
console.log('Media session stop')
|
|
this.pause()
|
|
},
|
|
mediaSessionSeekBackward() {
|
|
console.log('Media session seek backward')
|
|
this.jumpBackward()
|
|
},
|
|
mediaSessionSeekForward() {
|
|
console.log('Media session seek forward')
|
|
this.jumpForward()
|
|
},
|
|
mediaSessionSeekTo(e) {
|
|
console.log('Media session seek to', e)
|
|
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
|
// When "Use chapter track" is enabled and chapters exist, seekTime is
|
|
// relative to current chapter start. Map it back to absolute position.
|
|
if (this.useChapterTrack && this.currentChapter) {
|
|
const chapterStart = this.currentChapter.start
|
|
const chapterEnd = this.currentChapter.end
|
|
const chapterDuration = chapterEnd - chapterStart
|
|
// Clamp seekTime to chapter bounds to prevent seeking outside chapter
|
|
const clampedSeekTime = Math.max(0, Math.min(e.seekTime, chapterDuration))
|
|
const absoluteTime = chapterStart + clampedSeekTime
|
|
this.seek(absoluteTime)
|
|
} else {
|
|
// "Use chapter track" disabled or no chapters - use full-file seek
|
|
this.seek(e.seekTime)
|
|
}
|
|
}
|
|
},
|
|
mediaSessionPreviousTrack() {
|
|
if (this.$refs.audioPlayer) {
|
|
this.$refs.audioPlayer.prevChapter()
|
|
}
|
|
},
|
|
mediaSessionNextTrack() {
|
|
if (this.$refs.audioPlayer) {
|
|
this.$refs.audioPlayer.nextChapter()
|
|
}
|
|
},
|
|
updateMediaSessionPlaybackState() {
|
|
if ('mediaSession' in navigator) {
|
|
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
|
}
|
|
},
|
|
/**
|
|
* Update MediaSession metadata when chapter changes (only if useChapterTrack is enabled).
|
|
* Updates the title to show chapter info and resets position state.
|
|
*/
|
|
updateMediaSessionForChapter() {
|
|
if (!('mediaSession' in navigator) || !this.useChapterTrack || !this.currentChapter) {
|
|
return
|
|
}
|
|
|
|
const baseTitle = this.mediaItemShare.playbackSession.displayTitle || 'No title'
|
|
const chapterTitle = this.currentChapter.title
|
|
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: chapterTitle || baseTitle,
|
|
artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',
|
|
album: baseTitle,
|
|
artwork: [
|
|
{
|
|
src: this.coverUrl
|
|
}
|
|
]
|
|
})
|
|
|
|
this.updateMediaSessionPositionState()
|
|
},
|
|
/**
|
|
* Update MediaSession position state.
|
|
* When "Use chapter track" is enabled and a chapter is active, reports
|
|
* duration/position relative to chapter bounds so the OS scrubber spans
|
|
* only the current chapter. Otherwise uses full-file duration/position.
|
|
*/
|
|
updateMediaSessionPositionState() {
|
|
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) {
|
|
return
|
|
}
|
|
|
|
const playbackRate = this.currentPlaybackRate || 1
|
|
|
|
if (this.useChapterTrack && this.currentChapter) {
|
|
// "Use chapter track" enabled - report chapter-relative values
|
|
const chapterStart = this.currentChapter.start
|
|
const chapterEnd = this.currentChapter.end
|
|
const chapterDuration = chapterEnd - chapterStart
|
|
|
|
let chapterPosition = this.currentTime - chapterStart
|
|
chapterPosition = Math.max(0, Math.min(chapterPosition, chapterDuration))
|
|
|
|
if (isNaN(chapterDuration) || chapterDuration <= 0 || isNaN(chapterPosition)) {
|
|
console.warn('Invalid chapter position state values, skipping update')
|
|
return
|
|
}
|
|
|
|
try {
|
|
navigator.mediaSession.setPositionState({
|
|
duration: chapterDuration,
|
|
position: chapterPosition,
|
|
playbackRate: playbackRate
|
|
})
|
|
} catch (e) {
|
|
console.error('Error setting media session position state:', e)
|
|
}
|
|
} else if (this.totalDuration > 0) {
|
|
// "Use chapter track" disabled or no chapters - use full-file values
|
|
const position = Math.max(0, Math.min(this.currentTime, this.totalDuration))
|
|
|
|
if (isNaN(this.totalDuration) || isNaN(position)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
navigator.mediaSession.setPositionState({
|
|
duration: this.totalDuration,
|
|
position: position,
|
|
playbackRate: playbackRate
|
|
})
|
|
} catch (e) {
|
|
console.error('Error setting media session position state:', e)
|
|
}
|
|
}
|
|
},
|
|
setMediaSession() {
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
|
|
if ('mediaSession' in navigator) {
|
|
const chapterInfo = []
|
|
if (this.chapters.length > 0) {
|
|
this.chapters.forEach((chapter) => {
|
|
chapterInfo.push({
|
|
title: chapter.title,
|
|
startTime: chapter.start
|
|
})
|
|
})
|
|
}
|
|
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: this.mediaItemShare.playbackSession.displayTitle || 'No title',
|
|
artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',
|
|
artwork: [
|
|
{
|
|
src: this.coverUrl
|
|
}
|
|
],
|
|
chapterInfo
|
|
})
|
|
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
|
|
|
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
|
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
|
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
|
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
|
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
|
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
|
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
|
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
|
} else {
|
|
console.warn('Media session not available')
|
|
}
|
|
},
|
|
async coverImageLoaded(e) {
|
|
if (!this.playbackSession.coverPath) return
|
|
const fac = new FastAverageColor()
|
|
fac
|
|
.getColorAsync(e.target)
|
|
.then((color) => {
|
|
this.coverRgb = color.rgba
|
|
this.coverBgIsLight = color.isLight
|
|
|
|
document.body.style.backgroundColor = color.hex
|
|
})
|
|
.catch((e) => {
|
|
console.log(e)
|
|
})
|
|
},
|
|
playPause() {
|
|
if (this.isPlaying) {
|
|
this.pause()
|
|
} else {
|
|
this.play()
|
|
}
|
|
},
|
|
play() {
|
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
|
this.localAudioPlayer.play()
|
|
},
|
|
pause() {
|
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
|
this.localAudioPlayer.pause()
|
|
},
|
|
jumpForward() {
|
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
|
const currentTime = this.localAudioPlayer.getCurrentTime()
|
|
const duration = this.localAudioPlayer.getDuration()
|
|
const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
|
|
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
|
|
},
|
|
jumpBackward() {
|
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
|
const currentTime = this.localAudioPlayer.getCurrentTime()
|
|
const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
|
|
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
|
|
},
|
|
setVolume(volume) {
|
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
|
this.localAudioPlayer.setVolume(volume)
|
|
},
|
|
setPlaybackRate(playbackRate) {
|
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
|
this.currentPlaybackRate = playbackRate
|
|
this.localAudioPlayer.setPlaybackRate(playbackRate)
|
|
// Update position state with new playback rate
|
|
this.updateMediaSessionPositionState()
|
|
},
|
|
seek(time) {
|
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
|
|
|
this.localAudioPlayer.seek(time, this.isPlaying)
|
|
this.setCurrentTime(time)
|
|
},
|
|
setCurrentTime(time) {
|
|
if (!this.$refs.audioPlayer) return
|
|
|
|
const previousChapterId = this.currentChapter?.id
|
|
|
|
// Update UI
|
|
this.$refs.audioPlayer.setCurrentTime(time)
|
|
this.currentTime = time
|
|
|
|
// Update MediaSession position state (chapter-relative when useChapterTrack enabled)
|
|
this.updateMediaSessionPositionState()
|
|
|
|
// If chapter changed and useChapterTrack enabled, update MediaSession metadata
|
|
if (this.useChapterTrack && this.currentChapter?.id !== previousChapterId && this.currentChapter) {
|
|
this.updateMediaSessionForChapter()
|
|
}
|
|
},
|
|
setDuration() {
|
|
if (!this.localAudioPlayer) return
|
|
this.totalDuration = this.localAudioPlayer.getDuration()
|
|
if (this.$refs.audioPlayer) {
|
|
this.$refs.audioPlayer.setDuration(this.totalDuration)
|
|
}
|
|
},
|
|
sendProgressSync(currentTime) {
|
|
console.log('Sending progress sync for time', currentTime)
|
|
const progress = {
|
|
currentTime
|
|
}
|
|
this.$axios.$patch(`/public/share/${this.mediaItemShare.slug}/progress`, progress, { progress: false }).catch((error) => {
|
|
console.error('Failed to send progress sync', error)
|
|
})
|
|
},
|
|
startPlayInterval() {
|
|
let lastTick = Date.now()
|
|
clearInterval(this.playInterval)
|
|
this.playInterval = setInterval(() => {
|
|
if (!this.localAudioPlayer) return
|
|
|
|
const currentTime = this.localAudioPlayer.getCurrentTime()
|
|
this.setCurrentTime(currentTime)
|
|
const exactTimeElapsed = (Date.now() - lastTick) / 1000
|
|
lastTick = Date.now()
|
|
this.listeningTimeSinceSync += exactTimeElapsed
|
|
if (this.listeningTimeSinceSync >= 30) {
|
|
this.listeningTimeSinceSync = 0
|
|
this.sendProgressSync(currentTime)
|
|
}
|
|
}, 1000)
|
|
},
|
|
stopPlayInterval() {
|
|
clearInterval(this.playInterval)
|
|
this.playInterval = null
|
|
},
|
|
playerStateChange(state) {
|
|
this.playerState = state
|
|
if (state === 'LOADED' || state === 'PLAYING') {
|
|
this.setDuration()
|
|
}
|
|
if (state === 'LOADED') {
|
|
this.hasLoaded = true
|
|
}
|
|
if (state === 'PLAYING') {
|
|
this.startPlayInterval()
|
|
} else {
|
|
this.stopPlayInterval()
|
|
}
|
|
this.updateMediaSessionPlaybackState()
|
|
},
|
|
playerTimeUpdate(time) {
|
|
this.setCurrentTime(time)
|
|
},
|
|
getHotkeyName(e) {
|
|
var keyCode = e.keyCode || e.which
|
|
if (!this.$keynames[keyCode]) {
|
|
// Unused hotkey
|
|
return null
|
|
}
|
|
|
|
var keyName = this.$keynames[keyCode]
|
|
var name = keyName
|
|
if (e.shiftKey) name = 'Shift-' + keyName
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
console.log('Hotkey command', name)
|
|
}
|
|
return name
|
|
},
|
|
keyDown(e) {
|
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
|
|
|
var name = this.getHotkeyName(e)
|
|
if (!name) return
|
|
|
|
// Playing audiobook
|
|
if (Object.values(this.$hotkeys.AudioPlayer).includes(name)) {
|
|
this.$eventBus.$emit('player-hotkey', name)
|
|
e.preventDefault()
|
|
}
|
|
},
|
|
resize() {
|
|
setTimeout(() => {
|
|
this.windowWidth = window.innerWidth
|
|
this.windowHeight = window.innerHeight
|
|
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
|
}, 100)
|
|
},
|
|
playerError(error) {
|
|
console.error('Player error', error)
|
|
this.$toast.error('Failed to play audio on device')
|
|
},
|
|
playerFinished() {
|
|
console.log('Player finished')
|
|
},
|
|
downloadShareItem() {
|
|
this.$downloadFile(this.downloadUrl)
|
|
}
|
|
},
|
|
mounted() {
|
|
this.$store.dispatch('user/loadUserSettings')
|
|
|
|
this.resize()
|
|
window.addEventListener('resize', this.resize)
|
|
window.addEventListener('keydown', this.keyDown)
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log('Loaded media item share', this.mediaItemShare)
|
|
}
|
|
|
|
const startTime = this.playbackSession.currentTime || 0
|
|
this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false)
|
|
this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this))
|
|
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
|
|
this.localAudioPlayer.on('error', this.playerError.bind(this))
|
|
this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
|
|
|
|
this.setMediaSession()
|
|
},
|
|
beforeDestroy() {
|
|
window.removeEventListener('resize', this.resize)
|
|
window.removeEventListener('keydown', this.keyDown)
|
|
|
|
this.localAudioPlayer.off('stateChange', this.playerStateChange.bind(this))
|
|
this.localAudioPlayer.off('timeupdate', this.playerTimeUpdate.bind(this))
|
|
this.localAudioPlayer.off('error', this.playerError.bind(this))
|
|
this.localAudioPlayer.off('finished', this.playerFinished.bind(this))
|
|
this.localAudioPlayer.destroy()
|
|
}
|
|
}
|
|
</script>
|