Final touches

This commit is contained in:
Alex Kim
2026-06-05 23:36:34 +10:00
parent 2df63eb63c
commit 613ad1effc
4 changed files with 127 additions and 63 deletions

View File

@@ -32,6 +32,7 @@ export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
createGroup,
leaveGroup,
getGroups,
resumeGroupPlayback,
} = useSyncPlay();
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
@@ -93,6 +94,18 @@ export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
}
}, [leaveGroup, onClose]);
// Jump (back) into the group's current item. Mirrors jellyfin-web's
// "Resume playback" menu entry — close the sheet and navigate to
// the player; SyncPlayProvider handles the re-follow + URL build.
const handleResumePlayback = useCallback(async () => {
try {
await resumeGroupPlayback();
onClose();
} catch (error) {
console.error("Failed to resume group playback", error);
}
}, [resumeGroupPlayback, onClose]);
const containerStyle = {
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
@@ -135,6 +148,17 @@ export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
)}
</View>
<View className='mb-3'>
<Button onPress={handleResumePlayback} color='black'>
<View className='flex-row items-center justify-center'>
<Ionicons name='play-circle-outline' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.resume_playback")}
</Text>
</View>
</Button>
</View>
<Button onPress={handleLeaveGroup} color='red'>
<View className='flex-row items-center justify-center'>
<Ionicons name='exit-outline' size={20} color='white' />

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -1008,24 +1008,22 @@
"my_group": "My Group",
"join_group": "Join Group",
"leave_group": "Leave Group",
"create_group": "Create Group",
"create_new_group": "Create New Group",
"available_groups": "Available Groups",
"group_id": "Group ID",
"leader": "Leader",
"members": "members",
"enabled": "SyncPlay enabled",
"disabled": "SyncPlay disabled",
"user_joined": "{{username}} joined the group",
"user_left": "{{username}} left the group",
"permission_required": "Permission required to use SyncPlay",
"group_does_not_exist": "Group does not exist",
"create_denied": "Permission denied to create group",
"join_denied": "Permission denied to join group",
"library_access_denied": "Library access denied",
"waiting_for_group": "Waiting for group...",
"joining_playback": "Joining group playback...",
"failed_to_start": "Failed to start SyncPlay group playback"
"failed_to_start": "Failed to start SyncPlay group playback",
"resume_playback": "Resume playback",
"toasts": {
"MessageSyncPlayGroupJoined": "Joined group",
"MessageSyncPlayGroupLeft": "Left group",
"MessageSyncPlayUserJoined": "{{user}} joined the group",
"MessageSyncPlayUserLeft": "{{user}} left the group",
"MessageSyncPlayCreateGroupDenied": "Permission denied to create a group",
"MessageSyncPlayJoinGroupDenied": "Permission denied to join group",
"MessageSyncPlayLibraryAccessDenied": "Access to the content has been denied",
"MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist",
"MessageSyncPlayErrorMedia": "SyncPlay error during media playback"
}
},
"companion_login": {
"title": "Pair with TV",