mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			400 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			400 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import LocalAudioPlayer from './LocalAudioPlayer'
 | 
						|
import CastPlayer from './CastPlayer'
 | 
						|
import AudioTrack from './AudioTrack'
 | 
						|
 | 
						|
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.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.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)
 | 
						|
  }
 | 
						|
  get jumpForwardAmount() {
 | 
						|
    return this.ctx.$store.getters['user/getUserSetting']('jumpForwardAmount')
 | 
						|
  }
 | 
						|
  get jumpBackwardAmount() {
 | 
						|
    return this.ctx.$store.getters['user/getUserSetting']('jumpBackwardAmount')
 | 
						|
  }
 | 
						|
 | 
						|
  setSessionId(sessionId) {
 | 
						|
    this.currentSessionId = sessionId
 | 
						|
    this.ctx.$store.commit('setPlaybackSessionId', sessionId)
 | 
						|
  }
 | 
						|
 | 
						|
  load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
 | 
						|
    this.libraryItem = libraryItem
 | 
						|
 | 
						|
    this.episodeId = episodeId
 | 
						|
    this.playWhenReady = playWhenReady
 | 
						|
    this.initialPlaybackRate = playbackRate
 | 
						|
 | 
						|
    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)) {
 | 
						|
      console.log('[PlayerHandler] Switching to local player')
 | 
						|
 | 
						|
      this.stopPlayInterval()
 | 
						|
      this.playerStateChange('LOADING')
 | 
						|
 | 
						|
      if (this.player) {
 | 
						|
        this.player.destroy()
 | 
						|
      }
 | 
						|
 | 
						|
      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.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)
 | 
						|
 | 
						|
    this.ctx.mediaFinished(this.libraryItemId, this.episodeId)
 | 
						|
  }
 | 
						|
 | 
						|
  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)
 | 
						|
  }
 | 
						|
 | 
						|
  getDeviceId() {
 | 
						|
    let deviceId = localStorage.getItem('absDeviceId')
 | 
						|
    if (!deviceId) {
 | 
						|
      deviceId = this.ctx.$randomId()
 | 
						|
      localStorage.setItem('absDeviceId', deviceId)
 | 
						|
    }
 | 
						|
    return deviceId
 | 
						|
  }
 | 
						|
 | 
						|
  async prepare(forceTranscode = false) {
 | 
						|
    this.setSessionId(null) // Reset session
 | 
						|
 | 
						|
    const payload = {
 | 
						|
      deviceInfo: {
 | 
						|
        clientName: 'Abs Web',
 | 
						|
        deviceId: this.getDeviceId()
 | 
						|
      },
 | 
						|
      supportedMimeTypes: this.player.playableMimeTypes,
 | 
						|
      mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
 | 
						|
      forceTranscode,
 | 
						|
      forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
 | 
						|
    }
 | 
						|
 | 
						|
    const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
 | 
						|
    const 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.playWhenReady = false
 | 
						|
    this.initialPlaybackRate = playbackRate
 | 
						|
    this.startTimeOverride = undefined
 | 
						|
    this.lastSyncTime = 0
 | 
						|
    this.listeningTimeSinceSync = 0
 | 
						|
 | 
						|
    this.prepareSession(session)
 | 
						|
  }
 | 
						|
 | 
						|
  prepareSession(session) {
 | 
						|
    this.failedProgressSyncs = 0
 | 
						|
    this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
 | 
						|
    this.setSessionId(session.id)
 | 
						|
    this.displayTitle = session.displayTitle
 | 
						|
    this.displayAuthor = session.displayAuthor
 | 
						|
 | 
						|
    console.log('[PlayerHandler] Preparing Session', session)
 | 
						|
 | 
						|
    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()
 | 
						|
    this.resetPlayer()
 | 
						|
  }
 | 
						|
 | 
						|
  resetPlayer() {
 | 
						|
    if (this.player) {
 | 
						|
      this.player.destroy()
 | 
						|
    }
 | 
						|
    this.player = null
 | 
						|
    this.playerState = 'IDLE'
 | 
						|
    this.libraryItem = null
 | 
						|
    this.setSessionId(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)
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * First sync happens after 20 seconds
 | 
						|
   * subsequent syncs happen every 10 seconds
 | 
						|
   */
 | 
						|
  startPlayInterval() {
 | 
						|
    clearInterval(this.playInterval)
 | 
						|
    let lastTick = Date.now()
 | 
						|
    this.playInterval = setInterval(() => {
 | 
						|
      // Update UI
 | 
						|
      if (!this.player) return
 | 
						|
      const currentTime = this.player.getCurrentTime()
 | 
						|
      this.ctx.setCurrentTime(currentTime)
 | 
						|
 | 
						|
      const exactTimeElapsed = (Date.now() - lastTick) / 1000
 | 
						|
      lastTick = Date.now()
 | 
						|
      this.listeningTimeSinceSync += exactTimeElapsed
 | 
						|
      const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
 | 
						|
      if (this.listeningTimeSinceSync >= TimeToWaitBeforeSync) {
 | 
						|
        this.sendProgressSync(currentTime)
 | 
						|
      }
 | 
						|
    }, 1000)
 | 
						|
  }
 | 
						|
 | 
						|
  sendCloseSession() {
 | 
						|
    let syncData = null
 | 
						|
    if (this.player) {
 | 
						|
      const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
 | 
						|
      // When opening player and quickly closing dont save progress
 | 
						|
      if (listeningTimeToAdd > 20) {
 | 
						|
        syncData = {
 | 
						|
          timeListened: listeningTimeToAdd,
 | 
						|
          currentTime: this.getCurrentTime()
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
    this.listeningTimeSinceSync = 0
 | 
						|
    this.lastSyncTime = 0
 | 
						|
    return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000, progress: false }).catch((error) => {
 | 
						|
      console.error('Failed to close session', error)
 | 
						|
    })
 | 
						|
  }
 | 
						|
 | 
						|
  sendProgressSync(currentTime) {
 | 
						|
    const diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)
 | 
						|
    if (diffSinceLastSync < 1) return
 | 
						|
 | 
						|
    this.lastSyncTime = currentTime
 | 
						|
    const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
 | 
						|
    const syncData = {
 | 
						|
      timeListened: listeningTimeToAdd,
 | 
						|
      currentTime
 | 
						|
    }
 | 
						|
 | 
						|
    this.listeningTimeSinceSync = 0
 | 
						|
    this.ctx.$axios
 | 
						|
      .$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000, progress: false })
 | 
						|
      .then(() => {
 | 
						|
        this.failedProgressSyncs = 0
 | 
						|
      })
 | 
						|
      .catch((error) => {
 | 
						|
        console.error('Failed to update session progress', error)
 | 
						|
        // After 4 failed sync attempts show an alert toast
 | 
						|
        this.failedProgressSyncs++
 | 
						|
        if (this.failedProgressSyncs >= 4) {
 | 
						|
          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()
 | 
						|
    const jumpAmount = this.jumpBackwardAmount
 | 
						|
    this.seek(Math.max(0, currentTime - jumpAmount))
 | 
						|
  }
 | 
						|
 | 
						|
  jumpForward() {
 | 
						|
    if (!this.player) return
 | 
						|
    var currentTime = this.getCurrentTime()
 | 
						|
    const jumpAmount = this.jumpForwardAmount
 | 
						|
    this.seek(Math.min(currentTime + jumpAmount, 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, shouldSync = true) {
 | 
						|
    if (!this.player) return
 | 
						|
    this.player.seek(time, this.playerPlaying)
 | 
						|
    this.ctx.setCurrentTime(time)
 | 
						|
 | 
						|
    // Update progress if paused
 | 
						|
    if (!this.playerPlaying && shouldSync) {
 | 
						|
      this.sendProgressSync(time)
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 |