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 @@
-
+
updateSettingsKey('sortingIgnorePrefix', val)" />
Ignore prefix "The" when sorting title and series
+
+
updateSettingsKey('chromecastEnabled', val)" />
+ 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