diff --git a/client/components/AudioPlayer.vue b/client/components/AudioPlayer.vue index 41024072..2704e7ec 100644 --- a/client/components/AudioPlayer.vue +++ b/client/components/AudioPlayer.vue @@ -114,7 +114,8 @@ export default { showChaptersModal: false, currentTime: 0, trackOffsetLeft: 16, // Track is 16px from edge - duration: 0 + duration: 0, + chapterTicks: [] } }, computed: { @@ -138,7 +139,6 @@ export default { }, timeRemainingPretty() { if (this.timeRemaining < 0) { - console.warn('Time remaining < 0', this.duration, this.currentTime, this.timeRemaining) return this.$secondsToTimestamp(this.timeRemaining * -1) } return '-' + this.$secondsToTimestamp(this.timeRemaining) @@ -147,15 +147,6 @@ export default { if (!this.duration) return 0 return Math.round((100 * this.currentTime) / this.duration) }, - chapterTicks() { - return this.chapters.map((chap) => { - var perc = chap.start / this.duration - return { - title: chap.title, - left: perc * this.trackWidth - } - }) - }, currentChapter() { return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end) }, @@ -169,6 +160,14 @@ export default { methods: { setDuration(duration) { this.duration = duration + + this.chapterTicks = this.chapters.map((chap) => { + var perc = chap.start / this.duration + return { + title: chap.title, + left: perc * this.trackWidth + } + }) }, setCurrentTime(time) { this.currentTime = time diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index 59423c30..b169abb0 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -72,6 +72,9 @@ export default { } }, computed: { + showExperimentalFeatures() { + return this.$store.state.showExperimentalFeatures + }, coverAspectRatio() { return this.$store.getters['getServerSetting']('coverAspectRatio') }, diff --git a/client/players/CastPlayer.js b/client/players/CastPlayer.js index 9a6e715e..79687038 100644 --- a/client/players/CastPlayer.js +++ b/client/players/CastPlayer.js @@ -17,6 +17,8 @@ export default class CastPlayer extends EventEmitter { this.playWhenReady = false this.defaultPlaybackRate = 1 + this.playableMimetypes = {} + this.coverUrl = '' this.castPlayerState = 'IDLE' diff --git a/client/players/LocalPlayer.js b/client/players/LocalPlayer.js index 5fbdad6d..718743e3 100644 --- a/client/players/LocalPlayer.js +++ b/client/players/LocalPlayer.js @@ -14,10 +14,13 @@ export default class LocalPlayer extends EventEmitter { this.hlsStreamId = null this.hlsInstance = null this.usingNativeplayer = false - this.currentTime = 0 + this.startTime = 0 + this.trackStartTime = 0 this.playWhenReady = false this.defaultPlaybackRate = 1 + this.playableMimetypes = {} + this.initialize() } @@ -38,9 +41,16 @@ export default class LocalPlayer extends EventEmitter { 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('ended', this.evtEnded.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)) + + var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac'] + mimeTypes.forEach((mt) => { + this.playableMimetypes[mt] = this.player.canPlayType(mt) + }) + console.log(`[LocalPlayer] Supported mime types`, this.playableMimetypes) } evtPlay() { @@ -53,11 +63,27 @@ export default class LocalPlayer extends EventEmitter { var lastBufferTime = this.getLastBufferedTime() this.emit('buffertimeUpdate', lastBufferTime) } + evtEnded() { + if (this.currentTrackIndex < this.audioTracks.length - 1) { + console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`) + // Has next track + this.currentTrackIndex++ + this.playWhenReady = true + this.startTime = this.currentTrack.startOffset + this.loadCurrentTrack() + } else { + console.log(`[LocalPlayer] Ended`) + } + } evtError(error) { console.error('Player error', error) + this.emit('error', error) } evtLoadedMetadata(data) { - console.log('Audio Loaded Metadata', data) + if (!this.hlsStreamId) { + this.player.currentTime = this.trackStartTime + } + this.emit('stateChange', 'LOADED') if (this.playWhenReady) { this.playWhenReady = false @@ -89,23 +115,33 @@ export default class LocalPlayer extends EventEmitter { this.audioTracks = tracks this.hlsStreamId = hlsStreamId this.playWhenReady = playWhenReady + this.startTime = startTime + if (this.hlsInstance) { this.destroyHlsInstance() } - this.currentTime = startTime + if (this.hlsStreamId) { + this.setHlsStream() + } else { + this.setDirectPlay() + } + } + + setHlsStream() { + this.trackStartTime = 0 // 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.relativeContentUrl - this.player.currentTime = this.currentTime + this.player.currentTime = this.startTime return } var hlsOptions = { - startPosition: this.currentTime || -1 + startPosition: this.startTime || -1 // No longer needed because token is put in a query string // xhrSetup: (xhr) => { // xhr.setRequestHeader('Authorization', `Bearer ${this.token}`) @@ -133,6 +169,23 @@ export default class LocalPlayer extends EventEmitter { }) } + setDirectPlay() { + // Set initial track and track time offset + var trackIndex = this.audioTracks.findIndex(t => this.startTime >= t.startOffset && this.startTime < (t.startOffset + t.duration)) + this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0 + + this.loadCurrentTrack() + } + + loadCurrentTrack() { + if (!this.currentTrack) return + // When direct play track is loaded current time needs to be set + this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0)) + this.player.src = this.currentTrack.relativeContentUrl + console.log(`[LocalPlayer] Loading track src ${this.currentTrack.relativeContentUrl}`) + this.player.load() + } + destroyHlsInstance() { if (!this.hlsInstance) return if (this.hlsInstance.destroy) { @@ -181,8 +234,31 @@ export default class LocalPlayer extends EventEmitter { seek(time) { if (!this.player) return - var offsetTime = time - (this.currentTrack.startOffset || 0) - this.player.currentTime = Math.max(0, offsetTime) + if (this.hlsStreamId) { + // Seeking HLS stream + var offsetTime = time - (this.currentTrack.startOffset || 0) + this.player.currentTime = Math.max(0, offsetTime) + } else { + // Seeking Direct play + if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) { + // Change Track + var trackIndex = this.audioTracks.findIndex(t => time >= t.startOffset && time < (t.startOffset + t.duration)) + if (trackIndex >= 0) { + this.startTime = time + this.currentTrackIndex = trackIndex + + if (!this.player.paused) { + // audio player playing so play when track loads + this.playWhenReady = true + } + this.loadCurrentTrack() + } + } else { + var offsetTime = time - (this.currentTrack.startOffset || 0) + this.player.currentTime = Math.max(0, offsetTime) + } + } + } setVolume(volume) { diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index d0aadb88..c4c0a6f0 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -38,7 +38,6 @@ export default class PlayerHandler { load(audiobook, playWhenReady, startTime = 0) { if (!this.player) this.switchPlayer() - console.log('Load audiobook', audiobook) this.audiobook = audiobook this.startTime = startTime this.playWhenReady = playWhenReady @@ -88,6 +87,15 @@ export default class PlayerHandler { this.player.on('stateChange', this.playerStateChange.bind(this)) this.player.on('timeupdate', this.playerTimeupdate.bind(this)) this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this)) + this.player.on('error', this.playerError.bind(this)) + } + + playerError() { + // Switch to HLS stream on error + if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) { + console.log(`[PlayerHandler] Audio player error switching to HLS stream`) + this.prepare(true) + } } playerStateChange(state) { @@ -117,8 +125,36 @@ export default class PlayerHandler { this.ctx.setBufferTime(buffertime) } - async prepare() { - var useHls = !this.isCasting + async prepare(forceHls = false) { + var useHls = false + + 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 = this.getMimeTypeForTrack(track) + audioTrack.canDirectPlay = !!this.player.playableMimetypes[audioTrack.mimeType] + + runningTotal += audioTrack.duration + return audioTrack + }) + + // All html5 audio player plays use HLS unless experimental features is on + if (!this.isCasting) { + if (forceHls || !this.ctx.showExperimentalFeatures) { + useHls = true + } else { + // Use HLS if any audio track cannot be direct played + useHls = !!audioTracks.find(at => !at.canDirectPlay) + + if (useHls) { + console.warn(`[PlayerHandler] An audio track cannot be direct played`, audioTracks.find(at => !at.canDirectPlay)) + } + } + } + + if (useHls) { var stream = await this.ctx.$axios.$get(`/api/books/${this.audiobook.id}/stream`).catch((error) => { console.error('Failed to start stream', error) @@ -126,23 +162,30 @@ export default class PlayerHandler { if (stream) { console.log(`[PlayerHandler] prepare hls stream`, stream) this.setHlsStream(stream) + } else { + console.error(`[PlayerHandler] Failed to start HLS 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) } } + getMimeTypeForTrack(track) { + var ext = track.ext + if (ext === '.mp3' || ext === '.m4b' || ext === '.m4a') { + return 'audio/mpeg' + } else if (ext === '.mp4') { + return 'audio/mp4' + } else if (ext === '.ogg') { + return 'audio/ogg' + } else if (ext === '.aac' || ext === '.m4p') { + return 'audio/aac' + } else if (ext === '.flac') { + return 'audio/flac' + } + return 'audio/mpeg' + } + closePlayer() { console.log('[PlayerHandler] CLose Player') if (this.player) {