@@ -741,7 +741,7 @@ class SyncPlayManager {
 
         const playAtTime = this.lastCommand.When;
 
-        const currentPositionTicks = playbackManager.currentTime();
+        const currentPositionTicks = playbackManager.currentTime() * 10000;
         // Estimate PositionTicks on server
         const serverPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) + this.timeOffsetWithServer) * 10000;
         // Measure delay that needs to be recovered
/**
 * Module that manages the SyncPlay feature.
 * @module components/syncPlay/syncPlayManager
 */

import events from 'events';
import connectionManager from 'connectionManager';
import playbackManager from 'playbackManager';
import timeSyncManager from 'timeSyncManager';
import toast from 'toast';
import globalize from 'globalize';

/**
 * Waits for an event to be triggered on an object. An optional timeout can specified after which the promise is rejected.
 * @param {Object} emitter Object on which to listen for events.
 * @param {string} eventType Event name to listen for.
 * @param {number} timeout Time in milliseconds before rejecting promise if event does not trigger.
 * @returns {Promise} A promise that resolves when the event is triggered.
 */
function waitForEventOnce(emitter, eventType, timeout) {
    return new Promise((resolve, reject) => {
        let rejectTimeout;
        if (timeout) {
            rejectTimeout = setTimeout(() => {
                reject('Timed out.');
            }, timeout);
        }
        const callback = () => {
            events.off(emitter, eventType, callback);
            if (rejectTimeout) {
                clearTimeout(rejectTimeout);
            }
            resolve(arguments);
        };
        events.on(emitter, eventType, callback);
    });
}

/**
 * Gets active player id.
 * @returns {string} The player's id.
 */
function getActivePlayerId() {
    var info = playbackManager.getPlayerInfo();
    return info ? info.id : null;
}

/**
 * Playback synchronization
 */
const MaxAcceptedDelaySpeedToSync = 50; // milliseconds, delay after which SpeedToSync is enabled
const MaxAcceptedDelaySkipToSync = 300; // milliseconds, delay after which SkipToSync is enabled
const SyncMethodThreshold = 2000; // milliseconds, switches between SpeedToSync or SkipToSync
const SpeedToSyncTime = 1000; // milliseconds, duration in which the playback is sped up
const MaxAttemptsSpeedToSync = 3; // attempts before disabling SpeedToSync
const MaxAttemptsSync = 5; // attempts before disabling syncing at all

/**
 * Other constants
 */
const WaitForEventDefaultTimeout = 30000; // milliseconds
const WaitForPlayerEventTimeout = 500; // milliseconds

/**
 * Class that manages the SyncPlay feature.
 */
class SyncPlayManager {
    constructor() {
        this.playbackRateSupported = false;
        this.syncEnabled = false;
        this.playbackDiffMillis = 0; // used for stats
        this.syncMethod = 'None'; // used for stats
        this.syncAttempts = 0;
        this.lastSyncTime = new Date();
        this.syncWatcherTimeout = null; // interval that watches playback time and syncs it

        this.lastPlaybackWaiting = null; // used to determine if player's buffering
        this.minBufferingThresholdMillis = 1000;

        this.currentPlayer = null;
        this.localPlayerPlaybackRate = 1.0; // used to restore user PlaybackRate

        this.syncPlayEnabledAt = null; // Server time of when SyncPlay has been enabled
        this.syncPlayReady = false; // SyncPlay is ready after first ping to server

        this.lastCommand = null;
        this.queuedCommand = null;

        this.scheduledCommand = null;
        this.syncTimeout = null;

        this.timeOffsetWithServer = 0; // server time minus local time
        this.roundTripDuration = 0;
        this.notifySyncPlayReady = false;

        events.on(playbackManager, 'playbackstart', (player, state) => {
            this.onPlaybackStart(player, state);
        });

        events.on(playbackManager, 'playbackstop', (stopInfo) => {
            this.onPlaybackStop(stopInfo);
        });

        events.on(playbackManager, 'playerchange', () => {
            this.onPlayerChange();
        });

        this.bindToPlayer(playbackManager.getCurrentPlayer());

        events.on(this, 'timeupdate', (event) => {
            this.syncPlaybackTime();
        });

        events.on(timeSyncManager, 'update', (event, error, timeOffset, ping) => {
            if (error) {
                console.debug('SyncPlay, time update issue', error);
                return;
            }

            this.timeOffsetWithServer = timeOffset;
            this.roundTripDuration = ping * 2;

            if (this.notifySyncPlayReady) {
                this.syncPlayReady = true;
                events.trigger(this, 'ready');
                this.notifySyncPlayReady = false;
            }

            // Report ping
            if (this.syncEnabled) {
                const apiClient = connectionManager.currentApiClient();
                const sessionId = getActivePlayerId();

                if (!sessionId) {
                    this.signalError();
                    toast({
                        text: globalize.translate('MessageSyncPlayErrorMissingSession')
                    });
                    return;
                }

                apiClient.sendSyncPlayPing({
                    Ping: ping
                });
            }
        });
    }

