fix(portals): release the resume latch when the target cannot be reached

Carrying a position into a shorter cut of the same film — two hours into a
90-minute source — leaves the engine unable to ever report that time, so the
one-shot latch never released: every position save was suppressed for the
rest of the session, and the impossible start time kept being reported to
multi-source for the next switch.

The latch now also opens when a known duration puts the requested point out
of reach, while a reachable one still waits as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 06:42:36 +02:00
1 parent 7459f993e2
commit 0c891f13d8
3 files changed
+51 -11

No files matched your search

+4 -1
View File
@@ -304,7 +304,10 @@ the save throttle and applying it would visibly rewind.
A resuming engine can emit a `timeupdate` at ~0 before it finishes seeking.
`VodDetailsPlaybackService` guards this with a one-shot `resumeSettled` latch —
a filter would have broken deliberate seek-backwards. `handleInlineTimeUpdate`
a filter would have broken deliberate seek-backwards. The latch also releases
when the target is out of reach: switching a two-hour position into a
90-minute cut means the engine can never report it, and waiting would suppress
every save for the rest of the session. `handleInlineTimeUpdate`
returns that verdict and the route feeds multi-source the `startTime` it asked
for until the engine gets there, so a switch or a failure during the initial
seek does not resolve the next source at zero and restart the film. One latch
@@ -158,6 +158,43 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(addRecentItem).toHaveBeenCalled();
});
it('gives up on a resume point the source cannot reach', () => {
// Carrying a two-hour position into a 90-minute cut: the engine can
// never report that time, so latching on it would suppress every save
// for the whole session and keep re-reporting the impossible start.
service.inlinePlayback.set({
streamUrl: 'https://example.com/short-cut.mkv',
title: 'Example Movie',
startTime: 7200,
contentInfo: {
playlistId: ROUTE_PLAYLIST,
contentXtreamId: ROUTE_VOD_ID,
contentType: 'vod',
},
});
expect(
service.handleInlineTimeUpdate({ currentTime: 12, duration: 5400 })
).toBe(true);
});
it('still waits for a resume point the source can reach', () => {
service.inlinePlayback.set({
streamUrl: 'https://example.com/full.mkv',
title: 'Example Movie',
startTime: 2538,
contentInfo: {
playlistId: ROUTE_PLAYLIST,
contentXtreamId: ROUTE_VOD_ID,
contentType: 'vod',
},
});
expect(
service.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 })
).toBe(false);
});
describe('position updates from the bridge', () => {
function emit(playlistId: string, contentXtreamId: number, at: number) {
positionListener?.({
@@ -31,10 +31,9 @@ export interface VodDetailsPlaybackBindings {
/** Usable metadata of the selected VOD, if any */
vodInfo: Signal<XtreamVodInfo | null>;
/**
* The source actually playing when it is NOT the route's own — supplied by
* multi-source. An external player launched for an alternative carries
* that playlist's ids, so without this the session belongs to no page and
* its Stop button never appears.
* The source playing when it is NOT the route's own. An external player
* launched for an alternative carries that playlist's ids, so without
* this the session belongs to no page and never offers Stop.
*/
activeSource?: Signal<PlayerContentInfo | null>;
}
@@ -56,9 +55,7 @@ export class VodDetailsPlaybackService {
private readonly logger = createLogger('VodDetailsPlayback');
/** Signals bound from the host component via `bind()` */
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(
null
);
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
private lastSaveTime = 0;
/** Cleared on every start; latches once playback reaches startTime. */
private resumeSettled = false;
@@ -323,7 +320,11 @@ export class VodDetailsPlaybackService {
// is still saved normally.
if (!this.resumeSettled) {
const startTime = playback.startTime ?? 0;
if (startTime > 0 && event.currentTime < startTime - 5)
// A shorter cut can never reach a position carried into it, and
// latching on that would suppress every save for the session.
const reachable =
!(event.duration > 0) || startTime < event.duration;
if (reachable && startTime > 0 && event.currentTime < startTime - 5)
return false;
this.resumeSettled = true;
}
@@ -380,8 +381,7 @@ export class VodDetailsPlaybackService {
* engine survive and simply re-seek to `playback.startTime`.
*/
startResolvedPlayback(playback: ResolvedPortalPlayback): void {
// Same movie, different source: still a view, and the picker and
// pin paths would otherwise leave it out of Recently Viewed.
// Same movie, different source: still a view.
this.addToRecentlyViewed();
this.startPlayback(playback);
}