mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 21:48:31 +01:00
Final touches
This commit is contained in:
@@ -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' />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user