Add:Chromecast support in experimental #367, Change:Audio player model for direct play

This commit is contained in:
advplyr 2022-02-22 17:33:55 -06:00
parent 9f133ba98c
commit 89f498f31a
26 changed files with 1113 additions and 672 deletions

View File

@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-44' : 'right-32'"> <div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" /> <controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" />
</div> </div>
<div class="flex pb-4 md:pb-2"> <div class="flex pb-4 md:pb-2">
@ -21,13 +21,13 @@
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart"> <div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span> <span class="material-icons text-3xl">first_page</span>
</div> </div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="backward10"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span> <span class="material-icons text-3xl">replay_10</span>
</div> </div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick"> <div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span> <span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div> </div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span> <span class="material-icons text-3xl">forward_10</span>
</div> </div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" /> <controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
@ -75,20 +75,15 @@
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p> <p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div> </div>
<audio ref="audio" @progress="progress" @timeupdate="timeupdate" @loadedmetadata="audioLoadedMetadata" @play="audioPlayed" @pause="audioPaused" @error="audioError" @ended="audioEnded" @stalled="audioStalled" @suspend="audioSuspended" />
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div> </div>
</template> </template>
<script> <script>
import Hls from 'hls.js'
export default { export default {
props: { props: {
streamId: String,
audiobookId: String,
loading: Boolean, loading: Boolean,
paused: Boolean,
chapters: { chapters: {
type: Array, type: Array,
default: () => [] default: () => []
@ -100,57 +95,41 @@ export default {
}, },
data() { data() {
return { return {
hlsInstance: null,
staleHlsInstance: null,
usingNativeAudioPlayer: false,
playOnLoad: false,
startTime: 0,
volume: 1, volume: 1,
playbackRate: 1, playbackRate: 1,
trackWidth: 0, trackWidth: 0,
isPaused: true,
url: null,
src: null,
playedTrackWidth: 0, playedTrackWidth: 0,
bufferTrackWidth: 0, bufferTrackWidth: 0,
readyTrackWidth: 0, readyTrackWidth: 0,
audioEl: null, audioEl: null,
totalDuration: 0,
seekedTime: 0,
seekLoading: false, seekLoading: false,
showChaptersModal: false, showChaptersModal: false,
currentTime: 0, currentTime: 0,
trackOffsetLeft: 16, // Track is 16px from edge trackOffsetLeft: 16, // Track is 16px from edge
listenTimeInterval: null, duration: 0
listeningTimeSinceLastUpdate: 0,
totalListeningTimeInSession: 0
} }
}, },
computed: { computed: {
token() { token() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
timeRemaining() { timeRemaining() {
if (!this.audioEl) return 0 return (this.duration - this.currentTime) / this.playbackRate
return (this.totalDuration - this.currentTime) / this.playbackRate
}, },
timeRemainingPretty() { timeRemainingPretty() {
if (this.timeRemaining < 0) { if (this.timeRemaining < 0) {
console.warn('Time remaining < 0', this.totalDuration, this.currentTime, this.timeRemaining) console.warn('Time remaining < 0', this.duration, this.currentTime, this.timeRemaining)
return this.$secondsToTimestamp(this.timeRemaining * -1) return this.$secondsToTimestamp(this.timeRemaining * -1)
} }
return '-' + this.$secondsToTimestamp(this.timeRemaining) return '-' + this.$secondsToTimestamp(this.timeRemaining)
}, },
progressPercent() { progressPercent() {
if (!this.totalDuration) return 0 if (!this.duration) return 0
return Math.round((100 * this.currentTime) / this.totalDuration) return Math.round((100 * this.currentTime) / this.duration)
}, },
chapterTicks() { chapterTicks() {
return this.chapters.map((chap) => { return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration var perc = chap.start / this.duration
return { return {
title: chap.title, title: chap.title,
left: perc * this.trackWidth left: perc * this.trackWidth
@ -168,188 +147,77 @@ export default {
} }
}, },
methods: { methods: {
audioPlayed() { setDuration(duration) {
if (!this.$refs.audio) return this.duration = duration
console.log('Audio Played', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
this.startListenTimeInterval()
this.isPaused = this.$refs.audio.paused
}, },
audioPaused() { setCurrentTime(time) {
if (!this.$refs.audio) return this.currentTime = time
// console.log('Audio Paused', this.$refs.audio.paused, this.$refs.audio.currentTime) this.updateTimestamp()
this.isPaused = this.$refs.audio.paused this.updatePlayedTrack()
this.cancelListenTimeInterval()
}, },
audioError(err) { playPause() {
if (!this.$refs.audio) return this.$emit('playPause')
console.error('Audio Error', this.$refs.audio.paused, this.$refs.audio.currentTime, err)
}, },
audioEnded() { jumpBackward() {
if (!this.$refs.audio) return this.$emit('jumpBackward')
console.log('Audio Ended', this.$refs.audio.paused, this.$refs.audio.currentTime)
}, },
audioStalled() { jumpForward() {
if (!this.$refs.audio) return this.$emit('jumpForward')
console.warn('Audio Stalled', this.$refs.audio.paused, this.$refs.audio.currentTime)
}, },
audioSuspended() { increaseVolume() {
if (!this.$refs.audio) return if (this.volume >= 1) return
console.warn('Audio Suspended', this.$refs.audio.paused, this.$refs.audio.currentTime) this.volume = Math.min(1, this.volume + 0.1)
this.setVolume(this.volume)
}, },
sendStreamSync(timeListened = 0) { decreaseVolume() {
// If currentTime is null then currentTime wont be updated if (this.volume <= 0) return
var currentTime = null this.volume = Math.max(0, this.volume - 0.1)
if (this.$refs.audio) { this.setVolume(this.volume)
currentTime = this.$refs.audio.currentTime },
} else if (!timeListened) { setVolume(volume) {
console.warn('Not sending stream sync, no data to sync') this.$emit('setVolume', volume)
return },
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
} }
var syncData = {
timeListened,
currentTime,
streamId: this.streamId,
audiobookId: this.audiobookId
}
this.$emit('sync', syncData)
}, },
sendAddListeningTime() { increasePlaybackRate() {
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate) var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd) var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
this.sendStreamSync(listeningTimeToAdd) if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
}, },
cancelListenTimeInterval() { decreasePlaybackRate() {
this.sendAddListeningTime() var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
clearInterval(this.listenTimeInterval) var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
this.listenTimeInterval = null if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
}, },
startListenTimeInterval() { setPlaybackRate(playbackRate) {
if (!this.$refs.audio) return this.$emit('setPlaybackRate', playbackRate)
clearInterval(this.listenTimeInterval)
var lastTime = this.$refs.audio.currentTime
var lastTick = Date.now()
var noProgressCount = 0
this.listenTimeInterval = setInterval(() => {
if (!this.$refs.audio) {
console.error('Canceling audio played interval no audio player')
this.cancelListenTimeInterval()
return
}
if (this.$refs.audio.paused) {
console.warn('Canceling audio played interval audio player paused')
this.cancelListenTimeInterval()
return
}
var timeSinceLastTick = Date.now() - lastTick
lastTick = Date.now()
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
var currentTime = this.$refs.audio.currentTime
var differenceFromExpected = expectedAudioTime - currentTime
if (currentTime === lastTime) {
noProgressCount++
if (noProgressCount > 3) {
console.error('Audio current time has not increased - cancel interval and pause player')
this.cancelListenTimeInterval()
this.pause()
}
} else if (Math.abs(differenceFromExpected) > 0.1) {
noProgressCount = 0
console.warn('Invalid time between interval - resync last', differenceFromExpected)
lastTime = currentTime
} else {
noProgressCount = 0
var exactPlayTimeDifference = currentTime - lastTime
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
lastTime = currentTime
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
this.totalListeningTimeInSession += exactPlayTimeDifference
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
if (this.listeningTimeSinceLastUpdate > 5) {
this.sendAddListeningTime()
}
}
}, 1000)
}, },
selectChapter(chapter) { selectChapter(chapter) {
this.seek(chapter.start) this.seek(chapter.start)
this.showChaptersModal = false this.showChaptersModal = false
}, },
selectBookmark(bookmark) {
if (bookmark) {
this.seek(bookmark.time)
}
},
seek(time) { seek(time) {
if (this.loading) { this.$emit('seek', time)
return
}
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
}
if (!this.audioEl) {
console.error('No Audio el for seek', time)
return
}
if (!this.audioEl.paused) {
this.cancelListenTimeInterval()
}
this.seekedTime = time
this.seekLoading = true
this.audioEl.currentTime = time
this.sendStreamSync()
this.$nextTick(() => {
if (this.audioEl && !this.audioEl.paused) {
this.startListenTimeInterval()
}
})
if (this.$refs.playedTrack) {
var perc = time / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
}
},
updateVolume(volume) {
if (this.audioEl) {
this.audioEl.volume = volume
}
},
updatePlaybackRate(playbackRate) {
if (this.audioEl) {
try {
this.audioEl.playbackRate = playbackRate
this.audioEl.defaultPlaybackRate = playbackRate
} catch (error) {
console.error('Update playback rate failed', error)
}
} else {
console.error('No Audio El updatePlaybackRate')
}
}, },
playbackRateUpdated(playbackRate) { playbackRateUpdated(playbackRate) {
this.updatePlaybackRate(playbackRate) this.setPlaybackRate(playbackRate)
}, },
playbackRateChanged(playbackRate) { playbackRateChanged(playbackRate) {
this.updatePlaybackRate(playbackRate) this.setPlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => { this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err) console.error('Failed to update settings', err)
}) })
}, },
mousemoveTrack(e) { mousemoveTrack(e) {
var offsetX = e.offsetX var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.totalDuration var time = (offsetX / this.trackWidth) * this.duration
if (this.$refs.hoverTimestamp) { if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1 this.$refs.hoverTimestamp.style.opacity = 1
@ -395,17 +263,6 @@ export default {
}, },
restart() { restart() {
this.seek(0) this.seek(0)
this.$nextTick(this.sendStreamSync)
},
backward10() {
var newTime = this.audioEl.currentTime - 10
newTime = Math.max(0, newTime)
this.seek(newTime)
},
forward10() {
var newTime = this.audioEl.currentTime + 10
newTime = Math.min(this.audioEl.duration, newTime)
this.seek(newTime)
}, },
setStreamReady() { setStreamReady() {
this.readyTrackWidth = this.trackWidth this.readyTrackWidth = this.trackWidth
@ -435,114 +292,11 @@ export default {
console.error('No timestamp el') console.error('No timestamp el')
return return
} }
if (!this.audioEl) { var currTimeClean = this.$secondsToTimestamp(this.currentTime)
console.error('No Audio El')
return
}
var currTimeClean = this.$secondsToTimestamp(this.audioEl.currentTime)
ts.innerText = currTimeClean ts.innerText = currTimeClean
}, },
clickTrack(e) { updatePlayedTrack() {
var offsetX = e.offsetX var perc = this.currentTime / this.duration
var perc = offsetX / this.trackWidth
var time = perc * this.audioEl.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
playPauseClick() {
if (this.isPaused) {
this.play()
} else {
this.pause()
}
},
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true
}
return false
},
getBufferedRanges() {
if (!this.audioEl) return []
const ranges = []
const seekable = this.audioEl.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.audioEl.currentTime && buff.end > this.audioEl.currentTime)
if (buff) return buff.end
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
},
progress() {
if (!this.audioEl) {
return
}
var lastbuff = this.getLastBufferedTime()
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
timeupdate() {
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
}
if (!this.audioEl) {
console.error('No Audio El')
return
}
if (this.seekLoading) {
this.seekLoading = false
if (this.$refs.playedTrack) {
this.$refs.playedTrack.classList.remove('bg-yellow-300')
this.$refs.playedTrack.classList.add('bg-gray-200')
}
}
this.updateTimestamp()
// Send update to server when currentTime > 0
// this prevents errors when seeking to position not yet transcoded
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
// if (this.audioEl.currentTime) {
// this.sendStreamUpdate()
// }
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth) var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) { if (this.playedTrackWidth === ptWidth) {
return return
@ -550,83 +304,27 @@ export default {
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth this.playedTrackWidth = ptWidth
}, },
audioLoadedMetadata() { clickTrack(e) {
this.totalDuration = this.audioEl.duration if (this.loading) return
this.$emit('loaded', this.totalDuration)
if (this.usingNativeAudioPlayer) { var offsetX = e.offsetX
this.audioEl.currentTime = this.startTime var perc = offsetX / this.trackWidth
this.play() var time = perc * this.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
} }
this.seek(time)
}, },
set(url, currentTime, playOnLoad = false) { setBufferTime(bufferTime) {
if (this.hlsInstance) { if (!this.audioEl) {
this.terminateStream()
}
if (!this.$refs.audio) {
console.error('No audio widget')
return return
} }
this.listeningTimeSinceLastUpdate = 0 var bufferlen = (bufferTime / this.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
this.playOnLoad = playOnLoad if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.startTime = currentTime this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.url = url this.bufferTrackWidth = bufferlen
if (process.env.NODE_ENV === 'development') {
url = `${process.env.serverUrl}${url}`
}
this.src = url
console.log('[AudioPlayer-Set] Set url', url)
var audio = this.$refs.audio
audio.volume = this.volume
audio.defaultPlaybackRate = this.playbackRate
// 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.usingNativeAudioPlayer = true
audio.src = this.src + '?token=' + this.token
audio.currentTime = currentTime
return
}
var hlsOptions = {
startPosition: currentTime || -1,
xhrSetup: (xhr) => {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
}
}
console.log('Starting HLS audio stream at time', currentTime)
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
this.hlsInstance = new Hls(hlsOptions)
this.hlsInstance.attachMedia(audio)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
// console.log('[HLS] MEDIA ATTACHED')
this.hlsInstance.loadSource(url)
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[HLS] Manifest Parsed')
if (playOnLoad) {
this.play()
}
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details, data)
if (this.$refs.audio) {
console.log('Hls error check audio', this.$refs.audio.paused, this.$refs.audio.currentTime, this.$refs.audio.readyState)
}
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')
})
})
}, },
showChapters() { showChapters() {
if (!this.chapters.length) return if (!this.chapters.length) return
@ -635,39 +333,9 @@ export default {
showBookmarks() { showBookmarks() {
this.$emit('showBookmarks', this.currentTime) this.$emit('showBookmarks', this.currentTime)
}, },
play() {
if (!this.$refs.audio) {
console.error('No Audio ref')
return
}
this.$refs.audio.play()
},
pause() {
if (!this.$refs.audio) return
this.$refs.audio.pause()
},
terminateStream() {
if (this.hlsInstance) {
if (!this.hlsInstance.destroy) {
console.error('HLS Instance has no destroy property', this.hlsInstance)
return
}
this.staleHlsInstance = this.hlsInstance
this.staleHlsInstance.destroy()
this.hlsInstance = null
}
},
async resetStream(startTime) {
if (this.$refs.audio) this.$refs.audio.pause()
this.terminateStream()
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('Waited 1 second after terminating stream to start again')
this.set(this.url, startTime, true)
},
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.$emit('setPlaybackRate', this.playbackRate)
this.audioEl = this.$refs.audio
this.setTrackWidth() this.setTrackWidth()
}, },
setTrackWidth() { setTrackWidth() {
@ -679,48 +347,19 @@ export default {
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) { if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate) this.setPlaybackRate(settings.playbackRate)
} }
}, },
volumeUp() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.updateVolume(this.volume)
},
volumeDown() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.updateVolume(this.volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
closePlayer() { closePlayer() {
if (this.loading) return if (this.loading) return
this.$emit('close') this.$emit('close')
}, },
hotkey(action) { hotkey(action) {
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick() if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10() else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10() else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp() else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown() else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute() else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters() else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate() else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()

View File

@ -15,6 +15,10 @@
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span> <span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
<google-cast-launcher></google-cast-launcher>
</div>
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">equalizer</span> <span class="material-icons">equalizer</span>
</nuxt-link> </nuxt-link>
@ -120,6 +124,12 @@ export default {
}, },
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
},
isChromecastEnabled() {
return this.$store.getters['getServerSetting']('chromecastEnabled')
},
isChromecastInitialized() {
return this.$store.state.globals.isChromecastInitialized
} }
}, },
methods: { methods: {

View File

@ -168,8 +168,7 @@ export default {
}) })
}, },
startStream() { startStream() {
this.$store.commit('setStreamAudiobook', this.book) this.$eventBus.$emit('play-audiobook', this.book.id)
this.$root.socket.emit('open_stream', this.book.id)
}, },
editClick() { editClick() {
this.$emit('edit', this.book) this.$emit('edit', this.book)

View File

@ -22,26 +22,29 @@
</div> </div>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="stream" class="material-icons p-4 cursor-pointer" @click="cancelStream">close</span> <span class="material-icons p-4 cursor-pointer" @click="closePlayer">close</span>
</div> </div>
<audio-player ref="audioPlayer" :stream-id="streamId" :audiobook-id="audiobookId" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @sync="sendStreamSync" @hook:mounted="audioPlayerMounted" /> <audio-player ref="audioPlayer" :chapters="chapters" :paused="!isPlaying" :loading="playerLoading" :bookmarks="bookmarks" @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" @close="closePlayer" @showBookmarks="showBookmarks" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
</div> </div>
</template> </template>
<script> <script>
import PlayerHandler from '@/players/PlayerHandler'
export default { export default {
data() { data() {
return { return {
audioPlayerReady: false, playerHandler: new PlayerHandler(this),
lastServerUpdateSentSeconds: 0,
stream: null,
totalDuration: 0, totalDuration: 0,
showBookmarksModal: false, showBookmarksModal: false,
bookmarkCurrentTime: 0, bookmarkCurrentTime: 0,
bookmarkAudiobookId: null bookmarkAudiobookId: null,
playerLoading: false,
isPlaying: false,
currentTime: 0
} }
}, },
computed: { computed: {
@ -69,18 +72,13 @@ export default {
if (!this.audiobookId) return if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId) return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
}, },
userAudiobookCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
},
bookmarks() { bookmarks() {
if (!this.userAudiobook) return [] if (!this.userAudiobook) return []
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time) return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
}, },
isLoading() {
if (!this.streamAudiobook) return false
if (this.stream) {
// IF Stream exists, set loading if stream is diff from next stream
return this.stream.audiobook.id !== this.streamAudiobook.id
}
return true
},
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
}, },
@ -105,12 +103,6 @@ export default {
authorsList() { authorsList() {
return this.authorFL ? this.authorFL.split(', ') : [] return this.authorFL ? this.authorFL.split(', ') : []
}, },
streamId() {
return this.stream ? this.stream.id : null
},
playlistUrl() {
return this.stream ? this.stream.clientPlaylistUri : null
},
libraryId() { libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null return this.streamAudiobook ? this.streamAudiobook.libraryId : null
}, },
@ -119,8 +111,40 @@ export default {
} }
}, },
methods: { methods: {
addListeningTime(time) { playPause() {
console.log('Send listening time to server', time) this.playerHandler.playPause()
},
jumpForward() {
this.playerHandler.jumpForward()
},
jumpBackward() {
this.playerHandler.jumpBackward()
},
setVolume(volume) {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
this.playerHandler.seek(time)
},
setCurrentTime(time) {
this.currentTime = time
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setCurrentTime(time)
}
},
setDuration(duration) {
this.totalDuration = duration
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setDuration(duration)
}
},
setBufferTime(buffertime) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setBufferTime(buffertime)
}
}, },
showBookmarks(currentTime) { showBookmarks(currentTime) {
this.bookmarkAudiobookId = this.audiobookId this.bookmarkAudiobookId = this.audiobookId
@ -128,47 +152,12 @@ export default {
this.showBookmarksModal = true this.showBookmarksModal = true
}, },
selectBookmark(bookmark) { selectBookmark(bookmark) {
if (this.$refs.audioPlayer) { this.seek(bookmark.time)
this.$refs.audioPlayer.selectBookmark(bookmark)
}
this.showBookmarksModal = false this.showBookmarksModal = false
}, },
filterByAuthor() { closePlayer() {
if (this.$route.name !== 'index') { this.playerHandler.closePlayer()
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`) this.$store.commit('setStreamAudiobook', null)
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`
}
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
},
audioPlayerMounted() {
this.audioPlayerReady = true
if (this.stream) {
console.log('[STREAM-CONTAINER] audioPlayer Mounted w/ Stream', this.stream)
this.openStream()
}
},
cancelStream() {
this.$root.socket.emit('close_stream')
},
terminateStream() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
},
openStream() {
var playOnLoad = this.$store.state.playOnLoad
console.log(`[StreamContainer] openStream PlayOnLoad`, playOnLoad)
if (!this.$refs.audioPlayer) {
console.error('NO Audio Player')
return
}
var currentTime = this.stream.clientCurrentTime || 0
this.$refs.audioPlayer.set(this.playlistUrl, currentTime, playOnLoad)
if (this.stream.isTranscodeComplete) {
this.$refs.audioPlayer.setStreamReady()
}
}, },
streamProgress(data) { streamProgress(data) {
if (!data.numSegments) return if (!data.numSegments) return
@ -181,21 +170,14 @@ export default {
} }
}, },
streamOpen(stream) { streamOpen(stream) {
this.stream = stream this.$store.commit('setStreamAudiobook', stream.audiobook)
this.$store.commit('updateStreamAudiobook', stream.audiobook) this.playerHandler.prepareStream(stream)
if (this.$refs.audioPlayer) {
console.log('[StreamContainer] streamOpen', stream)
this.openStream()
} else if (this.audioPlayerReady) {
console.error('No Audio Ref')
}
}, },
streamClosed(streamId) { streamClosed(streamId) {
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) { // Stream was closed from the server
this.terminateStream() if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id) console.warn('[StreamContainer] Closing stream due to request from server')
this.stream = null this.playerHandler.closePlayer()
} }
}, },
streamReady() { streamReady() {
@ -207,41 +189,42 @@ export default {
} }
}, },
streamError(streamId) { streamError(streamId) {
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) { // Stream had critical error from the server
this.terminateStream() if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id) console.warn('[StreamContainer] Closing stream due to stream error from server')
this.stream = null this.playerHandler.closePlayer()
} }
}, },
sendStreamSync(syncData) {
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
if (Math.abs(diff) < 1 && !syncData.timeListened) {
// No need to sync
return
}
this.$root.socket.emit('stream_sync', syncData)
},
// updateTime(currentTime) {
// var diff = currentTime - this.lastServerUpdateSentSeconds
// if (diff > 4 || diff < 0) {
// this.lastServerUpdateSentSeconds = currentTime
// var updatePayload = {
// currentTime,
// streamId: this.streamId
// }
// this.$root.socket.emit('stream_update', updatePayload)
// }
// },
streamReset({ startTime, streamId }) { streamReset({ startTime, streamId }) {
if (streamId !== this.streamId) { this.playerHandler.resetStream(startTime, streamId)
console.error('resetStream StreamId Mismatch', streamId, this.streamId) },
return castSessionActive(isActive) {
} if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
if (this.$refs.audioPlayer) { // Cast session started switch to cast player
console.log(`[STREAM-CONTAINER] streamReset Received for time ${startTime}`) this.playerHandler.switchPlayer()
this.$refs.audioPlayer.resetStream(startTime) } else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
// Cast session ended switch to local player
this.playerHandler.switchPlayer()
} }
},
async playAudiobook(audiobookId) {
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
console.error('Failed to fetch full audiobook', error)
return null
})
if (!audiobook) return
this.$store.commit('setStreamAudiobook', audiobook)
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
} }
},
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('play-audiobook', this.playAudiobook)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('play-audiobook', this.playAudiobook)
} }
} }
</script> </script>

