From 89f498f31a31daff0ca31a4da87ac04225e9b07b Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 22 Feb 2022 17:33:55 -0600 Subject: [PATCH] Add:Chromecast support in experimental #367, Change:Audio player model for direct play --- client/components/AudioPlayer.vue | 529 +++--------------- client/components/app/Appbar.vue | 10 + client/components/app/BookListRow.vue | 3 +- client/components/app/StreamContainer.vue | 191 +++---- client/components/cards/LazyBookCard.vue | 6 +- .../tables/collection/BookTableRow.vue | 3 +- client/layouts/default.vue | 10 +- client/nuxt.config.js | 3 +- client/pages/audiobook/_id/index.vue | 3 +- client/pages/collection/_id.vue | 3 +- client/pages/config/index.vue | 18 +- client/players/AudioTrack.js | 19 + client/players/CastPlayer.js | 140 +++++ client/players/LocalPlayer.js | 238 ++++++++ client/players/PlayerHandler.js | 306 ++++++++++ client/players/castUtils.js | 74 +++ client/plugins/chromecast.client.js | 46 -- client/plugins/chromecast.js | 80 +++ client/store/globals.js | 10 +- client/store/index.js | 18 - server/ApiController.js | 10 + server/Server.js | 1 - server/StreamManager.js | 54 +- server/objects/ServerSettings.js | 5 +- server/objects/Stream.js | 5 - static/Logo.png | Bin 20340 -> 0 bytes 26 files changed, 1113 insertions(+), 672 deletions(-) create mode 100644 client/players/AudioTrack.js create mode 100644 client/players/CastPlayer.js create mode 100644 client/players/LocalPlayer.js create mode 100644 client/players/PlayerHandler.js create mode 100644 client/players/castUtils.js delete mode 100644 client/plugins/chromecast.client.js create mode 100644 client/plugins/chromecast.js delete mode 100644 static/Logo.png 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 c9397c020417ed9fa15438d63f55dcad9efb2a9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20340 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