mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-06 05:58:35 +01:00
Final touches
This commit is contained in:
@@ -53,6 +53,17 @@ interface SyncPlayContextValue {
|
||||
leaveGroup: () => Promise<void>;
|
||||
getGroups: () => Promise<GroupInfoDto[]>;
|
||||
|
||||
/**
|
||||
* Re-attach to the group's command stream and jump back to the
|
||||
* group's currently-playing item. Mirrors jellyfin-web's "Resume
|
||||
* playback" menu entry: in jellyfin-web it just calls
|
||||
* `playbackManager.play` on the group's current queue position.
|
||||
* Here we navigate to direct-player with the same params our
|
||||
* `localSetCurrentItem` bridge would use, so the player picks up
|
||||
* mid-group with `syncPlay=true` and the right offset.
|
||||
*/
|
||||
resumeGroupPlayback: () => Promise<void>;
|
||||
|
||||
controller: SyncPlayController | null;
|
||||
|
||||
setPlayerControls: (controls: PlayerControls | null) => void;
|
||||
@@ -170,41 +181,32 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
const playbackStartFiredRef = useRef(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manager lifecycle
|
||||
// Navigation to the player screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const mgr = new SyncPlayManager(api);
|
||||
mgr.init();
|
||||
setManager(mgr);
|
||||
|
||||
const playerWrapper = mgr.getPlayerWrapper();
|
||||
|
||||
/*
|
||||
* Both localPlay (new queue / joining a group) and
|
||||
* localSetCurrentPlaylistItem (queue advance to next episode)
|
||||
* end up navigating to the same screen with the same params;
|
||||
* jellyfin-web treats them as distinct because one calls full
|
||||
* playbackManager.play() and the other does a cheap item swap,
|
||||
* but on RN both have to re-mount direct-player either way.
|
||||
*/
|
||||
const navigateToPlayer = (
|
||||
itemId: string,
|
||||
startPositionTicks: number,
|
||||
withJoinToast: boolean,
|
||||
) => {
|
||||
/*
|
||||
* Single navigate-to-direct-player helper, used by every code path
|
||||
* that needs to (re-)open the player while in a SyncPlay group:
|
||||
* - localPlay (group's leader started a new queue / we just joined)
|
||||
* - localSetCurrentPlaylistItem (group advanced to next episode)
|
||||
* - resumeGroupPlayback (user tapped "Resume playback" in the menu)
|
||||
*
|
||||
* Both jellyfin-web's playbackManager.play and its setCurrentPlaylistItem
|
||||
* collapse to "point the player at this item / position" — RN is the
|
||||
* same shape, just a router navigation instead of an in-page DOM swap.
|
||||
*
|
||||
* Note: no "joining playback" toast here — the `GroupJoined`
|
||||
* WebSocket event already triggers a "Joined group" toast via
|
||||
* `Manager.ts`, and showing both on a fresh join was redundant.
|
||||
*/
|
||||
const navigateToPlayer = useCallback(
|
||||
(itemId: string, startPositionTicks: number) => {
|
||||
if (isNavigatingToPlayerRef.current) {
|
||||
console.debug("SyncPlay: already navigating to player");
|
||||
return;
|
||||
}
|
||||
isNavigatingToPlayerRef.current = true;
|
||||
|
||||
if (withJoinToast) {
|
||||
toast(i18n.t("syncplay.joining_playback"));
|
||||
}
|
||||
|
||||
// Opportunistic local playback: if we have a downloaded copy of
|
||||
// the target item, use it instead of streaming. Matters most when
|
||||
// the group advances to an episode you've downloaded — the local
|
||||
@@ -232,7 +234,22 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
setTimeout(() => {
|
||||
isNavigatingToPlayerRef.current = false;
|
||||
}, 2000);
|
||||
};
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manager lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const mgr = new SyncPlayManager(api);
|
||||
mgr.init();
|
||||
setManager(mgr);
|
||||
|
||||
const playerWrapper = mgr.getPlayerWrapper();
|
||||
|
||||
// localPlay → navigate to direct-player with syncPlay=true
|
||||
playerWrapper.setLocalPlayHandler((options) => {
|
||||
@@ -241,7 +258,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
console.warn("SyncPlay: localPlay called with no ids");
|
||||
return;
|
||||
}
|
||||
navigateToPlayer(itemId, options.startPositionTicks ?? 0, true);
|
||||
navigateToPlayer(itemId, options.startPositionTicks ?? 0);
|
||||
});
|
||||
|
||||
// localSetCurrentPlaylistItem → navigate to the new playlist item
|
||||
@@ -259,7 +276,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigateToPlayer(itemId, queueCore.getStartPositionTicks(), false);
|
||||
navigateToPlayer(itemId, queueCore.getStartPositionTicks());
|
||||
});
|
||||
|
||||
mgr.on("enabled", (...args: unknown[]) => {
|
||||
@@ -340,7 +357,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
mgr.destroy();
|
||||
setManager(null);
|
||||
};
|
||||
}, [api, router]);
|
||||
}, [api, navigateToPlayer]);
|
||||
|
||||
// Initial join race: once `enabled` flips true, snapshot the current group.
|
||||
useEffect(() => {
|
||||
@@ -408,6 +425,26 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
/*
|
||||
* Resume playback: re-follow the group's command stream and jump
|
||||
* the local player to the group's current item + position. This is
|
||||
* the only entry point a user needs from the menu — there is no
|
||||
* separate "halt" UI; the player exit/back already detaches us.
|
||||
*/
|
||||
const resumeGroupPlayback = useCallback(async (): Promise<void> => {
|
||||
if (!api || !manager) return;
|
||||
await manager.followGroupPlayback(api);
|
||||
const queueCore = manager.getQueueCore();
|
||||
const index = queueCore.getCurrentPlaylistIndex();
|
||||
const itemId =
|
||||
index >= 0 ? (queueCore.getPlaylist()[index]?.Id ?? null) : null;
|
||||
if (!itemId) {
|
||||
console.warn("SyncPlay: resumeGroupPlayback — no current group item");
|
||||
return;
|
||||
}
|
||||
navigateToPlayer(itemId, queueCore.getStartPositionTicks());
|
||||
}, [api, manager, navigateToPlayer]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -518,6 +555,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
createGroup,
|
||||
leaveGroup,
|
||||
getGroups,
|
||||
resumeGroupPlayback,
|
||||
controller: manager?.getController() ?? null,
|
||||
setPlayerControls,
|
||||
notifyReady,
|
||||
@@ -535,6 +573,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
createGroup,
|
||||
leaveGroup,
|
||||
getGroups,
|
||||
resumeGroupPlayback,
|
||||
manager,
|
||||
setPlayerControls,
|
||||
notifyReady,
|
||||
|
||||
@@ -132,26 +132,29 @@ export class PlaybackCore extends EventEmitter {
|
||||
command.When as unknown as string,
|
||||
);
|
||||
|
||||
// Duplicate-detection — mirrors jellyfin-web's PlaybackCore.applyCommand.
|
||||
// The server can redeliver the same command (WebSocket reconnect, multiple
|
||||
// group-state transitions referencing the same instant, etc). If every
|
||||
// identifying field matches the previously applied command, we don't
|
||||
// re-schedule — we just verify player state still matches and bail.
|
||||
//
|
||||
// IMPORTANT: this is NOT a monotonic-clock check. `When` is the scheduled
|
||||
// execution time and can legitimately move backward between commands
|
||||
// (e.g. a Pause emitted now with `When = now` arriving after an earlier
|
||||
// Unpause whose `When` was scheduled 10s in the future). An earlier
|
||||
// version of this code rejected anything whose `When` or `EmittedAt`
|
||||
// wasn't strictly greater than `lastCommand`'s — that silently locked
|
||||
// out every subsequent pause/unpause whenever group playback first
|
||||
// started with a future-scheduled Unpause.
|
||||
if (
|
||||
this.lastCommand &&
|
||||
((
|
||||
this.lastCommand as unknown as { EmittedAt: Date }
|
||||
).EmittedAt.getTime() >
|
||||
(command as unknown as { EmittedAt: Date }).EmittedAt.getTime() ||
|
||||
(this.lastCommand as unknown as { When: Date }).When.getTime() >
|
||||
(command as unknown as { When: Date }).When.getTime())
|
||||
(this.lastCommand as unknown as { When: Date }).When.getTime() ===
|
||||
(command as unknown as { When: Date }).When.getTime() &&
|
||||
this.lastCommand.PositionTicks === command.PositionTicks &&
|
||||
this.lastCommand.Command === command.Command &&
|
||||
this.lastCommand.PlaylistItemId === command.PlaylistItemId
|
||||
) {
|
||||
// NOTE: strict `>` (not `>=`) on EmittedAt — Jellyfin's server timestamps
|
||||
// commands at sub-ms precision but JS `Date` truncates to ms, so two
|
||||
// commands emitted within the same millisecond would otherwise be
|
||||
// rejected as "outdated" and silently dropped. This produced an
|
||||
// unbreakable pause/unpause loop where every fresh command was
|
||||
// discarded. Matches jellyfin-web's check in
|
||||
// `web/src/plugins/syncPlay/core/Manager.js`.
|
||||
console.debug(
|
||||
"SyncPlay applyCommand: dropping outdated command",
|
||||
command,
|
||||
);
|
||||
console.debug("SyncPlay applyCommand: duplicate command", command);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user