2020-09-25 09:44:30 +02:00
/ * *
* Module that manages the SyncPlay feature .
2020-11-23 14:27:54 +01:00
* @ module components / syncPlay / core / Manager
2020-09-25 09:44:30 +02:00
* /
import { Events } from 'jellyfin-apiclient' ;
2020-11-23 14:27:54 +01:00
import * as Helper from './Helper' ;
import TimeSyncCore from './timeSync/TimeSyncCore' ;
import PlaybackCore from './PlaybackCore' ;
import QueueCore from './QueueCore' ;
import Controller from './Controller' ;
2020-09-25 09:44:30 +02:00
/ * *
* Class that manages the SyncPlay feature .
* /
2020-11-23 14:27:54 +01:00
class Manager {
/ * *
* Creates an instance of SyncPlay Manager .
* @ param { PlayerFactory } playerFactory The PlayerFactory instance .
* /
constructor ( playerFactory ) {
this . playerFactory = playerFactory ;
2020-09-25 09:44:30 +02:00
this . apiClient = null ;
this . timeSyncCore = new TimeSyncCore ( ) ;
2020-11-23 14:27:54 +01:00
this . playbackCore = new PlaybackCore ( ) ;
this . queueCore = new QueueCore ( ) ;
this . controller = new Controller ( ) ;
2020-09-25 09:44:30 +02:00
this . syncMethod = 'None' ; // Used for stats.
this . groupInfo = null ;
this . syncPlayEnabledAt = null ; // Server time of when SyncPlay has been enabled.
this . syncPlayReady = false ; // SyncPlay is ready after first ping to server.
this . queuedCommand = null ; // Queued playback command, applied when SyncPlay is ready.
this . followingGroupPlayback = true ; // Follow or ignore group playback.
this . lastPlaybackCommand = null ; // Last received playback command from server, tracks state of group.
this . currentPlayer = null ;
this . playerWrapper = null ;
}
/ * *
* Initialise SyncPlay .
* @ param { Object } apiClient The ApiClient .
* /
init ( apiClient ) {
if ( ! apiClient ) {
throw new Error ( 'ApiClient is null!' ) ;
}
// Set ApiClient.
this . apiClient = apiClient ;
// Get default player wrapper.
2020-11-23 14:27:54 +01:00
this . playerWrapper = this . playerFactory . getDefaultWrapper ( this ) ;
2020-09-25 09:44:30 +02:00
// Initialize components.
this . timeSyncCore . init ( this ) ;
this . playbackCore . init ( this ) ;
this . queueCore . init ( this ) ;
this . controller . init ( this ) ;
Events . on ( this . timeSyncCore , 'time-sync-server-update' , ( event , timeOffset , ping ) => {
// Report ping back to server.
if ( this . syncEnabled ) {
this . getApiClient ( ) . sendSyncPlayPing ( {
Ping : ping
} ) ;
}
} ) ;
}
/ * *
* Gets the time sync core .
* @ returns { TimeSyncCore } The time sync core .
* /
getTimeSyncCore ( ) {
return this . timeSyncCore ;
}
/ * *
* Gets the playback core .
2020-11-23 14:27:54 +01:00
* @ returns { PlaybackCore } The playback core .
2020-09-25 09:44:30 +02:00
* /
getPlaybackCore ( ) {
return this . playbackCore ;
}
/ * *
* Gets the queue core .
2020-11-23 14:27:54 +01:00
* @ returns { QueueCore } The queue core .
2020-09-25 09:44:30 +02:00
* /
getQueueCore ( ) {
return this . queueCore ;
}
/ * *
* Gets the controller used to manage SyncPlay playback .
2020-11-23 14:27:54 +01:00
* @ returns { Controller } The controller .
2020-09-25 09:44:30 +02:00
* /
getController ( ) {
return this . controller ;
}
/ * *
* Gets the player wrapper used to control local playback .
* @ returns { SyncPlayGenericPlayer } The player wrapper .
* /
getPlayerWrapper ( ) {
return this . playerWrapper ;
}
/ * *
* Gets the ApiClient used to communicate with the server .
* @ returns { Object } The ApiClient .
* /
getApiClient ( ) {
return this . apiClient ;
}
/ * *
* Gets the last playback command , if any .
* @ returns { Object } The playback command .
* /
getLastPlaybackCommand ( ) {
return this . lastPlaybackCommand ;
}
/ * *
* Called when the player changes .
* /
onPlayerChange ( newPlayer , newTarget , oldPlayer ) {
this . bindToPlayer ( newPlayer ) ;
}
/ * *
* Binds to the player ' s events .
* @ param { Object } player The player .
* /
bindToPlayer ( player ) {
this . releaseCurrentPlayer ( ) ;
if ( ! player ) {
return ;
}
this . playerWrapper . unbindFromPlayer ( ) ;
this . currentPlayer = player ;
2020-11-23 14:27:54 +01:00
this . playerWrapper = this . playerFactory . getWrapper ( player , this ) ;
2020-09-25 09:44:30 +02:00
if ( this . isSyncPlayEnabled ( ) ) {
this . playerWrapper . bindToPlayer ( ) ;
}
Events . trigger ( this , 'playerchange' , [ this . currentPlayer ] ) ;
}
/ * *
* Removes the bindings from the current player ' s events .
* /
releaseCurrentPlayer ( ) {
this . currentPlayer = null ;
this . playerWrapper . unbindFromPlayer ( ) ;
2020-11-23 14:27:54 +01:00
this . playerWrapper = this . playerFactory . getDefaultWrapper ( this ) ;
2020-09-25 09:44:30 +02:00
if ( this . isSyncPlayEnabled ( ) ) {
this . playerWrapper . bindToPlayer ( ) ;
}
Events . trigger ( this , 'playerchange' , [ this . currentPlayer ] ) ;
}
/ * *
* Handles a group update from the server .
* @ param { Object } cmd The group update .
* @ param { Object } apiClient The ApiClient .
* /
processGroupUpdate ( cmd , apiClient ) {
switch ( cmd . Type ) {
case 'PlayQueue' :
this . queueCore . updatePlayQueue ( apiClient , cmd . Data ) ;
break ;
case 'UserJoined' :
Helper . showMessage ( this , 'MessageSyncPlayUserJoined' , [ cmd . Data ] ) ;
break ;
case 'UserLeft' :
Helper . showMessage ( this , 'MessageSyncPlayUserLeft' , [ cmd . Data ] ) ;
break ;
case 'GroupJoined' :
cmd . Data . LastUpdatedAt = new Date ( cmd . Data . LastUpdatedAt ) ;
this . enableSyncPlay ( apiClient , cmd . Data , true ) ;
break ;
case 'SyncPlayIsDisabled' :
Helper . showMessage ( this , 'MessageSyncPlayIsDisabled' ) ;
break ;
case 'NotInGroup' :
case 'GroupLeft' :
this . disableSyncPlay ( true ) ;
break ;
case 'GroupUpdate' :
cmd . Data . LastUpdatedAt = new Date ( cmd . Data . LastUpdatedAt ) ;
this . groupInfo = cmd . Data ;
break ;
case 'StateUpdate' :
Events . trigger ( this , 'group-state-update' , [ cmd . Data . State , cmd . Data . Reason ] ) ;
console . debug ( ` SyncPlay processGroupUpdate: state changed to ${ cmd . Data . State } because ${ cmd . Data . Reason } . ` ) ;
break ;
case 'GroupDoesNotExist' :
Helper . showMessage ( this , 'MessageSyncPlayGroupDoesNotExist' ) ;
break ;
case 'CreateGroupDenied' :
Helper . showMessage ( this , 'MessageSyncPlayCreateGroupDenied' ) ;
break ;
case 'JoinGroupDenied' :
Helper . showMessage ( this , 'MessageSyncPlayJoinGroupDenied' ) ;
break ;
case 'LibraryAccessDenied' :
Helper . showMessage ( this , 'MessageSyncPlayLibraryAccessDenied' ) ;
break ;
default :
console . error ( ` SyncPlay processGroupUpdate: command ${ cmd . Type } not recognised. ` ) ;
break ;
}
}
/ * *
* Handles a playback command from the server .
* @ param { Object } cmd The playback command .
* @ param { Object } apiClient The ApiClient .
* /
processCommand ( cmd , apiClient ) {
if ( cmd === null ) return ;
if ( typeof cmd . When === 'string' ) {
cmd . When = new Date ( cmd . When ) ;
cmd . EmittedAt = new Date ( cmd . EmittedAt ) ;
cmd . PositionTicks = cmd . PositionTicks ? parseInt ( cmd . PositionTicks ) : null ;
}
if ( ! this . isSyncPlayEnabled ( ) ) {
console . debug ( 'SyncPlay processCommand: SyncPlay not enabled, ignoring command.' , cmd ) ;
return ;
}
if ( cmd . EmittedAt . getTime ( ) < this . syncPlayEnabledAt . getTime ( ) ) {
console . debug ( 'SyncPlay processCommand: ignoring old command.' , cmd ) ;
return ;
}
if ( ! this . syncPlayReady ) {
console . debug ( 'SyncPlay processCommand: SyncPlay not ready, queued command.' , cmd ) ;
this . queuedCommand = cmd ;
return ;
}
this . lastPlaybackCommand = cmd ;
if ( ! this . isPlaybackActive ( ) ) {
console . debug ( 'SyncPlay processCommand: no active player!' ) ;
return ;
}
// Make sure command matches playing item in playlist.
const playlistItemId = this . queueCore . getCurrentPlaylistItemId ( ) ;
if ( cmd . PlaylistItemId !== playlistItemId && cmd . Command !== 'Stop' ) {
console . error ( 'SyncPlay processCommand: playlist item does not match!' , cmd ) ;
return ;
}
console . log ( ` SyncPlay will ${ cmd . Command } at ${ cmd . When } (in ${ cmd . When . getTime ( ) - Date . now ( ) } ms) ${ cmd . PositionTicks ? '' : ' from ' + cmd . PositionTicks } . ` ) ;
this . playbackCore . applyCommand ( cmd ) ;
}
/ * *
* Handles a group state change .
* @ param { Object } update The group state update .
* @ param { Object } apiClient The ApiClient .
* /
processStateChange ( update , apiClient ) {
if ( update === null || update . State === null || update . Reason === null ) return ;
if ( ! this . isSyncPlayEnabled ( ) ) {
console . debug ( 'SyncPlay processStateChange: SyncPlay not enabled, ignoring group state update.' , update ) ;
return ;
}
Events . trigger ( this , 'group-state-change' , [ update . State , update . Reason ] ) ;
}
/ * *
* Notifies server that this client is following group ' s playback .
* @ param { Object } apiClient The ApiClient .
* @ returns { Promise } A Promise fulfilled upon request completion .
* /
followGroupPlayback ( apiClient ) {
this . followingGroupPlayback = true ;
return apiClient . requestSyncPlaySetIgnoreWait ( {
IgnoreWait : false
} ) ;
}
/ * *
* Starts this client 's playback and loads the group' s play queue .
* @ param { Object } apiClient The ApiClient .
* /
resumeGroupPlayback ( apiClient ) {
this . followGroupPlayback ( apiClient ) . then ( ( ) => {
this . queueCore . startPlayback ( apiClient ) ;
} ) ;
}
/ * *
* Stops this client ' s playback and notifies server to be ignored in group wait .
* @ param { Object } apiClient The ApiClient .
* /
haltGroupPlayback ( apiClient ) {
this . followingGroupPlayback = false ;
apiClient . requestSyncPlaySetIgnoreWait ( {
IgnoreWait : true
} ) ;
this . playbackCore . localStop ( ) ;
}
/ * *
* Whether this client is following group playback .
* @ returns { boolean } _true _ if client should play group ' s content , _false _ otherwise .
* /
isFollowingGroupPlayback ( ) {
return this . followingGroupPlayback ;
}
/ * *
* Enables SyncPlay .
* @ param { Object } apiClient The ApiClient .
* @ param { Object } groupInfo The joined group ' s info .
* @ param { boolean } showMessage Display message .
* /
enableSyncPlay ( apiClient , groupInfo , showMessage = false ) {
if ( this . isSyncPlayEnabled ( ) ) {
if ( groupInfo . GroupId === this . groupInfo . GroupId ) {
console . debug ( ` SyncPlay enableSyncPlay: group ${ this . groupInfo . GroupId } already joined. ` ) ;
return ;
} else {
console . warn ( ` SyncPlay enableSyncPlay: switching from group ${ this . groupInfo . GroupId } to group ${ groupInfo . GroupId } . ` ) ;
this . disableSyncPlay ( false ) ;
}
showMessage = false ;
}
this . groupInfo = groupInfo ;
this . syncPlayEnabledAt = groupInfo . LastUpdatedAt ;
this . playerWrapper . bindToPlayer ( ) ;
Events . trigger ( this , 'enabled' , [ true ] ) ;
// Wait for time sync to be ready.
Helper . waitForEventOnce ( this . timeSyncCore , 'time-sync-server-update' ) . then ( ( ) => {
this . syncPlayReady = true ;
this . processCommand ( this . queuedCommand , apiClient ) ;
this . queuedCommand = null ;
} ) ;
this . syncPlayReady = false ;
this . followingGroupPlayback = true ;
this . timeSyncCore . forceUpdate ( ) ;
if ( showMessage ) {
Helper . showMessage ( this , 'MessageSyncPlayEnabled' ) ;
}
}
/ * *
* Disables SyncPlay .
* @ param { boolean } showMessage Display message .
* /
disableSyncPlay ( showMessage = false ) {
this . syncPlayEnabledAt = null ;
this . syncPlayReady = false ;
this . followingGroupPlayback = true ;
this . lastPlaybackCommand = null ;
this . queuedCommand = null ;
this . playbackCore . syncEnabled = false ;
Events . trigger ( this , 'enabled' , [ false ] ) ;
this . playerWrapper . unbindFromPlayer ( ) ;
if ( showMessage ) {
Helper . showMessage ( this , 'MessageSyncPlayDisabled' ) ;
}
}
/ * *
* Gets SyncPlay status .
* @ returns { boolean } _true _ if user joined a group , _false _ otherwise .
* /
isSyncPlayEnabled ( ) {
return this . syncPlayEnabledAt !== null ;
}
/ * *
* Gets the group information .
* @ returns { Object } The group information , null if SyncPlay is disabled .
* /
getGroupInfo ( ) {
return this . groupInfo ;
}
/ * *
* Gets SyncPlay stats .
* @ returns { Object } The SyncPlay stats .
* /
getStats ( ) {
return {
TimeSyncDevice : this . timeSyncCore . getActiveDeviceName ( ) ,
TimeSyncOffset : this . timeSyncCore . getTimeOffset ( ) . toFixed ( 2 ) ,
PlaybackDiff : this . playbackCore . playbackDiffMillis . toFixed ( 2 ) ,
SyncMethod : this . syncMethod
} ;
}
/ * *
* Gets playback status .
* @ returns { boolean } Whether a player is active .
* /
isPlaybackActive ( ) {
return this . playerWrapper . isPlaybackActive ( ) ;
}
/ * *
* Whether the player is remotely self - managed .
* @ returns { boolean } _true _ if the player is remotely self - managed , _false _ otherwise .
* /
isRemote ( ) {
return this . playerWrapper . isRemote ( ) ;
}
/ * *
* Checks if playlist is empty .
* @ returns { boolean } _true _ if playlist is empty , _false _ otherwise .
* /
isPlaylistEmpty ( ) {
return this . queueCore . isPlaylistEmpty ( ) ;
}
/ * *
* Checks if playback is unpaused .
* @ returns { boolean } _true _ if media is playing , _false _ otherwise .
* /
isPlaying ( ) {
if ( ! this . lastPlaybackCommand ) {
return false ;
} else {
return this . lastPlaybackCommand . Command === 'Unpause' ;
}
}
/ * *
* Emits an event to update the SyncPlay status icon .
* /
showSyncIcon ( syncMethod ) {
this . syncMethod = syncMethod ;
Events . trigger ( this , 'syncing' , [ true , this . syncMethod ] ) ;
}
/ * *
* Emits an event to clear the SyncPlay status icon .
* /
clearSyncIcon ( ) {
this . syncMethod = 'None' ;
Events . trigger ( this , 'syncing' , [ false , this . syncMethod ] ) ;
}
}
2020-11-23 14:27:54 +01:00
export default Manager ;