View File

@ -196,8 +196,6 @@ export default {
displayTitle() { displayTitle() {
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) { if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The' return this.title.substr(4) + ', The'
} else {
console.log('DOES NOT COMPUTE', this.orderBy, this.sortingIgnorePrefix, this.title.toLowerCase())
} }
return this.title return this.title
}, },
@ -497,8 +495,8 @@ export default {
this.$emit('select', this.audiobook) this.$emit('select', this.audiobook)
}, },
play() { play() {
this.store.commit('setStreamAudiobook', this.audiobook) var eventBus = this.$eventBus || this.$nuxt.$eventBus
this._socket.emit('open_stream', this.audiobookId) eventBus.$emit('play-audiobook', this.audiobookId)
}, },
mouseover() { mouseover() {
this.isHovering = true this.isHovering = true

View File

@ -133,8 +133,7 @@ export default {
this.isHovering = false this.isHovering = false
}, },
playClick() { playClick() {
this.$store.commit('setStreamAudiobook', this.book) this.$eventBus.$emit('play-audiobook', this.book.id)
this.$root.socket.emit('open_stream', this.book.id)
}, },
clickEdit() { clickEdit() {
this.$emit('edit', this.book) this.$emit('edit', this.book)

View File

@ -43,6 +43,9 @@ export default {
computed: { computed: {
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
},
isCasting() {
return this.$store.state.globals.isCasting
} }
}, },
methods: { methods: {
@ -99,7 +102,6 @@ export default {
console.log('Init Payload', payload) console.log('Init Payload', payload)
if (payload.stream) { if (payload.stream) {
if (this.$refs.streamContainer) { if (this.$refs.streamContainer) {
this.$store.commit('setStream', payload.stream)
this.$refs.streamContainer.streamOpen(payload.stream) this.$refs.streamContainer.streamOpen(payload.stream)
} else { } else {
console.warn('Stream Container not mounted') console.warn('Stream Container not mounted')
@ -111,6 +113,11 @@ export default {
} }
if (payload.serverSettings) { if (payload.serverSettings) {
this.$store.commit('setServerSettings', 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 // Start scans currently running
@ -511,6 +518,7 @@ export default {
this.resize() this.resize()
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown) window.addEventListener('keydown', this.keyDown)
this.$store.dispatch('libraries/load') this.$store.dispatch('libraries/load')
// If experimental features set in local storage // If experimental features set in local storage

View File

@ -7,7 +7,7 @@ module.exports = {
dev: process.env.NODE_ENV !== 'production', dev: process.env.NODE_ENV !== 'production',
env: { env: {
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333', serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
// serverUrl: '', chromecastReceiver: 'FD1F76C5',
baseUrl: process.env.BASE_URL || 'http://0.0.0.0' baseUrl: process.env.BASE_URL || 'http://0.0.0.0'
}, },
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '', // 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 to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [ plugins: [
// '@/plugins/chromecast.client.js',
'@/plugins/constants.js', '@/plugins/constants.js',
'@/plugins/init.client.js', '@/plugins/init.client.js',
'@/plugins/axios.js', '@/plugins/axios.js',

View File

@ -428,8 +428,7 @@ export default {
}) })
}, },
startStream() { startStream() {
this.$store.commit('setStreamAudiobook', this.audiobook) this.$eventBus.$emit('play-audiobook', this.audiobook.id)
this.$root.socket.emit('open_stream', this.audiobook.id)
}, },
editClick() { editClick() {
this.$store.commit('setBookshelfBookIds', []) this.$store.commit('setBookshelfBookIds', [])

View File

@ -123,8 +123,7 @@ export default {
clickPlay() { clickPlay() {
var nextBookNotRead = this.playableBooks.find((pb) => !this.userAudiobooks[pb.id] || !this.userAudiobooks[pb.id].isRead) var nextBookNotRead = this.playableBooks.find((pb) => !this.userAudiobooks[pb.id] || !this.userAudiobooks[pb.id].isRead)
if (nextBookNotRead) { if (nextBookNotRead) {
this.$store.commit('setStreamAudiobook', nextBookNotRead) this.$eventBus.$emit('play-audiobook', nextBookNotRead.id)
this.$root.socket.emit('open_stream', nextBookNotRead.id)
} }
} }
}, },

View File

@ -38,10 +38,15 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="updateSortIgnorePrefix" /> <ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<p class="pl-4 text-lg">Ignore prefix "The" when sorting title and series</p> <p class="pl-4 text-lg">Ignore prefix "The" when sorting title and series</p>
</div> </div>
<div v-if="showExperimentalFeatures" class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4 text-lg">Enable Chromecast</p>
</div>
<div class="flex items-center mb-2 mt-8"> <div class="flex items-center mb-2 mt-8">
<h1 class="text-xl">Scanner Settings</h1> <h1 class="text-xl">Scanner Settings</h1>
</div> </div>
@ -217,10 +222,8 @@ export default {
} }
}, },
methods: { methods: {
updateSortIgnorePrefix(val) { updateEnableChromecast(val) {
this.updateServerSettings({ this.updateServerSettings({ enableChromecast: val })
sortingIgnorePrefix: val
})
}, },
updateScannerFindCovers(val) { updateScannerFindCovers(val) {
this.updateServerSettings({ this.updateServerSettings({
@ -263,6 +266,11 @@ export default {
bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
}) })
}, },
updateSettingsKey(key, val) {
this.updateServerSettings({
[key]: val
})
},
updateServerSettings(payload) { updateServerSettings(payload) {
this.updatingServerSettings = true this.updatingServerSettings = true
this.$store this.$store

View File

@ -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}`
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}

View File

@ -6,7 +6,9 @@ export const state = () => ({
showUserCollectionsModal: false, showUserCollectionsModal: false,
showEditCollectionModal: false, showEditCollectionModal: false,
selectedCollection: null, selectedCollection: null,
showBookshelfTextureModal: false showBookshelfTextureModal: false,
isCasting: false, // Actively casting
isChromecastInitialized: false // Script loaded
}) })
export const getters = {} export const getters = {}
@ -33,5 +35,11 @@ export const mutations = {
}, },
setShowBookshelfTextureModal(state, val) { setShowBookshelfTextureModal(state, val) {
state.showBookshelfTextureModal = val state.showBookshelfTextureModal = val
},
setChromecastInitialized(state, val) {
state.isChromecastInitialized = val
},
setCasting(state, val) {
state.isCasting = val
} }
} }

View File

@ -10,7 +10,6 @@ export const state = () => ({
showEReader: false, showEReader: false,
selectedAudiobook: null, selectedAudiobook: null,
selectedAudiobookFile: null, selectedAudiobookFile: null,
playOnLoad: false,
developerMode: false, developerMode: false,
selectedAudiobooks: [], selectedAudiobooks: [],
processingBatch: false, processingBatch: false,
@ -107,25 +106,8 @@ export const mutations = {
state.serverSettings = settings state.serverSettings = settings
}, },
setStreamAudiobook(state, audiobook) { setStreamAudiobook(state, audiobook) {
state.playOnLoad = true
state.streamAudiobook = audiobook 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) { showEditModal(state, audiobook) {
state.editModalTab = 'details' state.editModalTab = 'details'
state.selectedAudiobook = audiobook state.selectedAudiobook = audiobook

View File

@ -178,6 +178,8 @@ class ApiController {
this.router.post('/syncStream', this.syncStream.bind(this)) this.router.post('/syncStream', this.syncStream.bind(this))
this.router.post('/syncLocal', this.syncLocal.bind(this)) this.router.post('/syncLocal', this.syncLocal.bind(this))
this.router.post('/streams/:id/close', this.closeStream.bind(this))
} }
async findBooks(req, res) { async findBooks(req, res) {
@ -397,6 +399,7 @@ class ApiController {
data: audiobookProgress || null data: audiobookProgress || null
}) })
} }
res.sendStatus(200)
} }
// //
@ -518,5 +521,12 @@ class ApiController {
await this.cacheManager.purgeAll() await this.cacheManager.purgeAll()
res.sendStatus(200) 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 module.exports = ApiController

View File

@ -260,7 +260,6 @@ class Server {
// Streaming // Streaming
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket)) 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('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData))
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload)) socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))

View File

@ -155,6 +155,30 @@ class StreamManager {
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams)) 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) { streamSync(socket, syncData) {
const client = socket.sheepClient const client = socket.sheepClient
if (!client || !client.stream) { if (!client || !client.stream) {
@ -233,35 +257,5 @@ class StreamManager {
res.sendStatus(200) 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 module.exports = StreamManager

View File

@ -39,7 +39,7 @@ class ServerSettings {
this.bookshelfView = BookshelfView.STANDARD this.bookshelfView = BookshelfView.STANDARD
this.sortingIgnorePrefix = false this.sortingIgnorePrefix = false
this.chromecastEnabled = false
this.logLevel = Logger.logLevel this.logLevel = Logger.logLevel
this.version = null this.version = null
@ -73,7 +73,7 @@ class ServerSettings {
this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD
this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix
this.chromecastEnabled = !!settings.chromecastEnabled
this.logLevel = settings.logLevel || Logger.logLevel this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null this.version = settings.version || null
@ -104,6 +104,7 @@ class ServerSettings {
coverAspectRatio: this.coverAspectRatio, coverAspectRatio: this.coverAspectRatio,
bookshelfView: this.bookshelfView, bookshelfView: this.bookshelfView,
sortingIgnorePrefix: this.sortingIgnorePrefix, sortingIgnorePrefix: this.sortingIgnorePrefix,
chromecastEnabled: this.chromecastEnabled,
logLevel: this.logLevel, logLevel: this.logLevel,
version: this.version version: this.version
} }

View File

@ -175,11 +175,6 @@ class Stream extends EventEmitter {
return false return false
} }
updateClientCurrentTime(currentTime) {
Logger.debug('[Stream] Updated client current time', secondsToTimestamp(currentTime))
this.clientCurrentTime = currentTime
}
syncStream({ timeListened, currentTime }) { syncStream({ timeListened, currentTime }) {
var syncLog = '' var syncLog = ''
// Set user current time // Set user current time

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB