593 lines
23 KiB
JavaScript
593 lines
23 KiB
JavaScript
/**
|
|
* Module that manages the playback of SyncPlay.
|
|
* @module components/syncPlay/core/PlaybackCore
|
|
*/
|
|
|
|
import { Events } from 'jellyfin-apiclient';
|
|
import * as Helper from './Helper';
|
|
|
|
/**
|
|
* Class that manages the playback of SyncPlay.
|
|
*/
|
|
class PlaybackCore {
|
|
constructor() {
|
|
this.manager = null;
|
|
this.timeSyncCore = null;
|
|
|
|
this.syncEnabled = false;
|
|
this.playbackDiffMillis = 0; // Used for stats and remote time sync.
|
|
this.syncAttempts = 0;
|
|
this.lastSyncTime = new Date();
|
|
this.enableSyncCorrection = true; // User setting to disable sync during playback.
|
|
|
|
this.playerIsBuffering = false;
|
|
|
|
this.lastCommand = null; // Last scheduled playback command, might not be the latest one.
|
|
this.scheduledCommandTimeout = null;
|
|
this.syncTimeout = null;
|
|
}
|
|
|
|
/**
|
|
* Initializes the core.
|
|
* @param {Manager} syncPlayManager The SyncPlay manager.
|
|
*/
|
|
init(syncPlayManager) {
|
|
this.manager = syncPlayManager;
|
|
this.timeSyncCore = syncPlayManager.getTimeSyncCore();
|
|
|
|
// Minimum required delay for SpeedToSync to kick in, in milliseconds.
|
|
this.minDelaySpeedToSync = 60.0;
|
|
|
|
// Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds.
|
|
this.maxDelaySpeedToSync = 3000.0;
|
|
|
|
// Time during which the playback is sped up, in milliseconds.
|
|
this.speedToSyncDuration = 1000.0;
|
|
|
|
// Minimum required delay for SkipToSync to kick in, in milliseconds.
|
|
this.minDelaySkipToSync = 400.0;
|
|
|
|
// Whether SpeedToSync should be used.
|
|
this.useSpeedToSync = true;
|
|
|
|
// Whether SkipToSync should be used.
|
|
this.useSkipToSync = true;
|
|
|
|
// Whether sync correction during playback is active.
|
|
this.enableSyncCorrection = true;
|
|
}
|
|
|
|
/**
|
|
* Called by player wrapper when playback starts.
|
|
*/
|
|
onPlaybackStart(player, state) {
|
|
Events.trigger(this.manager, 'playbackstart', [player, state]);
|
|
}
|
|
|
|
/**
|
|
* Called by player wrapper when playback stops.
|
|
*/
|
|
onPlaybackStop(stopInfo) {
|
|
this.lastCommand = null;
|
|
Events.trigger(this.manager, 'playbackstop', [stopInfo]);
|
|
}
|
|
|
|
/**
|
|
* Called by player wrapper when playback unpauses.
|
|
*/
|
|
onUnpause() {
|
|
Events.trigger(this.manager, 'unpause');
|
|
}
|
|
|
|
/**
|
|
* Called by player wrapper when playback pauses.
|
|
*/
|
|
onPause() {
|
|
Events.trigger(this.manager, 'pause');
|
|
}
|
|
|
|
/**
|
|
* Called by player wrapper on playback progress.
|
|
* @param {Object} event The time update event.
|
|
* @param {Object} timeUpdateData The time update data.
|
|
*/
|
|
onTimeUpdate(event, timeUpdateData) {
|
|
this.syncPlaybackTime(timeUpdateData);
|
|
Events.trigger(this.manager, 'timeupdate', [event, timeUpdateData]);
|
|
}
|
|
|
|
/**
|
|
* Called by player wrapper when player is ready to play.
|
|
*/
|
|
onReady() {
|
|
this.playerIsBuffering = false;
|
|
this.sendBufferingRequest(false);
|
|
Events.trigger(this.manager, 'ready');
|
|
}
|
|
|
|
/**
|
|
* Called by player wrapper when player is buffering.
|
|
*/
|
|
onBuffering() {
|
|
this.playerIsBuffering = true;
|
|
this.sendBufferingRequest(true);
|
|
Events.trigger(this.manager, 'buffering');
|
|
}
|
|
|
|
/**
|
|
* Sends a buffering request to the server.
|
|
* @param {boolean} isBuffering Whether this client is buffering or not.
|
|
*/
|
|
async sendBufferingRequest(isBuffering = true) {
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
const currentPosition = (playerWrapper.currentTimeAsync
|
|
? await playerWrapper.currentTimeAsync()
|
|
: playerWrapper.currentTime());
|
|
const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond);
|
|
const isPlaying = playerWrapper.isPlaying();
|
|
|
|
const currentTime = new Date();
|
|
const now = this.timeSyncCore.localDateToRemote(currentTime);
|
|
const playlistItemId = this.manager.getQueueCore().getCurrentPlaylistItemId();
|
|
|
|
const options = {
|
|
When: now.toISOString(),
|
|
PositionTicks: currentPositionTicks,
|
|
IsPlaying: isPlaying,
|
|
PlaylistItemId: playlistItemId
|
|
};
|
|
|
|
const apiClient = this.manager.getApiClient();
|
|
if (isBuffering) {
|
|
apiClient.requestSyncPlayBuffering(options);
|
|
} else {
|
|
apiClient.requestSyncPlayReady(options);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets playback buffering status.
|
|
* @returns {boolean} _true_ if player is buffering, _false_ otherwise.
|
|
*/
|
|
isBuffering() {
|
|
return this.playerIsBuffering;
|
|
}
|
|
|
|
/**
|
|
* Applies a command and checks the playback state if a duplicate command is received.
|
|
* @param {Object} command The playback command.
|
|
*/
|
|
async applyCommand(command) {
|
|
// Check if duplicate.
|
|
if (this.lastCommand &&
|
|
this.lastCommand.When.getTime() === command.When.getTime() &&
|
|
this.lastCommand.PositionTicks === command.PositionTicks &&
|
|
this.lastCommand.Command === command.Command &&
|
|
this.lastCommand.PlaylistItemId === command.PlaylistItemId
|
|
) {
|
|
// Duplicate command found, check playback state and correct if needed.
|
|
console.debug('SyncPlay applyCommand: duplicate command received!', command);
|
|
|
|
// Determine if past command or future one.
|
|
const currentTime = new Date();
|
|
const whenLocal = this.timeSyncCore.remoteDateToLocal(command.When);
|
|
if (whenLocal > currentTime) {
|
|
// Command should be already scheduled, not much we can do.
|
|
// TODO: should re-apply or just drop?
|
|
console.debug('SyncPlay applyCommand: command already scheduled.', command);
|
|
return;
|
|
} else {
|
|
// Check if playback state matches requested command.
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
const currentPositionTicks = Math.round((playerWrapper.currentTimeAsync
|
|
? await playerWrapper.currentTimeAsync()
|
|
: playerWrapper.currentTime()) * Helper.TicksPerMillisecond);
|
|
const isPlaying = playerWrapper.isPlaying();
|
|
|
|
switch (command.Command) {
|
|
case 'Unpause':
|
|
// Check playback state only, as position ticks will be corrected by sync.
|
|
if (!isPlaying) {
|
|
this.scheduleUnpause(command.When, command.PositionTicks);
|
|
}
|
|
break;
|
|
case 'Pause':
|
|
// FIXME: check range instead of fixed value for ticks.
|
|
if (isPlaying || currentPositionTicks !== command.PositionTicks) {
|
|
this.schedulePause(command.When, command.PositionTicks);
|
|
}
|
|
break;
|
|
case 'Stop':
|
|
if (isPlaying) {
|
|
this.scheduleStop(command.When);
|
|
}
|
|
break;
|
|
case 'Seek':
|
|
// During seek, playback is paused.
|
|
// FIXME: check range instead of fixed value for ticks.
|
|
if (isPlaying || currentPositionTicks !== command.PositionTicks) {
|
|
// Account for player imperfections, we got half a second of tollerance we can play with
|
|
// (the server tollerates a range of values when client reports that is ready).
|
|
const rangeWidth = 100; // In milliseconds.
|
|
const randomOffsetTicks = Math.round((Math.random() - 0.5) * rangeWidth) * Helper.TicksPerMillisecond;
|
|
this.scheduleSeek(command.When, command.PositionTicks + randomOffsetTicks);
|
|
console.debug('SyncPlay applyCommand: adding random offset to force seek:', randomOffsetTicks, command);
|
|
} else {
|
|
// All done, I guess?
|
|
this.sendBufferingRequest(false);
|
|
}
|
|
break;
|
|
default:
|
|
console.error('SyncPlay applyCommand: command is not recognised:', command);
|
|
break;
|
|
}
|
|
|
|
// All done.
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Applying command.
|
|
this.lastCommand = command;
|
|
|
|
// Ignore if remote player has local SyncPlay manager.
|
|
if (this.manager.isRemote()) {
|
|
return;
|
|
}
|
|
|
|
switch (command.Command) {
|
|
case 'Unpause':
|
|
this.scheduleUnpause(command.When, command.PositionTicks);
|
|
break;
|
|
case 'Pause':
|
|
this.schedulePause(command.When, command.PositionTicks);
|
|
break;
|
|
case 'Stop':
|
|
this.scheduleStop(command.When);
|
|
break;
|
|
case 'Seek':
|
|
this.scheduleSeek(command.When, command.PositionTicks);
|
|
break;
|
|
default:
|
|
console.error('SyncPlay applyCommand: command is not recognised:', command);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedules a resume playback on the player at the specified clock time.
|
|
* @param {Date} playAtTime The server's UTC time at which to resume playback.
|
|
* @param {number} positionTicks The PositionTicks from where to resume.
|
|
*/
|
|
async scheduleUnpause(playAtTime, positionTicks) {
|
|
this.clearScheduledCommand();
|
|
const enableSyncTimeout = this.maxDelaySpeedToSync / 2.0;
|
|
const currentTime = new Date();
|
|
const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
|
|
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
const currentPositionTicks = (playerWrapper.currentTimeAsync
|
|
? await playerWrapper.currentTimeAsync()
|
|
: playerWrapper.currentTime()) * Helper.TicksPerMillisecond;
|
|
|
|
if (playAtTimeLocal > currentTime) {
|
|
const playTimeout = playAtTimeLocal - currentTime;
|
|
|
|
// Seek only if delay is noticeable.
|
|
if ((currentPositionTicks - positionTicks) > this.minDelaySkipToSync * Helper.TicksPerMillisecond) {
|
|
this.localSeek(positionTicks);
|
|
}
|
|
|
|
this.scheduledCommandTimeout = setTimeout(() => {
|
|
this.localUnpause();
|
|
Events.trigger(this.manager, 'notify-osd', ['unpause']);
|
|
|
|
this.syncTimeout = setTimeout(() => {
|
|
this.syncEnabled = true;
|
|
}, enableSyncTimeout);
|
|
}, playTimeout);
|
|
|
|
console.debug('Scheduled unpause in', playTimeout / 1000.0, 'seconds.');
|
|
} else {
|
|
// Group playback already started.
|
|
const serverPositionTicks = this.estimateCurrentTicks(positionTicks, playAtTime);
|
|
Helper.waitForEventOnce(this.manager, 'unpause').then(() => {
|
|
this.localSeek(serverPositionTicks);
|
|
});
|
|
this.localUnpause();
|
|
setTimeout(() => {
|
|
Events.trigger(this.manager, 'notify-osd', ['unpause']);
|
|
}, 100);
|
|
|
|
this.syncTimeout = setTimeout(() => {
|
|
this.syncEnabled = true;
|
|
}, enableSyncTimeout);
|
|
|
|
console.debug(`SyncPlay scheduleUnpause: unpause now from ${serverPositionTicks} (was at ${currentPositionTicks}).`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedules a pause playback on the player at the specified clock time.
|
|
* @param {Date} pauseAtTime The server's UTC time at which to pause playback.
|
|
* @param {number} positionTicks The PositionTicks where player will be paused.
|
|
*/
|
|
schedulePause(pauseAtTime, positionTicks) {
|
|
this.clearScheduledCommand();
|
|
const currentTime = new Date();
|
|
const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime);
|
|
|
|
const callback = () => {
|
|
Helper.waitForEventOnce(this.manager, 'pause', Helper.WaitForPlayerEventTimeout).then(() => {
|
|
this.localSeek(positionTicks);
|
|
}).catch(() => {
|
|
// Player was already paused, seeking.
|
|
this.localSeek(positionTicks);
|
|
});
|
|
this.localPause();
|
|
};
|
|
|
|
if (pauseAtTimeLocal > currentTime) {
|
|
const pauseTimeout = pauseAtTimeLocal - currentTime;
|
|
this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout);
|
|
|
|
console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.');
|
|
} else {
|
|
callback();
|
|
console.debug('SyncPlay schedulePause: now.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedules a stop playback on the player at the specified clock time.
|
|
* @param {Date} stopAtTime The server's UTC time at which to stop playback.
|
|
*/
|
|
scheduleStop(stopAtTime) {
|
|
this.clearScheduledCommand();
|
|
const currentTime = new Date();
|
|
const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime);
|
|
|
|
const callback = () => {
|
|
this.localStop();
|
|
};
|
|
|
|
if (stopAtTimeLocal > currentTime) {
|
|
const stopTimeout = stopAtTimeLocal - currentTime;
|
|
this.scheduledCommandTimeout = setTimeout(callback, stopTimeout);
|
|
|
|
console.debug('Scheduled stop in', stopTimeout / 1000.0, 'seconds.');
|
|
} else {
|
|
callback();
|
|
console.debug('SyncPlay scheduleStop: now.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedules a seek playback on the player at the specified clock time.
|
|
* @param {Date} seekAtTime The server's UTC time at which to seek playback.
|
|
* @param {number} positionTicks The PositionTicks where player will be seeked.
|
|
*/
|
|
scheduleSeek(seekAtTime, positionTicks) {
|
|
this.clearScheduledCommand();
|
|
const currentTime = new Date();
|
|
const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime);
|
|
|
|
const callback = () => {
|
|
this.localUnpause();
|
|
this.localSeek(positionTicks);
|
|
|
|
Helper.waitForEventOnce(this.manager, 'ready', Helper.WaitForEventDefaultTimeout).then(() => {
|
|
this.localPause();
|
|
this.sendBufferingRequest(false);
|
|
}).catch((error) => {
|
|
console.error(`Timed out while waiting for 'ready' event! Seeking to ${positionTicks}.`, error);
|
|
this.localSeek(positionTicks);
|
|
});
|
|
};
|
|
|
|
if (seekAtTimeLocal > currentTime) {
|
|
const seekTimeout = seekAtTimeLocal - currentTime;
|
|
this.scheduledCommandTimeout = setTimeout(callback, seekTimeout);
|
|
|
|
console.debug('Scheduled seek in', seekTimeout / 1000.0, 'seconds.');
|
|
} else {
|
|
callback();
|
|
console.debug('SyncPlay scheduleSeek: now.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears the current scheduled command.
|
|
*/
|
|
clearScheduledCommand() {
|
|
clearTimeout(this.scheduledCommandTimeout);
|
|
clearTimeout(this.syncTimeout);
|
|
|
|
this.syncEnabled = false;
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
if (playerWrapper.hasPlaybackRate()) {
|
|
playerWrapper.setPlaybackRate(1.0);
|
|
}
|
|
|
|
this.manager.clearSyncIcon();
|
|
}
|
|
|
|
/**
|
|
* Unpauses the local player.
|
|
*/
|
|
localUnpause() {
|
|
// Ignore command when no player is active.
|
|
if (!this.manager.isPlaybackActive()) {
|
|
console.debug('SyncPlay localUnpause: no active player!');
|
|
return;
|
|
}
|
|
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
return playerWrapper.localUnpause();
|
|
}
|
|
|
|
/**
|
|
* Pauses the local player.
|
|
*/
|
|
localPause() {
|
|
// Ignore command when no player is active.
|
|
if (!this.manager.isPlaybackActive()) {
|
|
console.debug('SyncPlay localPause: no active player!');
|
|
return;
|
|
}
|
|
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
return playerWrapper.localPause();
|
|
}
|
|
|
|
/**
|
|
* Seeks the local player.
|
|
*/
|
|
localSeek(positionTicks) {
|
|
// Ignore command when no player is active.
|
|
if (!this.manager.isPlaybackActive()) {
|
|
console.debug('SyncPlay localSeek: no active player!');
|
|
return;
|
|
}
|
|
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
return playerWrapper.localSeek(positionTicks);
|
|
}
|
|
|
|
/**
|
|
* Stops the local player.
|
|
*/
|
|
localStop() {
|
|
// Ignore command when no player is active.
|
|
if (!this.manager.isPlaybackActive()) {
|
|
console.debug('SyncPlay localStop: no active player!');
|
|
return;
|
|
}
|
|
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
return playerWrapper.localStop();
|
|
}
|
|
|
|
/**
|
|
* Estimates current value for ticks given a past state.
|
|
* @param {number} ticks The value of the ticks.
|
|
* @param {Date} when The point in time for the value of the ticks.
|
|
* @param {Date} currentTime The current time, optional.
|
|
*/
|
|
estimateCurrentTicks(ticks, when, currentTime = new Date()) {
|
|
const remoteTime = this.timeSyncCore.localDateToRemote(currentTime);
|
|
return ticks + (remoteTime.getTime() - when.getTime()) * Helper.TicksPerMillisecond;
|
|
}
|
|
|
|
/**
|
|
* Attempts to sync playback time with estimated server time (or selected device for time sync).
|
|
*
|
|
* When sync is enabled, the following will be checked:
|
|
* - check if local playback time is close enough to the server playback time;
|
|
* - playback diff (distance from estimated server playback time) is aligned with selected device for time sync.
|
|
* If playback diff exceeds some set thresholds, then a playback time sync will be attempted.
|
|
* Two strategies of syncing are available:
|
|
* - SpeedToSync: speeds up the media for some time to catch up (default is one second)
|
|
* - SkipToSync: seeks the media to the estimated correct time
|
|
* SpeedToSync aims to reduce the delay as much as possible, whereas SkipToSync is less pretentious.
|
|
* @param {Object} timeUpdateData The time update data that contains the current time as date and the current position in milliseconds.
|
|
*/
|
|
syncPlaybackTime(timeUpdateData) {
|
|
// See comments in constants section for more info.
|
|
const syncMethodThreshold = this.maxDelaySpeedToSync;
|
|
let speedToSyncTime = this.speedToSyncDuration;
|
|
|
|
// Ignore sync when no player is active.
|
|
if (!this.manager.isPlaybackActive()) {
|
|
console.debug('SyncPlay syncPlaybackTime: no active player!');
|
|
return;
|
|
}
|
|
|
|
// Attempt to sync only when media is playing.
|
|
const { lastCommand } = this;
|
|
|
|
if (!lastCommand || lastCommand.Command !== 'Unpause' || this.isBuffering()) return;
|
|
|
|
// Avoid spoilers by making sure that command item matches current playlist item.
|
|
// This check is needed when switching from one item to another.
|
|
const queueCore = this.manager.getQueueCore();
|
|
const currentPlaylistItem = queueCore.getCurrentPlaylistItemId();
|
|
if (lastCommand.PlaylistItemId !== currentPlaylistItem) return;
|
|
|
|
const { currentTime, currentPosition } = timeUpdateData;
|
|
|
|
// Get current PositionTicks.
|
|
const currentPositionTicks = currentPosition * Helper.TicksPerMillisecond;
|
|
|
|
// Estimate PositionTicks on server.
|
|
const serverPositionTicks = this.estimateCurrentTicks(lastCommand.PositionTicks, lastCommand.When, currentTime);
|
|
|
|
// Measure delay that needs to be recovered.
|
|
// Diff might be caused by the player internally starting the playback.
|
|
const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond;
|
|
|
|
this.playbackDiffMillis = diffMillis;
|
|
|
|
// Avoid overloading the browser.
|
|
const elapsed = currentTime - this.lastSyncTime;
|
|
if (elapsed < syncMethodThreshold / 2) return;
|
|
|
|
this.lastSyncTime = currentTime;
|
|
const playerWrapper = this.manager.getPlayerWrapper();
|
|
|
|
if (this.syncEnabled && this.enableSyncCorrection) {
|
|
const absDiffMillis = Math.abs(diffMillis);
|
|
// TODO: SpeedToSync sounds bad on songs.
|
|
// TODO: SpeedToSync is failing on Safari (Mojave); even if playbackRate is supported, some delay seems to exist.
|
|
// TODO: both SpeedToSync and SpeedToSync seem to have a hard time keeping up on Android Chrome as well.
|
|
if (playerWrapper.hasPlaybackRate() && this.useSpeedToSync && absDiffMillis >= this.minDelaySpeedToSync && absDiffMillis < this.maxDelaySpeedToSync) {
|
|
// Fix negative speed when client is ahead of time more than speedToSyncTime.
|
|
const MinSpeed = 0.2;
|
|
if (diffMillis <= -speedToSyncTime * MinSpeed) {
|
|
speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed);
|
|
}
|
|
|
|
// SpeedToSync strategy.
|
|
const speed = 1 + diffMillis / speedToSyncTime;
|
|
|
|
if (speed <= 0) {
|
|
console.error('SyncPlay error: speed should not be negative!', speed, diffMillis, speedToSyncTime);
|
|
}
|
|
|
|
playerWrapper.setPlaybackRate(speed);
|
|
this.syncEnabled = false;
|
|
this.syncAttempts++;
|
|
this.manager.showSyncIcon(`SpeedToSync (x${speed.toFixed(2)})`);
|
|
|
|
this.syncTimeout = setTimeout(() => {
|
|
playerWrapper.setPlaybackRate(1.0);
|
|
this.syncEnabled = true;
|
|
this.manager.clearSyncIcon();
|
|
}, speedToSyncTime);
|
|
|
|
console.log('SyncPlay SpeedToSync', speed);
|
|
} else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) {
|
|
// SkipToSync strategy.
|
|
this.localSeek(serverPositionTicks);
|
|
this.syncEnabled = false;
|
|
this.syncAttempts++;
|
|
this.manager.showSyncIcon(`SkipToSync (${this.syncAttempts})`);
|
|
|
|
this.syncTimeout = setTimeout(() => {
|
|
this.syncEnabled = true;
|
|
this.manager.clearSyncIcon();
|
|
}, syncMethodThreshold / 2);
|
|
|
|
console.log('SyncPlay SkipToSync', serverPositionTicks);
|
|
} else {
|
|
// Playback is synced.
|
|
if (this.syncAttempts > 0) {
|
|
console.debug('Playback has been synced after', this.syncAttempts, 'attempts.');
|
|
}
|
|
this.syncAttempts = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export default PlaybackCore;
|