diff --git a/client/components/AudioPlayer.vue b/client/components/AudioPlayer.vue index ac95ffd0..bf9e0b29 100644 --- a/client/components/AudioPlayer.vue +++ b/client/components/AudioPlayer.vue @@ -12,7 +12,7 @@
@@ -21,13 +21,13 @@
first_page
-
+
replay_10
-
- {{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }} +
+ {{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}
-
+
forward_10
@@ -75,20 +75,15 @@

{{ timeRemainingPretty }}

-
diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 1ee3d4a3..def74f2f 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -196,8 +196,6 @@ export default { displayTitle() { if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) { return this.title.substr(4) + ', The' - } else { - console.log('DOES NOT COMPUTE', this.orderBy, this.sortingIgnorePrefix, this.title.toLowerCase()) } return this.title }, @@ -497,8 +495,8 @@ export default { this.$emit('select', this.audiobook) }, play() { - this.store.commit('setStreamAudiobook', this.audiobook) - this._socket.emit('open_stream', this.audiobookId) + var eventBus = this.$eventBus || this.$nuxt.$eventBus + eventBus.$emit('play-audiobook', this.audiobookId) }, mouseover() { this.isHovering = true diff --git a/client/components/tables/collection/BookTableRow.vue b/client/components/tables/collection/BookTableRow.vue index 729f707b..3fee336b 100644 --- a/client/components/tables/collection/BookTableRow.vue +++ b/client/components/tables/collection/BookTableRow.vue @@ -133,8 +133,7 @@ export default { this.isHovering = false }, playClick() { - this.$store.commit('setStreamAudiobook', this.book) - this.$root.socket.emit('open_stream', this.book.id) + this.$eventBus.$emit('play-audiobook', this.book.id) }, clickEdit() { this.$emit('edit', this.book) diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 6012bf2d..8286f913 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -43,6 +43,9 @@ export default { computed: { user() { return this.$store.state.user.user + }, + isCasting() { + return this.$store.state.globals.isCasting } }, methods: { @@ -99,7 +102,6 @@ export default { console.log('Init Payload', payload) if (payload.stream) { if (this.$refs.streamContainer) { - this.$store.commit('setStream', payload.stream) this.$refs.streamContainer.streamOpen(payload.stream) } else { console.warn('Stream Container not mounted') @@ -111,6 +113,11 @@ export default { } if (payload.serverSettings) { this.$store.commit('setServerSettings', payload.serverSettings) + + if (payload.serverSettings.chromecastEnabled) { + console.log('Chromecast enabled import script') + require('@/plugins/chromecast.js').default(this) + } } // Start scans currently running @@ -511,6 +518,7 @@ export default { this.resize() window.addEventListener('resize', this.resize) window.addEventListener('keydown', this.keyDown) + this.$store.dispatch('libraries/load') // If experimental features set in local storage diff --git a/client/nuxt.config.js b/client/nuxt.config.js index 6169c2ce..23cc3b37 100644 --- a/client/nuxt.config.js +++ b/client/nuxt.config.js @@ -7,7 +7,7 @@ module.exports = { dev: process.env.NODE_ENV !== 'production', env: { serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333', - // serverUrl: '', + chromecastReceiver: 'FD1F76C5', baseUrl: process.env.BASE_URL || 'http://0.0.0.0' }, // rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '', @@ -50,7 +50,6 @@ module.exports = { // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins plugins: [ - // '@/plugins/chromecast.client.js', '@/plugins/constants.js', '@/plugins/init.client.js', '@/plugins/axios.js', diff --git a/client/pages/audiobook/_id/index.vue b/client/pages/audiobook/_id/index.vue index 8264d80f..3a14e6b9 100644 --- a/client/pages/audiobook/_id/index.vue +++ b/client/pages/audiobook/_id/index.vue @@ -428,8 +428,7 @@ export default { }) }, startStream() { - this.$store.commit('setStreamAudiobook', this.audiobook) - this.$root.socket.emit('open_stream', this.audiobook.id) + this.$eventBus.$emit('play-audiobook', this.audiobook.id) }, editClick() { this.$store.commit('setBookshelfBookIds', []) diff --git a/client/pages/collection/_id.vue b/client/pages/collection/_id.vue index 554ad4cb..31978b49 100644 --- a/client/pages/collection/_id.vue +++ b/client/pages/collection/_id.vue @@ -123,8 +123,7 @@ export default { clickPlay() { var nextBookNotRead = this.playableBooks.find((pb) => !this.userAudiobooks[pb.id] || !this.userAudiobooks[pb.id].isRead) if (nextBookNotRead) { - this.$store.commit('setStreamAudiobook', nextBookNotRead) - this.$root.socket.emit('open_stream', nextBookNotRead.id) + this.$eventBus.$emit('play-audiobook', nextBookNotRead.id) } } }, diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index bd31e9b5..28a2ce76 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -38,10 +38,15 @@
- +

Ignore prefix "The" when sorting title and series

+
+ +

Enable Chromecast

+
+

Scanner Settings

@@ -217,10 +222,8 @@ export default { } }, methods: { - updateSortIgnorePrefix(val) { - this.updateServerSettings({ - sortingIgnorePrefix: val - }) + updateEnableChromecast(val) { + this.updateServerSettings({ enableChromecast: val }) }, updateScannerFindCovers(val) { this.updateServerSettings({ @@ -263,6 +266,11 @@ export default { bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD }) }, + updateSettingsKey(key, val) { + this.updateServerSettings({ + [key]: val + }) + }, updateServerSettings(payload) { this.updatingServerSettings = true this.$store diff --git a/client/players/AudioTrack.js b/client/players/AudioTrack.js new file mode 100644 index 00000000..da7ba92e --- /dev/null +++ b/client/players/AudioTrack.js @@ -0,0 +1,19 @@ +export default class AudioTrack { + constructor(track) { + this.index = track.index || 0 + this.startOffset = track.startOffset || 0 // Total time of all previous tracks + this.duration = track.duration || 0 + this.title = track.filename || '' + this.contentUrl = track.contentUrl || null + this.mimeType = track.mimeType + } + + get fullContentUrl() { + if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl + + if (process.env.NODE_ENV === 'development') { + return `${process.env.serverUrl}${this.contentUrl}` + } + return `${window.location.origin}/${this.contentUrl}` + } +} \ No newline at end of file diff --git a/client/players/CastPlayer.js b/client/players/CastPlayer.js new file mode 100644 index 00000000..9fd517a7 --- /dev/null +++ b/client/players/CastPlayer.js @@ -0,0 +1,140 @@ +import { buildCastLoadRequest, castLoadMedia } from "./castUtils" +import EventEmitter from 'events' + +export default class CastPlayer extends EventEmitter { + constructor(ctx) { + super() + + this.ctx = ctx + this.player = null + this.playerController = null + + this.audiobook = null + this.audioTracks = [] + this.currentTrackIndex = 0 + this.hlsStreamId = null + this.currentTime = 0 + this.playWhenReady = false + this.defaultPlaybackRate = 1 + + this.coverUrl = '' + this.castPlayerState = 'IDLE' + + // Supported audio codecs for chromecast + this.supportedAudioCodecs = ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav'] + + this.initialize() + } + + get currentTrack() { + return this.audioTracks[this.currentTrackIndex] || {} + } + + initialize() { + this.player = this.ctx.$root.castPlayer + this.playerController = this.ctx.$root.castPlayerController + this.playerController.addEventListener( + cast.framework.RemotePlayerEventType.MEDIA_INFO_CHANGED, this.evtMediaInfoChanged.bind(this)) + } + + evtMediaInfoChanged() { + // Use the current session to get an up to date media status. + let session = cast.framework.CastContext.getInstance().getCurrentSession() + if (!session) { + return + } + let media = session.getMediaSession() + if (!media) { + return + } + + // var currentItemId = media.currentItemId + var currentItemId = media.media.itemId + if (currentItemId && this.currentTrackIndex !== currentItemId - 1) { + this.currentTrackIndex = currentItemId - 1 + } + + if (media.playerState !== this.castPlayerState) { + this.emit('stateChange', media.playerState) + this.castPlayerState = media.playerState + } + } + + destroy() { + if (this.playerController) { + this.playerController.stop() + } + } + + async set(audiobook, tracks, hlsStreamId, startTime, playWhenReady = false) { + this.audiobook = audiobook + this.audioTracks = tracks + this.hlsStreamId = hlsStreamId + this.playWhenReady = playWhenReady + + this.currentTime = startTime + + var coverImg = this.ctx.$store.getters['audiobooks/getBookCoverSrc'](audiobook) + if (process.env.NODE_ENV === 'development') { + this.coverUrl = coverImg + } else { + this.coverUrl = `${window.location.origin}/${coverImg}` + } + + var request = buildCastLoadRequest(this.audiobook, this.coverUrl, this.audioTracks, this.currentTime, playWhenReady, this.defaultPlaybackRate) + + var castSession = cast.framework.CastContext.getInstance().getCurrentSession() + await castLoadMedia(castSession, request) + } + + resetStream(startTime) { + // Cast only direct play for now + } + + playPause() { + if (this.playerController) this.playerController.playOrPause() + } + + play() { + if (this.playerController) this.playerController.playOrPause() + } + + pause() { + if (this.playerController) this.playerController.playOrPause() + } + + getCurrentTime() { + var currentTrackOffset = this.currentTrack.startOffset || 0 + return this.player ? currentTrackOffset + this.player.currentTime : 0 + } + + getDuration() { + if (!this.audioTracks.length) return 0 + var lastTrack = this.audioTracks[this.audioTracks.length - 1] + return lastTrack.startOffset + lastTrack.duration + } + + setPlaybackRate(playbackRate) { + this.defaultPlaybackRate = playbackRate + } + + async seek(time, playWhenReady) { + if (!this.player) return + if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) { + // Change Track + var request = buildCastLoadRequest(this.audiobook, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate) + var castSession = cast.framework.CastContext.getInstance().getCurrentSession() + await castLoadMedia(castSession, request) + } else { + var offsetTime = time - (this.currentTrack.startOffset || 0) + this.player.currentTime = Math.max(0, offsetTime) + this.playerController.seek() + } + } + + setVolume(volume) { + if (!this.player) return + this.player.volumeLevel = volume + this.playerController.setVolumeLevel() + } +} \ No newline at end of file diff --git a/client/players/LocalPlayer.js b/client/players/LocalPlayer.js new file mode 100644 index 00000000..730f9bef --- /dev/null +++ b/client/players/LocalPlayer.js @@ -0,0 +1,238 @@ +import Hls from 'hls.js' +import EventEmitter from 'events' + +export default class LocalPlayer extends EventEmitter { + constructor(ctx) { + super() + + this.ctx = ctx + this.player = null + + this.audiobook = null + this.audioTracks = [] + this.currentTrackIndex = 0 + this.hlsStreamId = null + this.hlsInstance = null + this.usingNativeplayer = false + this.currentTime = 0 + this.playWhenReady = false + this.defaultPlaybackRate = 1 + + this.initialize() + } + + get currentTrack() { + return this.audioTracks[this.currentTrackIndex] || {} + } + + initialize() { + if (document.getElementById('audio-player')) { + document.getElementById('audio-player').remove() + } + var audioEl = document.createElement('audio') + audioEl.id = 'audio-player' + audioEl.style.display = 'none' + document.body.appendChild(audioEl) + this.player = audioEl + + this.player.addEventListener('play', this.evtPlay.bind(this)) + this.player.addEventListener('pause', this.evtPause.bind(this)) + this.player.addEventListener('progress', this.evtProgress.bind(this)) + this.player.addEventListener('error', this.evtError.bind(this)) + this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this)) + this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this)) + } + + evtPlay() { + this.emit('stateChange', 'PLAYING') + } + evtPause() { + this.emit('stateChange', 'PAUSED') + } + evtProgress() { + var lastBufferTime = this.getLastBufferedTime() + this.emit('buffertimeUpdate', lastBufferTime) + } + evtError(error) { + console.error('Player error', error) + } + evtLoadedMetadata(data) { + console.log('Audio Loaded Metadata', data) + this.emit('stateChange', 'LOADED') + if (this.playWhenReady) { + this.playWhenReady = false + this.play() + } + } + evtTimeupdate() { + if (this.player.paused) { + this.emit('timeupdate', this.getCurrentTime()) + } + } + + destroy() { + if (this.hlsStreamId) { + // Close HLS Stream + console.log('Closing HLS Streams', this.hlsStreamId) + this.ctx.$axios.$post(`/api/streams/${this.hlsStreamId}/close`).catch((error) => { + console.error('Failed to request close hls stream', this.hlsStreamId, error) + }) + } + this.destroyHlsInstance() + if (this.player) { + this.player.remove() + } + } + + set(audiobook, tracks, hlsStreamId, startTime, playWhenReady = false) { + this.audiobook = audiobook + this.audioTracks = tracks + this.hlsStreamId = hlsStreamId + this.playWhenReady = playWhenReady + if (this.hlsInstance) { + this.destroyHlsInstance() + } + + this.currentTime = startTime + + // iOS does not support Media Elements but allows for HLS in the native audio player + if (!Hls.isSupported()) { + console.warn('HLS is not supported - fallback to using audio element') + this.usingNativeplayer = true + this.player.src = this.currentTrack.fullContentUrl + this.player.currentTime = this.currentTime + return + } + + var hlsOptions = { + startPosition: this.currentTime || -1 + // No longer needed because token is put in a query string + // xhrSetup: (xhr) => { + // xhr.setRequestHeader('Authorization', `Bearer ${this.token}`) + // } + } + this.hlsInstance = new Hls(hlsOptions) + + this.hlsInstance.attachMedia(this.player) + this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => { + this.hlsInstance.loadSource(this.currentTrack.fullContentUrl) + + this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + console.log('[HLS] Manifest Parsed') + }) + + this.hlsInstance.on(Hls.Events.ERROR, (e, data) => { + console.error('[HLS] Error', data.type, data.details, data) + if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) { + console.error('[HLS] BUFFER STALLED ERROR') + } + }) + this.hlsInstance.on(Hls.Events.DESTROYING, () => { + console.log('[HLS] Destroying HLS Instance') + }) + }) + } + + destroyHlsInstance() { + if (!this.hlsInstance) return + if (this.hlsInstance.destroy) { + var temp = this.hlsInstance + temp.destroy() + } + this.hlsInstance = null + } + + async resetStream(startTime) { + this.destroyHlsInstance() + await new Promise((resolve) => setTimeout(resolve, 1000)) + this.set(this.audiobook, this.audioTracks, this.hlsStreamId, startTime, true) + } + + playPause() { + if (!this.player) return + if (this.player.paused) this.play() + else this.pause() + } + + play() { + if (this.player) this.player.play() + } + + pause() { + if (this.player) this.player.pause() + } + + getCurrentTime() { + var currentTrackOffset = this.currentTrack.startOffset || 0 + return this.player ? currentTrackOffset + this.player.currentTime : 0 + } + + getDuration() { + if (!this.audioTracks.length) return 0 + var lastTrack = this.audioTracks[this.audioTracks.length - 1] + return lastTrack.startOffset + lastTrack.duration + } + + setPlaybackRate(playbackRate) { + if (!this.player) return + this.defaultPlaybackRate = playbackRate + this.player.playbackRate = playbackRate + } + + seek(time) { + if (!this.player) return + var offsetTime = time - (this.currentTrack.startOffset || 0) + this.player.currentTime = Math.max(0, offsetTime) + } + + setVolume(volume) { + if (!this.player) return + this.player.volume = volume + } + + + // Utils + isValidDuration(duration) { + if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) { + return true + } + return false + } + + getBufferedRanges() { + if (!this.player) return [] + const ranges = [] + const seekable = this.player.buffered || [] + + let offset = 0 + + for (let i = 0, length = seekable.length; i < length; i++) { + let start = seekable.start(i) + let end = seekable.end(i) + if (!this.isValidDuration(start)) { + start = 0 + } + if (!this.isValidDuration(end)) { + end = 0 + continue + } + + ranges.push({ + start: start + offset, + end: end + offset + }) + } + return ranges + } + + getLastBufferedTime() { + var bufferedRanges = this.getBufferedRanges() + if (!bufferedRanges.length) return 0 + + var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime) + if (buff) return buff.end + + var last = bufferedRanges[bufferedRanges.length - 1] + return last.end + } +} \ No newline at end of file diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js new file mode 100644 index 00000000..8c4a9d64 --- /dev/null +++ b/client/players/PlayerHandler.js @@ -0,0 +1,306 @@ +import LocalPlayer from './LocalPlayer' +import CastPlayer from './CastPlayer' +import AudioTrack from './AudioTrack' + +export default class PlayerHandler { + constructor(ctx) { + this.ctx = ctx + this.audiobook = null + this.playWhenReady = false + this.player = null + this.playerState = 'IDLE' + this.currentStreamId = null + this.startTime = 0 + + this.lastSyncTime = 0 + this.lastSyncedAt = 0 + this.listeningTimeSinceSync = 0 + + this.playInterval = null + } + + get isCasting() { + return this.ctx.$store.state.globals.isCasting + } + get isPlayingCastedAudiobook() { + return this.audiobook && (this.player instanceof CastPlayer) + } + get isPlayingLocalAudiobook() { + return this.audiobook && (this.player instanceof LocalPlayer) + } + get userToken() { + return this.ctx.$store.getters['user/getToken'] + } + get playerPlaying() { + return this.playerState === 'PLAYING' + } + + load(audiobook, playWhenReady, startTime = 0) { + if (!this.player) this.switchPlayer() + + console.log('Load audiobook', audiobook) + this.audiobook = audiobook + this.startTime = startTime + this.playWhenReady = playWhenReady + this.prepare() + } + + switchPlayer() { + if (this.isCasting && !(this.player instanceof CastPlayer)) { + console.log('[PlayerHandler] Switching to cast player') + + this.stopPlayInterval() + this.playerStateChange('LOADING') + + this.startTime = this.player ? this.player.getCurrentTime() : this.startTime + if (this.player) { + this.player.destroy() + } + this.player = new CastPlayer(this.ctx) + this.setPlayerListeners() + + if (this.audiobook) { + // Audiobook was already loaded - prepare for cast + this.playWhenReady = false + this.prepare() + } + } else if (!this.isCasting && !(this.player instanceof LocalPlayer)) { + console.log('[PlayerHandler] Switching to local player') + + this.stopPlayInterval() + this.playerStateChange('LOADING') + + if (this.player) { + this.player.destroy() + } + this.player = new LocalPlayer(this.ctx) + this.setPlayerListeners() + + if (this.audiobook) { + // Audiobook was already loaded - prepare for local play + this.playWhenReady = false + this.prepare() + } + } + } + + setPlayerListeners() { + this.player.on('stateChange', this.playerStateChange.bind(this)) + this.player.on('timeupdate', this.playerTimeupdate.bind(this)) + this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this)) + } + + playerStateChange(state) { + console.log('[PlayerHandler] Player state change', state) + this.playerState = state + if (this.playerState === 'PLAYING') { + this.startPlayInterval() + } else { + this.stopPlayInterval() + } + if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') { + this.ctx.setDuration(this.player.getDuration()) + } + if (this.playerState !== 'LOADING') { + this.ctx.setCurrentTime(this.player.getCurrentTime()) + } + + this.ctx.isPlaying = this.playerState === 'PLAYING' + this.ctx.playerLoading = this.playerState === 'LOADING' + } + + playerTimeupdate(time) { + this.ctx.setCurrentTime(time) + } + + playerBufferTimeUpdate(buffertime) { + this.ctx.setBufferTime(buffertime) + } + + async prepare() { + var useHls = !this.isCasting + if (useHls) { + var stream = await this.ctx.$axios.$get(`/api/books/${this.audiobook.id}/stream`).catch((error) => { + console.error('Failed to start stream', error) + }) + if (stream) { + console.log(`[PlayerHandler] prepare hls stream`, stream) + this.setHlsStream(stream) + } + } else { + // Setup tracks + var runningTotal = 0 + var audioTracks = (this.audiobook.tracks || []).map((track) => { + var audioTrack = new AudioTrack(track) + audioTrack.startOffset = runningTotal + audioTrack.contentUrl = `/lib/${this.audiobook.libraryId}/${this.audiobook.folderId}/${track.path}?token=${this.userToken}` + audioTrack.mimeType = (track.codec === 'm4b' || track.codec === 'm4a') ? 'audio/mp4' : `audio/${track.codec}` + + runningTotal += audioTrack.duration + return audioTrack + }) + this.setDirectPlay(audioTracks) + } + } + + closePlayer() { + console.log('[PlayerHandler] CLose Player') + if (this.player) { + this.player.destroy() + } + this.player = null + this.playerState = 'IDLE' + this.audiobook = null + this.currentStreamId = null + this.startTime = 0 + this.stopPlayInterval() + } + + prepareStream(stream) { + if (!this.player) this.switchPlayer() + this.audiobook = stream.audiobook + this.setHlsStream({ + streamId: stream.id, + streamUrl: stream.clientPlaylistUri, + startTime: stream.clientCurrentTime + }) + } + + setHlsStream(stream) { + this.currentStreamId = stream.streamId + var audioTrack = new AudioTrack({ + duration: this.audiobook.duration, + contentUrl: stream.streamUrl + '?token=' + this.userToken, + mimeType: 'application/vnd.apple.mpegurl' + }) + this.startTime = stream.startTime + this.ctx.playerLoading = true + this.player.set(this.audiobook, [audioTrack], this.currentStreamId, stream.startTime, this.playWhenReady) + } + + setDirectPlay(audioTracks) { + this.currentStreamId = null + this.ctx.playerLoading = true + this.player.set(this.audiobook, audioTracks, null, this.startTime, this.playWhenReady) + } + + resetStream(startTime, streamId) { + if (this.currentStreamId === streamId) { + this.player.resetStream(startTime) + } else { + console.warn('resetStream mismatch streamId', this.currentStreamId, streamId) + } + } + + startPlayInterval() { + clearInterval(this.playInterval) + var lastTick = Date.now() + this.playInterval = setInterval(() => { + // Update UI + if (!this.player) return + var currentTime = this.player.getCurrentTime() + this.ctx.setCurrentTime(currentTime) + + var exactTimeElapsed = ((Date.now() - lastTick) / 1000) + lastTick = Date.now() + this.listeningTimeSinceSync += exactTimeElapsed + if (this.listeningTimeSinceSync >= 5) { + this.sendProgressSync(currentTime) + this.listeningTimeSinceSync = 0 + } + }, 1000) + } + + sendProgressSync(currentTime) { + var diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime) + if (diffSinceLastSync < 1) return + + this.lastSyncTime = currentTime + if (this.currentStreamId) { // Updating stream progress (HLS stream) + var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync)) + var syncData = { + timeListened: listeningTimeToAdd, + currentTime, + streamId: this.currentStreamId, + audiobookId: this.audiobook.id + } + this.ctx.$axios.$post('/api/syncStream', syncData, { timeout: 1000 }).catch((error) => { + console.error('Failed to update stream progress', error) + }) + } else { + // Direct play via chromecast does not yet have backend stream session model + // so the progress update for the audiobook is updated this way (instead of through the stream) + var duration = this.getDuration() + var syncData = { + totalDuration: duration, + currentTime, + progress: duration > 0 ? currentTime / duration : 0, + isRead: false, + audiobookId: this.audiobook.id, + lastUpdate: Date.now() + } + this.ctx.$axios.$post('/api/syncLocal', syncData, { timeout: 1000 }).catch((error) => { + console.error('Failed to update local progress', error) + }) + } + } + + stopPlayInterval() { + clearInterval(this.playInterval) + this.playInterval = null + } + + playPause() { + if (this.player) this.player.playPause() + } + + play() { + if (!this.player) return + this.player.play() + } + + pause() { + if (this.player) this.player.pause() + } + + getCurrentTime() { + return this.player ? this.player.getCurrentTime() : 0 + } + + getDuration() { + return this.player ? this.player.getDuration() : 0 + } + + jumpBackward() { + if (!this.player) return + var currentTime = this.getCurrentTime() + this.seek(Math.max(0, currentTime - 10)) + } + + jumpForward() { + if (!this.player) return + var currentTime = this.getCurrentTime() + this.seek(Math.min(currentTime + 10, this.getDuration())) + } + + setVolume(volume) { + if (!this.player) return + this.player.setVolume(volume) + } + + setPlaybackRate(playbackRate) { + if (!this.player) return + this.player.setPlaybackRate(playbackRate) + } + + seek(time) { + if (!this.player) return + this.player.seek(time, this.playerPlaying) + this.ctx.setCurrentTime(time) + + // Update progress if paused + if (!this.playerPlaying) { + this.sendProgressSync(time) + } + } +} \ No newline at end of file diff --git a/client/players/castUtils.js b/client/players/castUtils.js new file mode 100644 index 00000000..20d8d96c --- /dev/null +++ b/client/players/castUtils.js @@ -0,0 +1,74 @@ + +function getMediaInfoFromTrack(audiobook, castImage, track) { + // https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.AudiobookChapterMediaMetadata + var metadata = new chrome.cast.media.AudiobookChapterMediaMetadata() + metadata.bookTitle = audiobook.book.title + metadata.chapterNumber = track.index + metadata.chapterTitle = track.title + metadata.images = [castImage] + metadata.title = track.title + metadata.subtitle = audiobook.book.title + + var trackurl = track.fullContentUrl + var mimeType = track.mimeType + var mediainfo = new chrome.cast.media.MediaInfo(trackurl, mimeType) + mediainfo.metadata = metadata + mediainfo.itemId = track.index + mediainfo.duration = track.duration + return mediainfo +} + +function buildCastMediaInfo(audiobook, coverUrl, tracks) { + const castImage = new chrome.cast.Image(coverUrl) + return tracks.map(t => getMediaInfoFromTrack(audiobook, castImage, t)) +} + +function buildCastQueueRequest(audiobook, coverUrl, tracks, startTime) { + var mediaInfoItems = buildCastMediaInfo(audiobook, coverUrl, tracks) + + var containerMetadata = new chrome.cast.media.AudiobookContainerMetadata() + containerMetadata.authors = [audiobook.book.authorFL] + containerMetadata.narrators = [audiobook.book.narratorFL] + containerMetadata.publisher = audiobook.book.publisher || undefined + + var mediaQueueItems = mediaInfoItems.map((mi) => { + var queueItem = new chrome.cast.media.QueueItem(mi) + return queueItem + }) + + // Find track to start playback and calculate track start offset + var track = tracks.find(at => at.startOffset <= startTime && at.startOffset + at.duration > startTime) + var trackStartIndex = track ? track.index - 1 : 0 + var trackStartTime = Math.floor(track ? startTime - track.startOffset : 0) + + var queueData = new chrome.cast.media.QueueData(audiobook.id, audiobook.book.title, '', false, mediaQueueItems, trackStartIndex, trackStartTime) + queueData.containerMetadata = containerMetadata + queueData.queueType = chrome.cast.media.QueueType.AUDIOBOOK + return queueData +} + +function castLoadMedia(castSession, request) { + return new Promise((resolve) => { + castSession.loadMedia(request) + .then(() => resolve(true), (reason) => { + console.error('Load media failed', reason) + resolve(false) + }) + }) +} + +function buildCastLoadRequest(audiobook, coverUrl, tracks, startTime, autoplay, playbackRate) { + var request = new chrome.cast.media.LoadRequest() + + request.queueData = buildCastQueueRequest(audiobook, coverUrl, tracks, startTime) + request.currentTime = request.queueData.startTime + + request.autoplay = autoplay + request.playbackRate = playbackRate + return request +} + +export { + buildCastLoadRequest, + castLoadMedia +} \ No newline at end of file diff --git a/client/plugins/chromecast.client.js b/client/plugins/chromecast.client.js deleted file mode 100644 index a26bd108..00000000 --- a/client/plugins/chromecast.client.js +++ /dev/null @@ -1,46 +0,0 @@ -var initializeCastApi = function () { - var context = cast.framework.CastContext.getInstance() - context.setOptions({ - receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, - autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED - }); - - context.addEventListener( - cast.framework.CastContextEventType.SESSION_STATE_CHANGED, - (event) => { - console.log('Session state changed event', event) - - switch (event.sessionState) { - case cast.framework.SessionState.SESSION_STARTED: - console.log('CAST SESSION STARTED') - - // Test: Casting an image - // var castSession = cast.framework.CastContext.getInstance().getCurrentSession(); - // var mediaInfo = new chrome.cast.media.MediaInfo('https://images.unsplash.com/photo-1519331379826-f10be5486c6f', 'image/jpg'); - // var request = new chrome.cast.media.LoadRequest(mediaInfo); - // castSession.loadMedia(request).then( - // function () { console.log('Load succeed'); }, - // function (errorCode) { console.log('Error code: ' + errorCode); }) - - break; - case cast.framework.SessionState.SESSION_RESUMED: - console.log('CAST SESSION RESUMED') - break; - case cast.framework.SessionState.SESSION_ENDED: - console.log('CastContext: CastSession disconnected') - // Update locally as necessary - break; - } - }) -} - -window['__onGCastApiAvailable'] = function (isAvailable) { - if (isAvailable) { - initializeCastApi() - } -} - -var script = document.createElement('script') -script.type = 'text/javascript' -script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1' -document.head.appendChild(script) diff --git a/client/plugins/chromecast.js b/client/plugins/chromecast.js new file mode 100644 index 00000000..b544a068 --- /dev/null +++ b/client/plugins/chromecast.js @@ -0,0 +1,80 @@ +export default (ctx) => { + var sendInit = async (castContext) => { + // Fetch background covers for chromecast (temp) + var covers = await ctx.$axios.$get(`/api/libraries/${ctx.$store.state.libraries.currentLibraryId}/books/all?limit=40&minified=1`).then((data) => { + return data.results.filter((b) => b.book.cover).map((ab) => { + var coverUrl = ctx.$store.getters['audiobooks/getBookCoverSrc'](ab) + if (process.env.NODE_ENV === 'development') return coverUrl + return `${window.location.origin}/${coverUrl}` + }) + }).catch((error) => { + console.error('failed to fetch books', error) + return null + }) + + // Custom message to receiver + var castSession = castContext.getCurrentSession() + castSession.sendMessage('urn:x-cast:com.audiobookshelf.cast', { + covers + }) + } + + var initializeCastApi = () => { + var castContext = cast.framework.CastContext.getInstance() + castContext.setOptions({ + receiverApplicationId: process.env.chromecastReceiver, + autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED + }); + + castContext.addEventListener( + cast.framework.CastContextEventType.SESSION_STATE_CHANGED, + (event) => { + console.log('Session state changed event', event) + + switch (event.sessionState) { + case cast.framework.SessionState.SESSION_STARTED: + console.log('[chromecast] CAST SESSION STARTED') + + ctx.$store.commit('globals/setCasting', true) + sendInit(castContext) + + setTimeout(() => { + ctx.$eventBus.$emit('cast-session-active', true) + }, 500) + + break; + case cast.framework.SessionState.SESSION_RESUMED: + console.log('[chromecast] CAST SESSION RESUMED') + + setTimeout(() => { + ctx.$eventBus.$emit('cast-session-active', true) + }, 500) + break; + case cast.framework.SessionState.SESSION_ENDED: + console.log('[chromecast] CAST SESSION DISCONNECTED') + + ctx.$store.commit('globals/setCasting', false) + ctx.$eventBus.$emit('cast-session-active', false) + break; + } + }) + + ctx.$store.commit('globals/setChromecastInitialized', true) + + var player = new cast.framework.RemotePlayer() + var playerController = new cast.framework.RemotePlayerController(player) + ctx.$root.castPlayer = player + ctx.$root.castPlayerController = playerController + } + + window['__onGCastApiAvailable'] = function (isAvailable) { + if (isAvailable) { + initializeCastApi() + } + } + + var script = document.createElement('script') + script.type = 'text/javascript' + script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1' + document.head.appendChild(script) +} \ No newline at end of file diff --git a/client/store/globals.js b/client/store/globals.js index afacd6fa..2720c760 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -6,7 +6,9 @@ export const state = () => ({ showUserCollectionsModal: false, showEditCollectionModal: false, selectedCollection: null, - showBookshelfTextureModal: false + showBookshelfTextureModal: false, + isCasting: false, // Actively casting + isChromecastInitialized: false // Script loaded }) export const getters = {} @@ -33,5 +35,11 @@ export const mutations = { }, setShowBookshelfTextureModal(state, val) { state.showBookshelfTextureModal = val + }, + setChromecastInitialized(state, val) { + state.isChromecastInitialized = val + }, + setCasting(state, val) { + state.isCasting = val } } \ No newline at end of file diff --git a/client/store/index.js b/client/store/index.js index 443aabad..3d85edf6 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -10,7 +10,6 @@ export const state = () => ({ showEReader: false, selectedAudiobook: null, selectedAudiobookFile: null, - playOnLoad: false, developerMode: false, selectedAudiobooks: [], processingBatch: false, @@ -107,25 +106,8 @@ export const mutations = { state.serverSettings = settings }, setStreamAudiobook(state, audiobook) { - state.playOnLoad = true state.streamAudiobook = audiobook }, - updateStreamAudiobook(state, audiobook) { // Initial stream audiobook is minified, on open audiobook is updated to full - state.streamAudiobook = audiobook - }, - setStream(state, stream) { - state.playOnLoad = false - state.streamAudiobook = stream ? stream.audiobook : null - }, - clearStreamAudiobook(state, audiobookId) { - if (state.streamAudiobook && state.streamAudiobook.id === audiobookId) { - state.playOnLoad = false - state.streamAudiobook = null - } - }, - setPlayOnLoad(state, val) { - state.playOnLoad = val - }, showEditModal(state, audiobook) { state.editModalTab = 'details' state.selectedAudiobook = audiobook diff --git a/server/ApiController.js b/server/ApiController.js index edb0b661..96cef619 100644 --- a/server/ApiController.js +++ b/server/ApiController.js @@ -178,6 +178,8 @@ class ApiController { this.router.post('/syncStream', this.syncStream.bind(this)) this.router.post('/syncLocal', this.syncLocal.bind(this)) + + this.router.post('/streams/:id/close', this.closeStream.bind(this)) } async findBooks(req, res) { @@ -397,6 +399,7 @@ class ApiController { data: audiobookProgress || null }) } + res.sendStatus(200) } // @@ -518,5 +521,12 @@ class ApiController { await this.cacheManager.purgeAll() res.sendStatus(200) } + + async closeStream(req, res) { + const streamId = req.params.id + const userId = req.user.id + this.streamManager.closeStreamApiRequest(userId, streamId) + res.sendStatus(200) + } } module.exports = ApiController \ No newline at end of file diff --git a/server/Server.js b/server/Server.js index 1bd5da04..5ab7baca 100644 --- a/server/Server.js +++ b/server/Server.js @@ -260,7 +260,6 @@ class Server { // Streaming socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket)) - socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload)) socket.on('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData)) socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload)) diff --git a/server/StreamManager.js b/server/StreamManager.js index 2b8a6451..0ed1e2e1 100644 --- a/server/StreamManager.js +++ b/server/StreamManager.js @@ -155,6 +155,30 @@ class StreamManager { this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams)) } + async closeStreamApiRequest(userId, streamId) { + Logger.info('[StreamManager] Close Stream Api Request', streamId) + + var stream = this.streams.find(s => s.id === streamId) + if (!stream) { + Logger.warn('[StreamManager] Stream not found', streamId) + return + } + + if (!stream.client || !stream.client.user || stream.client.user.id !== userId) { + Logger.warn(`[StreamManager] Stream close request from invalid user ${userId}`, stream.client) + return + } + + stream.client.user.stream = null + stream.client.stream = null + this.db.updateUserStream(stream.client.user.id, null) + + await stream.close() + + this.streams = this.streams.filter(s => s.id !== streamId) + Logger.info(`[StreamManager] Stream ${streamId} closed via API request by ${userId}`) + } + streamSync(socket, syncData) { const client = socket.sheepClient if (!client || !client.stream) { @@ -233,35 +257,5 @@ class StreamManager { res.sendStatus(200) } - - streamUpdate(socket, { currentTime, streamId }) { - var client = socket.sheepClient - if (!client || !client.stream) { - Logger.error('No stream for client', (client && client.user) ? client.user.id : 'No Client') - return - } - if (client.stream.id !== streamId) { - Logger.error('Stream id mismatch on stream update', streamId, client.stream.id) - return - } - client.stream.updateClientCurrentTime(currentTime) - if (!client.user) { - Logger.error('No User for client', client) - return - } - if (!client.user.updateAudiobookProgressFromStream) { - Logger.error('Invalid User for client', client) - return - } - var userAudiobook = client.user.updateAudiobookProgressFromStream(client.stream) - this.db.updateEntity('user', client.user) - - if (userAudiobook) { - this.clientEmitter(client.user.id, 'current_user_audiobook_update', { - id: userAudiobook.audiobookId, - data: userAudiobook.toJSON() - }) - } - } } module.exports = StreamManager \ No newline at end of file diff --git a/server/objects/ServerSettings.js b/server/objects/ServerSettings.js index 8df6fdcd..cf8e8b1e 100644 --- a/server/objects/ServerSettings.js +++ b/server/objects/ServerSettings.js @@ -39,7 +39,7 @@ class ServerSettings { this.bookshelfView = BookshelfView.STANDARD this.sortingIgnorePrefix = false - + this.chromecastEnabled = false this.logLevel = Logger.logLevel this.version = null @@ -73,7 +73,7 @@ class ServerSettings { this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix - + this.chromecastEnabled = !!settings.chromecastEnabled this.logLevel = settings.logLevel || Logger.logLevel this.version = settings.version || null @@ -104,6 +104,7 @@ class ServerSettings { coverAspectRatio: this.coverAspectRatio, bookshelfView: this.bookshelfView, sortingIgnorePrefix: this.sortingIgnorePrefix, + chromecastEnabled: this.chromecastEnabled, logLevel: this.logLevel, version: this.version } diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 02dc0993..1a2ccd28 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -175,11 +175,6 @@ class Stream extends EventEmitter { return false } - updateClientCurrentTime(currentTime) { - Logger.debug('[Stream] Updated client current time', secondsToTimestamp(currentTime)) - this.clientCurrentTime = currentTime - } - syncStream({ timeListened, currentTime }) { var syncLog = '' // Set user current time diff --git a/static/Logo.png b/static/Logo.png deleted file mode 100644 index c9397c02..00000000 Binary files a/static/Logo.png and /dev/null differ