import LocalAudioPlayer from './LocalAudioPlayer' import LocalVideoPlayer from './LocalVideoPlayer' import CastPlayer from './CastPlayer' import AudioTrack from './AudioTrack' import VideoTrack from './VideoTrack' export default class PlayerHandler { constructor(ctx) { this.ctx = ctx this.libraryItem = null this.episodeId = null this.displayTitle = null this.displayAuthor = null this.playWhenReady = false this.initialPlaybackRate = 1 this.player = null this.playerState = 'IDLE' this.isHlsTranscode = false this.isVideo = false this.currentSessionId = null this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page) this.startTime = 0 this.failedProgressSyncs = 0 this.lastSyncTime = 0 this.lastSyncedAt = 0 this.listeningTimeSinceSync = 0 this.playInterval = null } get isCasting() { return this.ctx.$store.state.globals.isCasting } get libraryItemId() { return this.libraryItem ? this.libraryItem.id : null } get isPlayingCastedItem() { return this.libraryItem && (this.player instanceof CastPlayer) } get isPlayingLocalItem() { return this.libraryItem && (this.player instanceof LocalAudioPlayer) } get userToken() { return this.ctx.$store.getters['user/getToken'] } get playerPlaying() { return this.playerState === 'PLAYING' } get episode() { if (!this.episodeId) return null return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId) } load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) { this.libraryItem = libraryItem this.episodeId = episodeId this.playWhenReady = playWhenReady this.initialPlaybackRate = playbackRate this.isVideo = libraryItem.mediaType === 'video' this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride) if (!this.player) this.switchPlayer(playWhenReady) else this.prepare() } switchPlayer(playWhenReady) { 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.libraryItem) { // libraryItem was already loaded - prepare for cast this.playWhenReady = playWhenReady this.prepare() } } else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) { console.log('[PlayerHandler] Switching to local player') this.stopPlayInterval() this.playerStateChange('LOADING') if (this.player) { this.player.destroy() } if (this.isVideo) { this.player = new LocalVideoPlayer(this.ctx) } else { this.player = new LocalAudioPlayer(this.ctx) } this.setPlayerListeners() if (this.libraryItem) { // libraryItem was already loaded - prepare for local play this.playWhenReady = playWhenReady 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)) this.player.on('error', this.playerError.bind(this)) this.player.on('finished', this.playerFinished.bind(this)) } playerError() { // Switch to HLS stream on error if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) { console.log(`[PlayerHandler] Audio player error switching to HLS stream`) this.prepare(true) } } playerFinished() { this.stopPlayInterval() var currentTime = this.player.getCurrentTime() this.ctx.setCurrentTime(currentTime) // TODO: Add listening time between last sync and now? this.sendProgressSync(currentTime) } playerStateChange(state) { console.log('[PlayerHandler] Player state change', state) this.playerState = state if (this.playerState === 'PLAYING') { this.setPlaybackRate(this.initialPlaybackRate) this.startPlayInterval() } else { this.stopPlayInterval() } if (this.player) { if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') { this.ctx.setDuration(this.getDuration()) } if (this.playerState !== 'LOADING') { this.ctx.setCurrentTime(this.player.getCurrentTime()) } } this.ctx.setPlaying(this.playerState === 'PLAYING') this.ctx.playerLoading = this.playerState === 'LOADING' } playerTimeupdate(time) { this.ctx.setCurrentTime(time) } playerBufferTimeUpdate(buffertime) { this.ctx.setBufferTime(buffertime) } async prepare(forceTranscode = false) { var payload = { supportedMimeTypes: this.player.playableMimeTypes, mediaPlayer: this.isCasting ? 'chromecast' : 'html5', forceTranscode, forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast } var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play` var session = await this.ctx.$axios.$post(path, payload).catch((error) => { console.error('Failed to start stream', error) }) this.prepareSession(session) } prepareOpenSession(session, playbackRate) { // Session opened on init socket if (!this.player) this.switchPlayer() // Must set player first for open sessions this.libraryItem = session.libraryItem this.isVideo = session.libraryItem.mediaType === 'video' this.playWhenReady = false this.initialPlaybackRate = playbackRate this.startTimeOverride = undefined this.prepareSession(session) } prepareSession(session) { this.failedProgressSyncs = 0 this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime this.currentSessionId = session.id this.displayTitle = session.displayTitle this.displayAuthor = session.displayAuthor console.log('[PlayerHandler] Preparing Session', session) if (session.videoTrack) { var videoTrack = new VideoTrack(session.videoTrack, this.userToken) this.ctx.playerLoading = true this.isHlsTranscode = true if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) { this.isHlsTranscode = false } this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady) } else { var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken)) this.ctx.playerLoading = true this.isHlsTranscode = true if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) { this.isHlsTranscode = false } this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady) } // browser media session api this.ctx.setMediaSession() } closePlayer() { console.log('[PlayerHandler] Close Player') this.sendCloseSession() if (this.player) { this.player.destroy() } this.player = null this.playerState = 'IDLE' this.libraryItem = null this.startTime = 0 this.stopPlayInterval() } resetStream(startTime, streamId) { if (this.isHlsTranscode && this.currentSessionId === streamId) { this.player.resetStream(startTime) } else { console.warn('resetStream mismatch streamId', this.currentSessionId, 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) } }, 1000) } sendCloseSession() { var syncData = null if (this.player) { var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync)) syncData = { timeListened: listeningTimeToAdd, duration: this.getDuration(), currentTime: this.getCurrentTime() } } this.listeningTimeSinceSync = 0 return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => { console.error('Failed to close session', error) }) } sendProgressSync(currentTime) { var diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime) if (diffSinceLastSync < 1) return this.lastSyncTime = currentTime var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync)) var syncData = { timeListened: listeningTimeToAdd, duration: this.getDuration(), currentTime } this.listeningTimeSinceSync = 0 this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).then(() => { this.failedProgressSyncs = 0 }).catch((error) => { console.error('Failed to update session progress', error) this.failedProgressSyncs++ if (this.failedProgressSyncs >= 2) { this.ctx.showFailedProgressSyncs() this.failedProgressSyncs = 0 } }) } stopPlayInterval() { clearInterval(this.playInterval) this.playInterval = null } playPause() { if (this.player) this.player.playPause() } play() { if (this.player) 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) { this.initialPlaybackRate = playbackRate // Might be loaded from settings before player is started 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) } } }