    /**
     * Called when playback starts.
     */
    onPlaybackStart (player, state) {
        events.trigger(this, 'playbackstart', [player, state]);
    }

    /**
     * Called when playback stops.
     */
    onPlaybackStop (stopInfo) {
        events.trigger(this, 'playbackstop', [stopInfo]);
        if (this.isSyncPlayEnabled()) {
            this.disableSyncPlay(false);
        }
    }

    /**
     * Called when the player changes.
     */
    onPlayerChange () {
        this.bindToPlayer(playbackManager.getCurrentPlayer());
        events.trigger(this, 'playerchange', [this.currentPlayer]);
    }

    /**
     * Called when playback unpauses.
     */
    onPlayerUnpause () {
        events.trigger(this, 'unpause', [this.currentPlayer]);
    }

    /**
     * Called when playback pauses.
     */
    onPlayerPause() {
        events.trigger(this, 'pause', [this.currentPlayer]);
    }

    /**
     * Called on playback progress.
     * @param {Object} e The time update event.
     */
    onTimeUpdate (e) {
        // NOTICE: this event is unreliable, at least in Safari
        // which just stops firing the event after a while.
        events.trigger(this, 'timeupdate', [e]);
    }

    /**
     * Called when playback is resumed.
     */
    onPlaying () {
        // TODO: implement group wait
        this.lastPlaybackWaiting = null;
        events.trigger(this, 'playing');
    }

    /**
     * Called when playback is buffering.
     */
    onWaiting () {
        // TODO: implement group wait
        if (!this.lastPlaybackWaiting) {
            this.lastPlaybackWaiting = new Date();
        }

        events.trigger(this, 'waiting');
    }

    /**
     * Gets playback buffering status.
     * @returns {boolean} _true_ if player is buffering, _false_ otherwise.
     */
    isBuffering () {
        if (this.lastPlaybackWaiting === null) return false;
        return (new Date() - this.lastPlaybackWaiting) > this.minBufferingThresholdMillis;
    }

    /**
     * Binds to the player's events.
     * @param {Object} player The player.
     */
    bindToPlayer (player) {
        if (player !== this.currentPlayer) {
            this.releaseCurrentPlayer();
            this.currentPlayer = player;
            if (!player) return;
        }

        // FIXME: the following are needed because the 'events' module
        // is changing the scope when executing the callbacks.
        // For instance, calling 'onPlayerUnpause' from the wrong scope breaks things because 'this'
        // points to 'player' (the event emitter) instead of pointing to the SyncPlayManager singleton.
        const self = this;
        this._onPlayerUnpause = () => {
            self.onPlayerUnpause();
        };

        this._onPlayerPause = () => {
            self.onPlayerPause();
        };

        this._onTimeUpdate = (e) => {
            self.onTimeUpdate(e);
        };

        this._onPlaying = () => {
            self.onPlaying();
        };

        this._onWaiting = () => {
            self.onWaiting();
        };

        events.on(player, 'unpause', this._onPlayerUnpause);
        events.on(player, 'pause', this._onPlayerPause);
        events.on(player, 'timeupdate', this._onTimeUpdate);
        events.on(player, 'playing', this._onPlaying);
        events.on(player, 'waiting', this._onWaiting);

        // Save player current PlaybackRate value
        if (player.supports && player.supports('PlaybackRate')) {
            this.localPlayerPlaybackRate = player.getPlaybackRate();
        }
    }

