mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(portals): gate VOD auto-failover on engines that can report failures
Merges master and resolves the collision with its shared playback helpers, then closes two Codex findings. Master extracted the Play/Stop button state and the inline position writer that this branch had modified. Rather than forking private copies back out of Xtream, both behaviours move into the shared helpers: `alsoOwns` lets a page own an external session launched for a copy of the same film in another playlist, and the resume latch — which stops a timeupdate emitted before the engine reaches `startTime` from overwriting the point being resumed from — now protects Stalker too, which had the same bug. Auto-failover was offered on every engine, but only the built-in web players raise the playback diagnostic that reaches `onPlaybackFailed()`: Embedded MPV has its diagnostics suppressed and MPV/VLC play outside the app. The toggle is now hidden there, in settings and in the sources menu, instead of promising a switch that can never happen. `setAutoFailover` also ignored `updateSettings()`, which patches memory first and rejects if the write fails — the toggle looked saved, silently reverted on restart, and the rejection surfaced only as an unhandled promise. It now reports the failure like the settings form's own save paths. The three VOD-details route specs each carried a near-identical 150-line TestBed; they now share one harness, which is what makes room for the new cases (1012 -> 584 lines). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
56733db1ec
37 files changed
+1795
-910
No files matched your search
@@ -26,3 +26,5 @@ export * from './lib/navigation/portal-rail-links';
|
||||
export * from './lib/navigation/portal-route.utils';
|
||||
export * from './lib/navigation/workspace-portal-navigation';
|
||||
export * from './lib/collection';
|
||||
export * from './lib/external-playback-button-state';
|
||||
export * from './lib/inline-playback-position-writer';
|
||||
@@ -0,0 +1,210 @@
|
||||
import { signal } from '@angular/core';
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createExternalPlaybackButtonState } from './external-playback-button-state';
|
||||
|
||||
function session(
|
||||
overrides: Partial<ExternalPlayerSession> = {}
|
||||
): ExternalPlayerSession {
|
||||
return {
|
||||
player: 'mpv',
|
||||
status: 'playing',
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
},
|
||||
...overrides,
|
||||
} as ExternalPlayerSession;
|
||||
}
|
||||
|
||||
function setup(initial: ExternalPlayerSession | null = null) {
|
||||
const current = signal<ExternalPlayerSession | null>(initial);
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: current,
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(42),
|
||||
});
|
||||
return { current, api };
|
||||
}
|
||||
|
||||
describe('createExternalPlaybackButtonState', () => {
|
||||
it('is idle with no session', () => {
|
||||
const { api } = setup(null);
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
expect(api.primaryIcon()).toBe('play_arrow');
|
||||
expect(api.primaryLabel()).toBeNull();
|
||||
});
|
||||
|
||||
it('matches a session for the item on screen', () => {
|
||||
const { api } = setup(session());
|
||||
|
||||
expect(api.matchedSession()).not.toBeNull();
|
||||
expect(api.buttonState()).toBe('stop');
|
||||
expect(api.primaryIcon()).toBe('stop_circle');
|
||||
expect(api.primaryLabel()).toBe('Stop MPV');
|
||||
expect(api.isStopAction()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a session playing a different movie', () => {
|
||||
// The whole point of the match: another movie playing in MPV must not
|
||||
// turn this page's Play button into Stop.
|
||||
const { api } = setup(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 999,
|
||||
contentType: 'vod',
|
||||
},
|
||||
} as Partial<ExternalPlayerSession>)
|
||||
);
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('ignores a session from a different playlist', () => {
|
||||
const { api } = setup(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'other-playlist',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
},
|
||||
} as Partial<ExternalPlayerSession>)
|
||||
);
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a session of a different content type', () => {
|
||||
const { api } = setup(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'episode',
|
||||
},
|
||||
} as Partial<ExternalPlayerSession>)
|
||||
);
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['closed', 'error'] as const)(
|
||||
'treats a %s session as nothing playing',
|
||||
(status) => {
|
||||
const { api } = setup(session({ status }));
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
}
|
||||
);
|
||||
|
||||
it('reports a launching session', () => {
|
||||
const { api } = setup(session({ status: 'launching' }));
|
||||
|
||||
expect(api.buttonState()).toBe('launching');
|
||||
expect(api.primaryIcon()).toBe('hourglass_top');
|
||||
expect(api.primaryLabel()).toBe('Opening in MPV...');
|
||||
expect(api.isLaunchPending()).toBe(true);
|
||||
expect(api.isStopAction()).toBe(false);
|
||||
});
|
||||
|
||||
it('reacts to the session changing', () => {
|
||||
const { current, api } = setup(null);
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
|
||||
current.set(session({ status: 'launching' }));
|
||||
expect(api.buttonState()).toBe('launching');
|
||||
|
||||
current.set(session({ status: 'playing' }));
|
||||
expect(api.buttonState()).toBe('stop');
|
||||
|
||||
current.set(null);
|
||||
expect(api.buttonState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('does not match while the item id is unknown', () => {
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: signal<ExternalPlayerSession | null>(session()),
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(null),
|
||||
});
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('also owns a session launched for another playlist it switched to', () => {
|
||||
// Multi-source: same film, other playlist, other stream id. Without
|
||||
// this the page shows Play while its own switch is streaming, and
|
||||
// clicking it opens a second player instead of stopping the first.
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: signal<ExternalPlayerSession | null>(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'vod',
|
||||
},
|
||||
})
|
||||
),
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(42),
|
||||
alsoOwns: signal<PlayerContentInfo | null>({
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'vod',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(api.matchedSession()).not.toBeNull();
|
||||
expect(api.isStopAction()).toBe(true);
|
||||
});
|
||||
|
||||
it('disowns that session once playback moved back', () => {
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: signal<ExternalPlayerSession | null>(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'vod',
|
||||
},
|
||||
})
|
||||
),
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(42),
|
||||
alsoOwns: signal<PlayerContentInfo | null>(null),
|
||||
});
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('still checks the content type of an owned alternative', () => {
|
||||
const api = createExternalPlaybackButtonState({
|
||||
session: signal<ExternalPlayerSession | null>(
|
||||
session({
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'episode',
|
||||
},
|
||||
})
|
||||
),
|
||||
playlistId: signal<string | null>('playlist-1'),
|
||||
contentId: signal<number | null>(42),
|
||||
alsoOwns: signal<PlayerContentInfo | null>({
|
||||
playlistId: 'playlist-2',
|
||||
contentXtreamId: 991,
|
||||
contentType: 'vod',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(api.matchedSession()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { computed, type Signal } from '@angular/core';
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Derives the primary Play/Stop button's state from the active external
|
||||
* (MPV/VLC) session.
|
||||
*
|
||||
* Extracted because Xtream and Stalker each carried a near-identical private
|
||||
* copy of these six computeds. A single Play button that behaves differently
|
||||
* per portal is a bug waiting to happen, so both now read from here.
|
||||
*/
|
||||
|
||||
export type ExternalPlaybackButtonState = 'idle' | 'launching' | 'stop';
|
||||
|
||||
export interface ExternalPlaybackButtonStateConfig {
|
||||
/** The currently active external player session, if any. */
|
||||
session: Signal<ExternalPlayerSession | null>;
|
||||
/** Playlist the detail view is showing. */
|
||||
playlistId: Signal<string | null | undefined>;
|
||||
/** Provider-side id of the item on screen. */
|
||||
contentId: Signal<number | null | undefined>;
|
||||
/** Defaults to `'vod'`. */
|
||||
contentType?: 'vod' | 'episode';
|
||||
/**
|
||||
* A second pair of ids this page also owns, if any.
|
||||
*
|
||||
* Multi-source can launch an external player on a copy of the same film in
|
||||
* ANOTHER playlist, and the session then carries that playlist's ids.
|
||||
* Without this the page shows Play while its own switch is streaming, and
|
||||
* the user has no way to stop it from here.
|
||||
*/
|
||||
alsoOwns?: Signal<PlayerContentInfo | null>;
|
||||
}
|
||||
|
||||
export interface ExternalPlaybackButtonStateApi {
|
||||
/**
|
||||
* The session, but only when it belongs to the item on screen. A session
|
||||
* playing a different movie must not turn this page's Play into Stop.
|
||||
*/
|
||||
matchedSession: Signal<ExternalPlayerSession | null>;
|
||||
primaryLabel: Signal<string | null>;
|
||||
primaryIcon: Signal<string>;
|
||||
isLaunchPending: Signal<boolean>;
|
||||
isStopAction: Signal<boolean>;
|
||||
buttonState: Signal<ExternalPlaybackButtonState>;
|
||||
}
|
||||
|
||||
export function createExternalPlaybackButtonState(
|
||||
config: ExternalPlaybackButtonStateConfig
|
||||
): ExternalPlaybackButtonStateApi {
|
||||
const contentType = config.contentType ?? 'vod';
|
||||
|
||||
const matchedSession = computed(() => {
|
||||
const session = config.session();
|
||||
// A closed or errored session says nothing about what is playing now.
|
||||
if (
|
||||
!session?.contentInfo ||
|
||||
session.status === 'closed' ||
|
||||
session.status === 'error'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = session.contentInfo;
|
||||
if (info.contentType !== contentType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const alsoOwns = config.alsoOwns?.();
|
||||
const owned =
|
||||
(info.playlistId === config.playlistId() &&
|
||||
info.contentXtreamId === config.contentId()) ||
|
||||
(!!alsoOwns &&
|
||||
info.playlistId === alsoOwns.playlistId &&
|
||||
info.contentXtreamId === alsoOwns.contentXtreamId);
|
||||
|
||||
return owned ? session : null;
|
||||
});
|
||||
|
||||
const primaryLabel = computed(() => {
|
||||
const session = matchedSession();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const player = session.player.toUpperCase();
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return `Opening in ${player}...`;
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return `Stop ${player}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const primaryIcon = computed(() => {
|
||||
switch (matchedSession()?.status) {
|
||||
case 'launching':
|
||||
return 'hourglass_top';
|
||||
case 'opened':
|
||||
case 'playing':
|
||||
return 'stop_circle';
|
||||
default:
|
||||
return 'play_arrow';
|
||||
}
|
||||
});
|
||||
|
||||
const isLaunchPending = computed(
|
||||
() => matchedSession()?.status === 'launching'
|
||||
);
|
||||
|
||||
const isStopAction = computed(() => {
|
||||
const status = matchedSession()?.status;
|
||||
return status === 'opened' || status === 'playing';
|
||||
});
|
||||
|
||||
const buttonState = computed<ExternalPlaybackButtonState>(() => {
|
||||
if (isLaunchPending()) {
|
||||
return 'launching';
|
||||
}
|
||||
return isStopAction() ? 'stop' : 'idle';
|
||||
});
|
||||
|
||||
return {
|
||||
matchedSession,
|
||||
primaryLabel,
|
||||
primaryIcon,
|
||||
isLaunchPending,
|
||||
isStopAction,
|
||||
buttonState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { signal } from '@angular/core';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { createInlinePlaybackPositionWriter } from './inline-playback-position-writer';
|
||||
|
||||
function playbackWithInfo(): ResolvedPortalPlayback {
|
||||
return {
|
||||
streamUrl: 'http://example.com/movie.mkv',
|
||||
title: 'Dune',
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(initial: ResolvedPortalPlayback | null = playbackWithInfo()) {
|
||||
const playback = signal<ResolvedPortalPlayback | null>(initial);
|
||||
const save = jest.fn();
|
||||
const onSaved = jest.fn();
|
||||
const writer = createInlinePlaybackPositionWriter({
|
||||
playback,
|
||||
save,
|
||||
onSaved,
|
||||
});
|
||||
return { playback, save, onSaved, writer };
|
||||
}
|
||||
|
||||
describe('createInlinePlaybackPositionWriter', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('writes the first update immediately', () => {
|
||||
const { writer, save, onSaved } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 12.7, duration: 100.2 });
|
||||
|
||||
expect(save).toHaveBeenCalledWith('playlist-1', {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 12,
|
||||
durationSeconds: 100,
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throttles subsequent updates to one per 15s', () => {
|
||||
jest.useFakeTimers();
|
||||
const { writer, save } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 1, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The player fires ~4x/second; none of these may reach storage.
|
||||
jest.advanceTimersByTime(5000);
|
||||
writer.handleTimeUpdate({ currentTime: 6, duration: 100 });
|
||||
jest.advanceTimersByTime(5000);
|
||||
writer.handleTimeUpdate({ currentTime: 11, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(6000);
|
||||
writer.handleTimeUpdate({ currentTime: 17, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reset makes the next update write immediately', () => {
|
||||
jest.useFakeTimers();
|
||||
const { writer, save } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 1, duration: 100 });
|
||||
jest.advanceTimersByTime(1000);
|
||||
writer.handleTimeUpdate({ currentTime: 2, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
writer.reset();
|
||||
writer.handleTimeUpdate({ currentTime: 3, duration: 100 });
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('writes nothing without a playback', () => {
|
||||
const { writer, save } = setup(null);
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 5, duration: 100 });
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing without contentInfo — there is no key to store under', () => {
|
||||
const { writer, save } = setup({
|
||||
streamUrl: 'http://example.com/movie.mkv',
|
||||
title: 'Dune',
|
||||
});
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 5, duration: 100 });
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('floors fractional times', () => {
|
||||
const { writer, save } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 9.99, duration: 42.99 });
|
||||
|
||||
expect(save.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
positionSeconds: 9,
|
||||
durationSeconds: 42,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('resume latch', () => {
|
||||
function resuming(startTime: number): ResolvedPortalPlayback {
|
||||
return { ...playbackWithInfo(), startTime };
|
||||
}
|
||||
|
||||
it('drops updates emitted before the engine reaches startTime', () => {
|
||||
// A resuming engine ticks at ~0 while it is still seeking. Writing
|
||||
// one of those overwrites the very position being resumed from.
|
||||
const { writer, save } = setup(resuming(2538));
|
||||
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 })
|
||||
).toBe(false);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases once, so a later seek backwards is still saved', () => {
|
||||
jest.useFakeTimers();
|
||||
const { writer, save } = setup(resuming(2538));
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 });
|
||||
// Reaching the resume point latches the guard open for good.
|
||||
writer.handleTimeUpdate({ currentTime: 2540, duration: 7744 });
|
||||
|
||||
jest.advanceTimersByTime(16000);
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 30, duration: 7744 })
|
||||
).toBe(true);
|
||||
expect(save).toHaveBeenLastCalledWith(
|
||||
'playlist-1',
|
||||
expect.objectContaining({ positionSeconds: 30 })
|
||||
);
|
||||
});
|
||||
|
||||
it('gives up on a resume point the source cannot reach', () => {
|
||||
// Two hours carried into a 90-minute cut: the engine can never
|
||||
// report that time, so latching on it would suppress every save
|
||||
// for the whole session.
|
||||
const { writer, save } = setup(resuming(7200));
|
||||
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 5400 })
|
||||
).toBe(true);
|
||||
expect(save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the latch closed across an update with no contentInfo', () => {
|
||||
const { writer, playback } = setup(resuming(2538));
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 });
|
||||
playback.set({ streamUrl: 'http://example.com/x.mkv', title: 'X' });
|
||||
|
||||
// Reporting `true` here would tell multi-source that a timecode of
|
||||
// ~0 can be carried to the next source.
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 13, duration: 7744 })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is cleared by reset, so a new resume waits again', () => {
|
||||
const { writer, playback } = setup();
|
||||
|
||||
writer.handleTimeUpdate({ currentTime: 40, duration: 7744 });
|
||||
playback.set(resuming(2538));
|
||||
writer.reset();
|
||||
|
||||
expect(
|
||||
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Signal } from '@angular/core';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Throttled persistence of the inline player's position.
|
||||
*
|
||||
* Extracted because Xtream and Stalker each carried their own copy of the same
|
||||
* "every timeupdate, but at most once per 15s" logic, and a resume point that
|
||||
* behaves differently per portal is exactly the kind of divergence users
|
||||
* notice.
|
||||
*
|
||||
* Beyond that throttle it holds one resume latch, because a position written
|
||||
* before the engine reaches `startTime` overwrites the point being resumed
|
||||
* from — a bug both copies had.
|
||||
*/
|
||||
|
||||
/** The player fires ~4x/second; persisting that often would hammer SQLite. */
|
||||
const DEFAULT_THROTTLE_MS = 15000;
|
||||
|
||||
/** Engines land near, not exactly on, the requested `startTime`. */
|
||||
const RESUME_TOLERANCE_SECONDS = 5;
|
||||
|
||||
export interface InlinePlaybackPositionWriterConfig {
|
||||
/** The playback currently mounted in the inline player. */
|
||||
playback: Signal<ResolvedPortalPlayback | null>;
|
||||
save: (playlistId: string, position: PlaybackPositionData) => void;
|
||||
/** Called with each persisted position, for local resume state. */
|
||||
onSaved?: (position: PlaybackPositionData) => void;
|
||||
throttleMs?: number;
|
||||
}
|
||||
|
||||
export interface InlinePlaybackPositionWriter {
|
||||
/**
|
||||
* @returns whether `currentTime` can be believed yet — false while the
|
||||
* engine is still on its way to `startTime`. Multi-source switching needs
|
||||
* the same answer to carry a timecode across, and one latch has to serve
|
||||
* both or the two disagree about where playback is.
|
||||
*/
|
||||
handleTimeUpdate(event: { currentTime: number; duration: number }): boolean;
|
||||
/** Clears the throttle and the resume latch for a new playback. */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function createInlinePlaybackPositionWriter(
|
||||
config: InlinePlaybackPositionWriterConfig
|
||||
): InlinePlaybackPositionWriter {
|
||||
const throttleMs = config.throttleMs ?? DEFAULT_THROTTLE_MS;
|
||||
let lastSaveTime = 0;
|
||||
/** Cleared on every start; latches once playback reaches `startTime`. */
|
||||
let resumeSettled = false;
|
||||
|
||||
return {
|
||||
handleTimeUpdate(event) {
|
||||
const playback = config.playback();
|
||||
// Without contentInfo there is no key to store the position under.
|
||||
// The latch never ran, so the answer is whatever it already was —
|
||||
// not a fresh "no".
|
||||
if (!playback?.contentInfo) {
|
||||
return resumeSettled;
|
||||
}
|
||||
|
||||
// A resuming engine can emit timeupdates at ~0 before it finishes
|
||||
// seeking to startTime. Writing one would overwrite the very
|
||||
// position being resumed from — which a source switch then
|
||||
// inherits, restarting the film from the beginning.
|
||||
//
|
||||
// This is a one-shot latch, not a filter: once playback has
|
||||
// reached the resume point the guard is done, so a deliberate seek
|
||||
// backwards is still saved normally.
|
||||
if (!resumeSettled) {
|
||||
const startTime = playback.startTime ?? 0;
|
||||
// A shorter cut can never reach a position carried into it,
|
||||
// and latching on that would suppress every save this session.
|
||||
const reachable =
|
||||
!(event.duration > 0) || startTime < event.duration;
|
||||
if (
|
||||
reachable &&
|
||||
startTime > 0 &&
|
||||
event.currentTime < startTime - RESUME_TOLERANCE_SECONDS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
resumeSettled = true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastSaveTime <= throttleMs) {
|
||||
return true;
|
||||
}
|
||||
lastSaveTime = now;
|
||||
|
||||
const position: PlaybackPositionData = {
|
||||
...playback.contentInfo,
|
||||
positionSeconds: Math.floor(event.currentTime),
|
||||
durationSeconds: Math.floor(event.duration),
|
||||
};
|
||||
|
||||
config.save(playback.contentInfo.playlistId, position);
|
||||
config.onSaved?.(position);
|
||||
return true;
|
||||
},
|
||||
|
||||
reset() {
|
||||
lastSaveTime = 0;
|
||||
resumeSettled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user