mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			384 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			384 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <div class="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
 | |
|     }
 | |
|   },
 | |
|   computed: {
 | |
|     playbackSession() {
 | |
|       return this.mediaItemShare.playbackSession
 | |
|     },
 | |
|     coverUrl() {
 | |
|       if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
 | |
|       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)
 | |
|     },
 | |
|     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)) {
 | |
|         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'
 | |
|       }
 | |
|     },
 | |
|     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.localAudioPlayer.setPlaybackRate(playbackRate)
 | |
|     },
 | |
|     seek(time) {
 | |
|       if (!this.localAudioPlayer || !this.hasLoaded) return
 | |
| 
 | |
|       this.localAudioPlayer.seek(time, this.isPlaying)
 | |
|       this.setCurrentTime(time)
 | |
|     },
 | |
|     setCurrentTime(time) {
 | |
|       if (!this.$refs.audioPlayer) return
 | |
| 
 | |
|       // Update UI
 | |
|       this.$refs.audioPlayer.setCurrentTime(time)
 | |
|       this.currentTime = time
 | |
|     },
 | |
|     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() {
 | |
|       this.windowWidth = window.innerWidth
 | |
|       this.windowHeight = window.innerHeight
 | |
|     },
 | |
|     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>
 |