    /**
     * Removes the bindings to the current player's events.
     */
    releaseCurrentPlayer () {
        var player = this.currentPlayer;
        if (player) {
            events.off(player, 'unpause', this._onPlayerUnpause);
            events.off(player, 'pause', this._onPlayerPause);
            events.off(player, 'timeupdate', this._onTimeUpdate);
            events.off(player, 'playing', this._onPlaying);
            events.off(player, 'waiting', this._onWaiting);
            // Restore player original PlaybackRate value
            if (this.playbackRateSupported) {
                player.setPlaybackRate(this.localPlayerPlaybackRate);
                this.localPlayerPlaybackRate = 1.0;
            }

            this.currentPlayer = null;
            this.playbackRateSupported = false;
        }
    }

    /**
     * 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 'PrepareSession':
                this.prepareSession(apiClient, cmd.GroupId, cmd.Data);
                break;
            case 'UserJoined':
                toast({
                    text: globalize.translate('MessageSyncPlayUserJoined', cmd.Data)
                });
                break;
            case 'UserLeft':
                toast({
                    text: globalize.translate('MessageSyncPlayUserLeft', cmd.Data)
                });
                break;
            case 'GroupJoined':
                this.enableSyncPlay(apiClient, new Date(cmd.Data), true);
                break;
            case 'NotInGroup':
            case 'GroupLeft':
                this.disableSyncPlay(true);
                break;
            case 'GroupWait':
                toast({
                    text: globalize.translate('MessageSyncPlayGroupWait', cmd.Data)
                });
                break;
            case 'GroupDoesNotExist':
                toast({
                    text: globalize.translate('MessageSyncPlayGroupDoesNotExist')
                });
                break;
            case 'CreateGroupDenied':
                toast({
                    text: globalize.translate('MessageSyncPlayCreateGroupDenied')
                });
                break;
            case 'JoinGroupDenied':
                toast({
                    text: globalize.translate('MessageSyncPlayJoinGroupDenied')
                });
                break;
            case 'LibraryAccessDenied':
                toast({
                    text: globalize.translate('MessageSyncPlayLibraryAccessDenied')
                });
                break;
            default:
                console.error('processSyncPlayGroupUpdate: command is not recognised: ' + cmd.Type);
                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 (!this.isSyncPlayEnabled()) {
            console.debug('SyncPlay processCommand: SyncPlay not enabled, ignoring command', cmd);
            return;
        }

        if (!this.syncPlayReady) {
            console.debug('SyncPlay processCommand: SyncPlay not ready, queued command', cmd);
            this.queuedCommand = cmd;
            return;
        }

        cmd.When = new Date(cmd.When);
        cmd.EmittedAt = new Date(cmd.EmitttedAt);

        if (cmd.EmitttedAt < this.syncPlayEnabledAt) {
            console.debug('SyncPlay processCommand: ignoring old command', cmd);
            return;
        }

        // Check if new command differs from last one
        if (this.lastCommand &&
            this.lastCommand.When === cmd.When &&
            this.lastCommand.PositionTicks === cmd.PositionTicks &&
            this.Command === cmd.Command
        ) {
            console.debug('SyncPlay processCommand: ignoring duplicate command', cmd);
            return;
        }

        this.lastCommand = cmd;
        console.log('SyncPlay will', cmd.Command, 'at', cmd.When, 'PositionTicks', cmd.PositionTicks);

        switch (cmd.Command) {
            case 'Play':
                this.schedulePlay(cmd.When, cmd.PositionTicks);
                break;
            case 'Pause':
                this.schedulePause(cmd.When, cmd.PositionTicks);
                break;
            case 'Seek':
                this.scheduleSeek(cmd.When, cmd.PositionTicks);
                break;
            default:
                console.error('processCommand: command is not recognised: ' + cmd.Type);
                break;
        }
    }

    /**
     * Prepares this client to join a group by loading the required content.
     * @param {Object} apiClient The ApiClient.
     * @param {string} groupId The group to join.
     * @param {Object} sessionData Info about the content to load.
     */
    prepareSession (apiClient, groupId, sessionData) {
        const serverId = apiClient.serverInfo().Id;
        playbackManager.play({
            ids: sessionData.ItemIds,
            startPositionTicks: sessionData.StartPositionTicks,
            mediaSourceId: sessionData.MediaSourceId,
            audioStreamIndex: sessionData.AudioStreamIndex,
            subtitleStreamIndex: sessionData.SubtitleStreamIndex,
            startIndex: sessionData.StartIndex,
            serverId: serverId
        }).then(() => {
            waitForEventOnce(this, 'playbackstart', WaitForEventDefaultTimeout).then(() => {
                var sessionId = getActivePlayerId();
                if (!sessionId) {
                    console.error('Missing sessionId!');
                    toast({
                        text: globalize.translate('MessageSyncPlayErrorMissingSession')
                    });
                    return;
                }

                // Get playing item id
                let playingItemId;
                try {
                    const playState = playbackManager.getPlayerState();
                    playingItemId = playState.NowPlayingItem.Id;
                } catch (error) {
                    playingItemId = '';
                }
                // Make sure the server has received the player state
                waitForEventOnce(playbackManager, 'reportplayback', WaitForEventDefaultTimeout).then((success) => {
                    this.localPause();
                    if (!success) {
                        console.warning('Error reporting playback state to server. Joining group will fail.');
                    }
                    apiClient.joinSyncPlayGroup({
                        GroupId: groupId,
                        PlayingItemId: playingItemId
                    });
                }).catch(() => {
                    console.error('Timed out while waiting for `reportplayback` event!');
                    toast({
                        text: globalize.translate('MessageSyncPlayErrorMedia')
                    });
                    return;
                });
            }).catch(() => {
                console.error('Timed out while waiting for `playbackstart` event!');
                if (!this.isSyncPlayEnabled()) {
                    toast({
                        text: globalize.translate('MessageSyncPlayErrorMedia')
                    });
                }
                return;
            });
        }).catch((error) => {
            console.error(error);
            toast({
                text: globalize.translate('MessageSyncPlayErrorMedia')
            });
        });
    }

    /**
     * Enables SyncPlay.
     * @param {Object} apiClient The ApiClient.
     * @param {Date} enabledAt When SyncPlay has been enabled. Server side date.
     * @param {boolean} showMessage Display message.
     */
    enableSyncPlay (apiClient, enabledAt, showMessage = false) {
        this.syncPlayEnabledAt = enabledAt;
        this.injectPlaybackManager();
        events.trigger(this, 'enabled', [true]);

        waitForEventOnce(this, 'ready').then(() => {
            this.processCommand(this.queuedCommand, apiClient);
            this.queuedCommand = null;
        });

        this.syncPlayReady = false;
        this.notifySyncPlayReady = true;

        timeSyncManager.forceUpdate();

        if (showMessage) {
            toast({
                text: globalize.translate('MessageSyncPlayEnabled')
            });
        }
    }

    /**
     * Disables SyncPlay.
     * @param {boolean} showMessage Display message.
     */
    disableSyncPlay (showMessage = false) {
        this.syncPlayEnabledAt = null;
        this.syncPlayReady = false;
        this.lastCommand = null;
        this.queuedCommand = null;
        this.syncEnabled = false;
        events.trigger(this, 'enabled', [false]);
        this.restorePlaybackManager();

        if (showMessage) {
            toast({
                text: globalize.translate('MessageSyncPlayDisabled')
            });
        }
    }

    /**
     * Gets SyncPlay status.
     * @returns {boolean} _true_ if user joined a group, _false_ otherwise.
     */
    isSyncPlayEnabled () {
        return this.syncPlayEnabledAt !== null;
    }

    /**
     * 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.
     */
    schedulePlay (playAtTime, positionTicks) {
        this.clearScheduledCommand();
        const currentTime = new Date();
        const playAtTimeLocal = timeSyncManager.serverDateToLocal(playAtTime);

        if (playAtTimeLocal > currentTime) {
            const playTimeout = playAtTimeLocal - currentTime;
            this.localSeek(positionTicks);

            this.scheduledCommand = setTimeout(() => {
                this.localUnpause();

                this.syncTimeout = setTimeout(() => {
                    this.syncEnabled = true;
                }, SyncMethodThreshold / 2);
            }, playTimeout);

            console.debug('Scheduled play in', playTimeout / 1000.0, 'seconds.');
        } else {
            // Group playback already started
            const serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000;
            waitForEventOnce(this, 'unpause').then(() => {
                this.localSeek(serverPositionTicks);
            });
            this.localUnpause();

            this.syncTimeout = setTimeout(() => {
                this.syncEnabled = true;
            }, SyncMethodThreshold / 2);
        }
    }

    /**
     * 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 = timeSyncManager.serverDateToLocal(pauseAtTime);

        const callback = () => {
            waitForEventOnce(this, 'pause', WaitForPlayerEventTimeout).then(() => {
                this.localSeek(positionTicks);
            }).catch(() => {
                // Player was already paused, seeking
                this.localSeek(positionTicks);
            });
            this.localPause();
        };

        if (pauseAtTimeLocal > currentTime) {
            const pauseTimeout = pauseAtTimeLocal - currentTime;
            this.scheduledCommand = setTimeout(callback, pauseTimeout);

            console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.');
        } else {
            callback();
        }
    }

    /**
     * Schedules a seek playback on the player at the specified clock time.
     * @param {Date} pauseAtTime The server's UTC time at which to seek playback.
     * @param {number} positionTicks The PositionTicks where player will be seeked.
     */
    scheduleSeek (seekAtTime, positionTicks) {
        this.schedulePause(seekAtTime, positionTicks);
    }

    /**
     * Clears the current scheduled command.
     */
    clearScheduledCommand () {
        clearTimeout(this.scheduledCommand);
        clearTimeout(this.syncTimeout);

        this.syncEnabled = false;
        if (this.currentPlayer) {
            this.currentPlayer.setPlaybackRate(1);
        }

        this.clearSyncIcon();
    }

    /**
     * Overrides some PlaybackManager's methods to intercept playback commands.
     */
    injectPlaybackManager () {
        if (!this.isSyncPlayEnabled()) return;
        if (playbackManager.syncPlayEnabled) return;

        // TODO: make this less hacky
        playbackManager._localUnpause = playbackManager.unpause;
        playbackManager._localPause = playbackManager.pause;
        playbackManager._localSeek = playbackManager.seek;

        playbackManager.unpause = this.playRequest;
        playbackManager.pause = this.pauseRequest;
        playbackManager.seek = this.seekRequest;
        playbackManager.syncPlayEnabled = true;
    }

    /**
     * Restores original PlaybackManager's methods.
     */
    restorePlaybackManager () {
        if (this.isSyncPlayEnabled()) return;
        if (!playbackManager.syncPlayEnabled) return;

        playbackManager.unpause = playbackManager._localUnpause;
        playbackManager.pause = playbackManager._localPause;
        playbackManager.seek = playbackManager._localSeek;
        playbackManager.syncPlayEnabled = false;
    }

    /**
     * Overrides PlaybackManager's unpause method.
     */
    playRequest (player) {
        var apiClient = connectionManager.currentApiClient();
        apiClient.requestSyncPlayStart();
    }

    /**
     * Overrides PlaybackManager's pause method.
     */
    pauseRequest (player) {
        var apiClient = connectionManager.currentApiClient();
        apiClient.requestSyncPlayPause();
        // Pause locally as well, to give the user some little control
        playbackManager._localUnpause(player);
    }

    /**
     * Overrides PlaybackManager's seek method.
     */
    seekRequest (PositionTicks, player) {
        var apiClient = connectionManager.currentApiClient();
        apiClient.requestSyncPlaySeek({
            PositionTicks: PositionTicks
        });
    }

    /**
     * Calls original PlaybackManager's unpause method.
     */
    localUnpause(player) {
        if (playbackManager.syncPlayEnabled) {
            playbackManager._localUnpause(player);
        } else {
            playbackManager.unpause(player);
        }
    }

    /**
     * Calls original PlaybackManager's pause method.
     */
    localPause(player) {
        if (playbackManager.syncPlayEnabled) {
            playbackManager._localPause(player);
        } else {
            playbackManager.pause(player);
        }
    }

    /**
     * Calls original PlaybackManager's seek method.
     */
    localSeek(PositionTicks, player) {
        if (playbackManager.syncPlayEnabled) {
            playbackManager._localSeek(PositionTicks, player);
        } else {
            playbackManager.seek(PositionTicks, player);
        }
    }

    /**
     * Attempts to sync playback time with estimated server time.
     *
     * When sync is enabled, the following will be checked:
     *  - check if local playback time is close enough to the server playback time
     * If it is not, then a playback time sync will be attempted.
     * Two methods 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.
     */
    syncPlaybackTime () {
        // Attempt to sync only when media is playing.
        if (!this.lastCommand || this.lastCommand.Command !== 'Play' || this.isBuffering()) return;

        const currentTime = new Date();

        // Avoid overloading the browser
        const elapsed = currentTime - this.lastSyncTime;
        if (elapsed < SyncMethodThreshold / 2) return;
        this.lastSyncTime = currentTime;

        const playAtTime = this.lastCommand.When;

        const currentPositionTicks = playbackManager.currentTime();
        // Estimate PositionTicks on server
        const serverPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) + this.timeOffsetWithServer) * 10000;
        // Measure delay that needs to be recovered
        // diff might be caused by the player internally starting the playback
        const diffMillis = (serverPositionTicks - currentPositionTicks) / 10000.0;

        this.playbackDiffMillis = diffMillis;

        if (this.syncEnabled) {
            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
            if (this.playbackRateSupported && absDiffMillis > MaxAcceptedDelaySpeedToSync && absDiffMillis < SyncMethodThreshold) {
                // Disable SpeedToSync if it keeps failing
                if (this.syncAttempts > MaxAttemptsSpeedToSync) {
                    this.playbackRateSupported = false;
                }
                // SpeedToSync method
                const speed = 1 + diffMillis / SpeedToSyncTime;

                this.currentPlayer.setPlaybackRate(speed);
                this.syncEnabled = false;
                this.syncAttempts++;
                this.showSyncIcon('SpeedToSync (x' + speed + ')');

                this.syncTimeout = setTimeout(() => {
                    this.currentPlayer.setPlaybackRate(1);
                    this.syncEnabled = true;
                    this.clearSyncIcon();
                }, SpeedToSyncTime);
            } else if (absDiffMillis > MaxAcceptedDelaySkipToSync) {
                // Disable SkipToSync if it keeps failing
                if (this.syncAttempts > MaxAttemptsSync) {
                    this.syncEnabled = false;
                    this.showSyncIcon('Sync disabled (too many attempts)');
                }
                // SkipToSync method
                this.localSeek(serverPositionTicks);
                this.syncEnabled = false;
                this.syncAttempts++;
                this.showSyncIcon('SkipToSync (' + this.syncAttempts + ')');

                this.syncTimeout = setTimeout(() => {
                    this.syncEnabled = true;
                    this.clearSyncIcon();
                }, SyncMethodThreshold / 2);
            } else {
                // Playback is synced
                if (this.syncAttempts > 0) {
                    console.debug('Playback has been synced after', this.syncAttempts, 'attempts.');
                }
                this.syncAttempts = 0;
            }
        }
    }

    /**
     * Gets SyncPlay stats.
     * @returns {Object} The SyncPlay stats.
     */
    getStats () {
        return {
            TimeOffset: this.timeOffsetWithServer,
            PlaybackDiff: this.playbackDiffMillis,
            SyncMethod: this.syncMethod
        };
    }

    /**
     * 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]);
    }

    /**
     * Signals an error state, which disables and resets SyncPlay for a new session.
     */
    signalError () {
        this.disableSyncPlay();
    }
}

/** SyncPlayManager singleton. */
export default new SyncPlayManager();

Better fix would be on the next line, since what it does is essentially compute the difference between server position and current position, then convert to ms. So converting the `serverPositionTicks` to ms would allow us to not have to convert this, then convert the result to ms afterwards.