mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(playback): track session recovery attempts
This commit is contained in:
1 parent
aae11613fc
commit
4c16f9b7a0
3 files changed
+666
-23
No files matched your search
@@ -879,7 +879,9 @@ expect(session.recordFailure(first)).toBe(false);
|
||||
Also test Retry preserves attempts, source changes with the same key preserve
|
||||
attempts, live sessions never retain a resume point, external attempts are
|
||||
recorded, an accepted failure or settle clears pending state, and concurrent
|
||||
switch/retry attempts accept only the first operation.
|
||||
switch/retry attempts accept only the first operation. Explicitly cover the
|
||||
pre-effect interval after an accepted switch and retry: old binding callbacks
|
||||
must be rejected until `beginPlayback` installs the replacement binding.
|
||||
|
||||
Run the focused spec. Expected: FAIL because the class does not exist.
|
||||
|
||||
@@ -894,19 +896,28 @@ export interface PlaybackBinding {
|
||||
}
|
||||
|
||||
export class PlaybackRecoverySession {
|
||||
readonly attemptedTargets = signal<
|
||||
private readonly attemptedTargetsState = signal<
|
||||
ReadonlySet<PlaybackRecommendationTarget>
|
||||
>(new Set());
|
||||
readonly temporaryPlayerOverride = signal<InlinePlaybackPlayer | null>(
|
||||
null
|
||||
);
|
||||
readonly switchPending = signal(false);
|
||||
readonly activeBinding = signal<PlaybackBinding | null>(null);
|
||||
private readonly temporaryPlayerOverrideState =
|
||||
signal<InlinePlaybackPlayer | null>(null);
|
||||
private readonly switchPendingState = signal(false);
|
||||
private readonly activeBindingState = signal<PlaybackBinding | null>(null);
|
||||
|
||||
private readonly sessionKey = signal<string | null>(null);
|
||||
private readonly generation = signal(0);
|
||||
private readonly resumePosition = signal<number | null>(null);
|
||||
|
||||
readonly attemptedTargets: Signal<
|
||||
ReadonlySet<PlaybackRecommendationTarget>
|
||||
> = this.attemptedTargetsState.asReadonly();
|
||||
readonly temporaryPlayerOverride: Signal<InlinePlaybackPlayer | null> =
|
||||
this.temporaryPlayerOverrideState.asReadonly();
|
||||
readonly switchPending: Signal<boolean> =
|
||||
this.switchPendingState.asReadonly();
|
||||
readonly activeBinding: Signal<PlaybackBinding | null> =
|
||||
this.activeBindingState.asReadonly();
|
||||
|
||||
syncSession(key: string): boolean;
|
||||
beginPlayback(target: InlinePlaybackPlayer): PlaybackBinding;
|
||||
clearPlaybackBinding(): void;
|
||||
@@ -930,23 +941,28 @@ Implementation rules:
|
||||
- `syncSession` is a no-op for the same key; a new key clears attempts,
|
||||
override, pending state, resume position, and active binding, then increments
|
||||
generation.
|
||||
- `beginPlayback` always advances the generation and installs the exact active
|
||||
target binding. Call it for every applied source, including a same-content
|
||||
alternative URL, so delayed events from the replaced source become stale
|
||||
without clearing attempts. `clearPlaybackBinding` advances the generation
|
||||
and stores null for Embedded MPV/non-diagnostic playback.
|
||||
- Public state is exposed as read-only `Signal` values backed by private
|
||||
writable signals. `beginPlayback` always advances the generation and
|
||||
installs and returns the same frozen active-target snapshot. Call it for
|
||||
every applied source, including a same-content alternative URL, so delayed
|
||||
events from the replaced source become stale without clearing attempts.
|
||||
`clearPlaybackBinding` delegates to the same private invalidation helper used
|
||||
by recovery operations; the helper advances the generation and stores null
|
||||
for Embedded MPV/non-diagnostic playback.
|
||||
- `recordInlineAttempt`, `recordExternalAttempt`, and every failure update copy
|
||||
the Set before adding.
|
||||
- `recordFailure` and `settle` first call `accepts`; stale generations do
|
||||
nothing. Both clear `switchPending` for an accepted binding; a replacement
|
||||
target that fails before emitting a success/clear event must not leave every
|
||||
recovery action disabled.
|
||||
- `beginPlayerSwitch` returns false while pending; otherwise it records the
|
||||
target, clears live resume, sets the override and pending state, and returns
|
||||
true. The component's playback effect then calls `beginPlayback` for the new
|
||||
selected target.
|
||||
- `beginRetry` returns false while pending or without an active binding;
|
||||
otherwise it keeps attempts/override, marks the reload pending, and returns
|
||||
nothing. Both clear `switchPending` for an accepted replacement binding; a
|
||||
replacement target that fails before emitting a success/clear event must not
|
||||
leave every recovery action disabled.
|
||||
- `beginPlayerSwitch` returns false without invalidating the binding while
|
||||
pending. Otherwise it records the target, clears live resume, sets the
|
||||
override and pending state, synchronously invalidates the current binding,
|
||||
and returns true. Old callbacks in the interval before the component's
|
||||
playback effect calls `beginPlayback` are therefore rejected.
|
||||
- `beginRetry` returns false without invalidating the binding while pending or
|
||||
without an active binding. Otherwise it keeps attempts/override, marks the
|
||||
reload pending, synchronously invalidates the current binding, and returns
|
||||
true. Incrementing the component reload token reruns the playback effect and
|
||||
installs the next binding.
|
||||
- `recordTimeUpdate` stores only finite non-negative VOD positions; live,
|
||||
@@ -1509,8 +1525,8 @@ Add the required input and focused computed state:
|
||||
```typescript
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
private readonly recoverySession = new PlaybackRecoverySession();
|
||||
readonly recoveryPending = this.recoverySession.switchPending.asReadonly();
|
||||
readonly activeBinding = this.recoverySession.activeBinding.asReadonly();
|
||||
readonly recoveryPending = this.recoverySession.switchPending;
|
||||
readonly activeBinding = this.recoverySession.activeBinding;
|
||||
|
||||
readonly selectedPlayer = computed<VideoPlayer>(() => {
|
||||
const temporary = this.recoverySession.temporaryPlayerOverride();
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
type PlaybackRecommendationTarget,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { PlaybackRecoverySession } from './playback-recovery-session';
|
||||
|
||||
describe('PlaybackRecoverySession', () => {
|
||||
it('tracks failure, switch resume, and a reset across sessions', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
|
||||
expect(session.syncSession('movie-a')).toBe(true);
|
||||
const firstBinding = session.beginPlayback(
|
||||
InlinePlaybackPlayer.VideoJs
|
||||
);
|
||||
expect(session.recordFailure(firstBinding)).toBe(true);
|
||||
expect([...session.attemptedTargets()]).toEqual([
|
||||
InlinePlaybackPlayer.VideoJs,
|
||||
]);
|
||||
|
||||
session.recordTimeUpdate({ currentTime: 42, duration: 120 }, false);
|
||||
expect(
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
|
||||
).toBe(true);
|
||||
const replacementBinding = session.beginPlayback(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
expect(session.resumeStartTime(0, false)).toBe(42);
|
||||
|
||||
const previousAttempts = session.attemptedTargets();
|
||||
expect(session.syncSession('movie-b')).toBe(true);
|
||||
expect(session.attemptedTargets()).not.toBe(previousAttempts);
|
||||
expect(session.attemptedTargets()).toEqual(new Set());
|
||||
expect(session.temporaryPlayerOverride()).toBeNull();
|
||||
expect(session.switchPending()).toBe(false);
|
||||
expect(session.activeBinding()).toBeNull();
|
||||
expect(session.recordFailure(firstBinding)).toBe(false);
|
||||
expect(session.resumeStartTime(0, false)).toBe(0);
|
||||
const newBinding = session.beginPlayback(
|
||||
InlinePlaybackPlayer.ArtPlayer
|
||||
);
|
||||
expect(newBinding.generation).toBe(replacementBinding.generation + 2);
|
||||
});
|
||||
|
||||
it('treats the same exact session key as a no-op', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.syncSession('movie-a');
|
||||
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
|
||||
session.recordTimeUpdate({ currentTime: 24, duration: 100 }, false);
|
||||
const attempts = session.attemptedTargets();
|
||||
|
||||
expect(session.syncSession('movie-a')).toBe(false);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
expect(session.activeBinding()).toBe(binding);
|
||||
expect(session.accepts(binding)).toBe(true);
|
||||
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
|
||||
const switchAttempts = session.attemptedTargets();
|
||||
expect(session.syncSession('movie-a')).toBe(false);
|
||||
expect(session.attemptedTargets()).toBe(switchAttempts);
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
expect(session.resumeStartTime(5, false)).toBe(24);
|
||||
expect(session.activeBinding()).toBeNull();
|
||||
expect(session.accepts(binding)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an empty session key and compares keys exactly', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
|
||||
expect(session.syncSession('')).toBe(true);
|
||||
expect(session.syncSession('')).toBe(false);
|
||||
expect(session.syncSession(' ')).toBe(true);
|
||||
});
|
||||
|
||||
it('advances the generation whenever playback begins', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.syncSession('movie-a');
|
||||
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
|
||||
|
||||
const first = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
const second = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
|
||||
expect(second.generation).toBe(first.generation + 1);
|
||||
expect(session.accepts(first)).toBe(false);
|
||||
expect(session.accepts(second)).toBe(true);
|
||||
expect(session.attemptedTargets()).toEqual(
|
||||
new Set([InlinePlaybackPlayer.ArtPlayer])
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves session attempts across an alternative source binding', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.syncSession('movie-a');
|
||||
const first = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
session.recordFailure(first);
|
||||
session.beginRetry();
|
||||
|
||||
const alternative = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
session.settle(first);
|
||||
|
||||
expect(session.accepts(first)).toBe(false);
|
||||
expect(session.accepts(alternative)).toBe(true);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
expect(session.attemptedTargets()).toEqual(
|
||||
new Set([InlinePlaybackPlayer.VideoJs])
|
||||
);
|
||||
session.settle(alternative);
|
||||
expect(session.switchPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('clears playback binding and prevents retry without resetting session data', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.syncSession('movie-a');
|
||||
session.recordTimeUpdate({ currentTime: 17, duration: 80 }, false);
|
||||
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
|
||||
const binding = session.beginPlayback(InlinePlaybackPlayer.Html5);
|
||||
session.settle(binding);
|
||||
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
|
||||
|
||||
session.clearPlaybackBinding();
|
||||
|
||||
expect(session.activeBinding()).toBeNull();
|
||||
expect(session.accepts(binding)).toBe(false);
|
||||
expect(session.beginRetry()).toBe(false);
|
||||
expect(session.attemptedTargets()).toEqual(
|
||||
new Set([
|
||||
InlinePlaybackPlayer.Html5,
|
||||
InlinePlaybackPlayer.ArtPlayer,
|
||||
])
|
||||
);
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
expect(session.resumeStartTime(0, false)).toBe(17);
|
||||
const nextBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
expect(nextBinding.generation).toBe(binding.generation + 2);
|
||||
});
|
||||
|
||||
it('accepts only a matching active generation and target', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
expect(
|
||||
session.accepts({
|
||||
generation: 0,
|
||||
target: InlinePlaybackPlayer.VideoJs,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
|
||||
expect(session.accepts({ ...binding })).toBe(true);
|
||||
expect(
|
||||
session.accepts({
|
||||
generation: binding.generation,
|
||||
target: InlinePlaybackPlayer.Html5,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes an immutable active binding snapshot', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
const activeBinding = session.activeBinding();
|
||||
|
||||
expect(Object.isFrozen(binding)).toBe(true);
|
||||
expect(activeBinding).toBe(binding);
|
||||
expect(Object.isFrozen(activeBinding)).toBe(true);
|
||||
expect('set' in session.attemptedTargets).toBe(false);
|
||||
expect('set' in session.temporaryPlayerOverride).toBe(false);
|
||||
expect('set' in session.switchPending).toBe(false);
|
||||
expect('set' in session.activeBinding).toBe(false);
|
||||
expect(
|
||||
Reflect.set(
|
||||
binding as unknown as Record<string, unknown>,
|
||||
'target',
|
||||
InlinePlaybackPlayer.Html5
|
||||
)
|
||||
).toBe(false);
|
||||
expect(session.activeBinding()?.target).toBe(
|
||||
InlinePlaybackPlayer.VideoJs
|
||||
);
|
||||
expect(
|
||||
session.accepts({
|
||||
generation: binding.generation,
|
||||
target: InlinePlaybackPlayer.Html5,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('copies attempted targets for every inline and external attempt', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
const initial = session.attemptedTargets();
|
||||
|
||||
session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs);
|
||||
const inline = session.attemptedTargets();
|
||||
session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs);
|
||||
const duplicate = session.attemptedTargets();
|
||||
session.recordExternalAttempt('mpv');
|
||||
const external = session.attemptedTargets();
|
||||
|
||||
expect(initial).toEqual(new Set());
|
||||
expect(inline).toEqual(new Set([InlinePlaybackPlayer.VideoJs]));
|
||||
expect(duplicate).toEqual(new Set([InlinePlaybackPlayer.VideoJs]));
|
||||
expect(external).toEqual(
|
||||
new Set<PlaybackRecommendationTarget>([
|
||||
InlinePlaybackPlayer.VideoJs,
|
||||
'mpv',
|
||||
])
|
||||
);
|
||||
expect(inline).not.toBe(initial);
|
||||
expect(duplicate).not.toBe(inline);
|
||||
expect(external).not.toBe(duplicate);
|
||||
});
|
||||
|
||||
it('copies attempts and clears pending for an accepted failure', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.beginPlayback(InlinePlaybackPlayer.Html5);
|
||||
session.beginRetry();
|
||||
const binding = session.beginPlayback(InlinePlaybackPlayer.Html5);
|
||||
const attempts = session.attemptedTargets();
|
||||
|
||||
expect(session.recordFailure(binding)).toBe(true);
|
||||
expect(session.attemptedTargets()).not.toBe(attempts);
|
||||
expect(session.attemptedTargets()).toEqual(
|
||||
new Set([InlinePlaybackPlayer.Html5])
|
||||
);
|
||||
expect(session.switchPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects the old binding throughout the switch pre-effect interval', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
const oldBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
|
||||
expect(
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
|
||||
).toBe(true);
|
||||
const attempts = session.attemptedTargets();
|
||||
expect(session.activeBinding()).toBeNull();
|
||||
expect(session.recordFailure(oldBinding)).toBe(false);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
session.settle(oldBinding);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
|
||||
const replacement = session.beginPlayback(InlinePlaybackPlayer.Html5);
|
||||
session.settle(replacement);
|
||||
expect(session.switchPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects the old binding throughout the retry pre-effect interval', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
|
||||
const oldBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
|
||||
expect(session.beginRetry()).toBe(true);
|
||||
const attempts = session.attemptedTargets();
|
||||
expect(session.activeBinding()).toBeNull();
|
||||
expect(session.recordFailure(oldBinding)).toBe(false);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
session.settle(oldBinding);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
expect(session.temporaryPlayerOverride()).toBeNull();
|
||||
|
||||
const replacement = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
expect(session.recordFailure(replacement)).toBe(true);
|
||||
expect(session.switchPending()).toBe(false);
|
||||
expect(session.attemptedTargets()).not.toBe(attempts);
|
||||
expect(session.attemptedTargets()).toEqual(
|
||||
new Set([
|
||||
InlinePlaybackPlayer.ArtPlayer,
|
||||
InlinePlaybackPlayer.VideoJs,
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves all state unchanged for a stale failure', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
const stale = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
|
||||
const current = session.beginPlayback(InlinePlaybackPlayer.Html5);
|
||||
const attempts = session.attemptedTargets();
|
||||
|
||||
expect(session.recordFailure(stale)).toBe(false);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
expect(session.activeBinding()).toBe(current);
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
});
|
||||
|
||||
it('settles only the matching binding without recording an attempt', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
const stale = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
|
||||
const current = session.beginPlayback(InlinePlaybackPlayer.Html5);
|
||||
const attempts = session.attemptedTargets();
|
||||
|
||||
session.settle(stale);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
|
||||
session.settle(current);
|
||||
expect(session.switchPending()).toBe(false);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
});
|
||||
|
||||
it('records a switch immediately and clears resume only for live playback', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.recordTimeUpdate({ currentTime: 31, duration: 90 }, false);
|
||||
const attempts = session.attemptedTargets();
|
||||
|
||||
expect(
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, true)
|
||||
).toBe(true);
|
||||
|
||||
expect(session.attemptedTargets()).not.toBe(attempts);
|
||||
expect(session.attemptedTargets()).toEqual(
|
||||
new Set([InlinePlaybackPlayer.Html5])
|
||||
);
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
expect(session.resumeStartTime(7, false)).toBe(7);
|
||||
});
|
||||
|
||||
it('allows only the switch when a switch races a retry', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
|
||||
expect(
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
|
||||
).toBe(true);
|
||||
const replacement = session.beginPlayback(InlinePlaybackPlayer.Html5);
|
||||
const attempts = session.attemptedTargets();
|
||||
expect(session.beginRetry()).toBe(false);
|
||||
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
expect(session.activeBinding()).toBe(replacement);
|
||||
expect(session.accepts(replacement)).toBe(true);
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
session.settle(replacement);
|
||||
const bindingAfterRejectedRetry = session.beginPlayback(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
expect(bindingAfterRejectedRetry.generation).toBe(
|
||||
replacement.generation + 1
|
||||
);
|
||||
});
|
||||
|
||||
it('allows only the retry when a retry races a switch', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
|
||||
session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.ArtPlayer, false);
|
||||
const switched = session.beginPlayback(InlinePlaybackPlayer.ArtPlayer);
|
||||
session.settle(switched);
|
||||
|
||||
expect(session.beginRetry()).toBe(true);
|
||||
const replacement = session.beginPlayback(
|
||||
InlinePlaybackPlayer.ArtPlayer
|
||||
);
|
||||
const attempts = session.attemptedTargets();
|
||||
expect(
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false)
|
||||
).toBe(false);
|
||||
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
expect(session.activeBinding()).toBe(replacement);
|
||||
expect(session.accepts(replacement)).toBe(true);
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.ArtPlayer
|
||||
);
|
||||
expect(session.switchPending()).toBe(true);
|
||||
session.settle(replacement);
|
||||
const bindingAfterRejectedSwitch = session.beginPlayback(
|
||||
InlinePlaybackPlayer.ArtPlayer
|
||||
);
|
||||
expect(bindingAfterRejectedSwitch.generation).toBe(
|
||||
replacement.generation + 1
|
||||
);
|
||||
});
|
||||
|
||||
it('begins retry only with an active binding and preserves state', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer);
|
||||
expect(session.beginRetry()).toBe(false);
|
||||
|
||||
const initial = session.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
expect(initial.generation).toBe(1);
|
||||
session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false);
|
||||
const switched = session.beginPlayback(InlinePlaybackPlayer.Html5);
|
||||
session.settle(switched);
|
||||
const attempts = session.attemptedTargets();
|
||||
|
||||
expect(session.beginRetry()).toBe(true);
|
||||
expect(session.attemptedTargets()).toBe(attempts);
|
||||
expect(session.activeBinding()).toBeNull();
|
||||
expect(session.temporaryPlayerOverride()).toBe(
|
||||
InlinePlaybackPlayer.Html5
|
||||
);
|
||||
expect(session.beginRetry()).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ currentTime: 9, duration: 20 }, true],
|
||||
[{ currentTime: Number.NaN, duration: 20 }, false],
|
||||
[{ currentTime: Number.POSITIVE_INFINITY, duration: 20 }, false],
|
||||
[{ currentTime: Number.NEGATIVE_INFINITY, duration: 20 }, false],
|
||||
[{ currentTime: -1, duration: 20 }, false],
|
||||
])('ignores invalid or live time update %#', (event, isLive) => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
session.recordTimeUpdate({ currentTime: 13, duration: 40 }, false);
|
||||
|
||||
session.recordTimeUpdate(event, isLive);
|
||||
|
||||
expect(session.resumeStartTime(3, false)).toBe(13);
|
||||
});
|
||||
|
||||
it('stores any finite non-negative VOD time without interpreting duration', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
|
||||
session.recordTimeUpdate(
|
||||
{ currentTime: 0, duration: Number.NaN },
|
||||
false
|
||||
);
|
||||
expect(session.resumeStartTime(8, false)).toBe(0);
|
||||
session.recordTimeUpdate({ currentTime: 75, duration: 10 }, false);
|
||||
expect(session.resumeStartTime(8, false)).toBe(75);
|
||||
});
|
||||
|
||||
it('always returns zero for live and otherwise preserves the host start time', () => {
|
||||
const session = new PlaybackRecoverySession();
|
||||
|
||||
expect(session.resumeStartTime(19, false)).toBe(19);
|
||||
expect(session.resumeStartTime(19, true)).toBe(0);
|
||||
|
||||
session.recordTimeUpdate({ currentTime: 44, duration: 100 }, false);
|
||||
expect(session.resumeStartTime(19, true)).toBe(0);
|
||||
expect(session.resumeStartTime(19, false)).toBe(44);
|
||||
});
|
||||
|
||||
it('does not let stale callbacks clear pending for a new session', () => {
|
||||
const stale = new PlaybackRecoverySession();
|
||||
stale.syncSession('movie-a');
|
||||
const oldBinding = stale.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
stale.syncSession('movie-b');
|
||||
stale.beginPlayback(InlinePlaybackPlayer.VideoJs);
|
||||
stale.beginRetry();
|
||||
|
||||
stale.settle(oldBinding);
|
||||
expect(stale.switchPending()).toBe(true);
|
||||
expect(stale.recordFailure(oldBinding)).toBe(false);
|
||||
expect(stale.switchPending()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { signal, type Signal } from '@angular/core';
|
||||
import type {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackRecommendationTarget,
|
||||
} from '@iptvnator/playback/util';
|
||||
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface PlaybackBinding {
|
||||
readonly generation: number;
|
||||
readonly target: InlinePlaybackPlayer;
|
||||
}
|
||||
|
||||
export class PlaybackRecoverySession {
|
||||
private readonly attemptedTargetsState = signal<
|
||||
ReadonlySet<PlaybackRecommendationTarget>
|
||||
>(new Set());
|
||||
private readonly temporaryPlayerOverrideState =
|
||||
signal<InlinePlaybackPlayer | null>(null);
|
||||
private readonly switchPendingState = signal(false);
|
||||
private readonly activeBindingState = signal<PlaybackBinding | null>(null);
|
||||
private readonly sessionKey = signal<string | null>(null);
|
||||
private readonly generation = signal(0);
|
||||
private readonly resumePosition = signal<number | null>(null);
|
||||
|
||||
public readonly attemptedTargets: Signal<
|
||||
ReadonlySet<PlaybackRecommendationTarget>
|
||||
> = this.attemptedTargetsState.asReadonly();
|
||||
public readonly temporaryPlayerOverride: Signal<InlinePlaybackPlayer | null> =
|
||||
this.temporaryPlayerOverrideState.asReadonly();
|
||||
public readonly switchPending: Signal<boolean> =
|
||||
this.switchPendingState.asReadonly();
|
||||
public readonly activeBinding: Signal<PlaybackBinding | null> =
|
||||
this.activeBindingState.asReadonly();
|
||||
|
||||
syncSession(key: string): boolean {
|
||||
if (this.sessionKey() === key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.sessionKey.set(key);
|
||||
this.attemptedTargetsState.set(new Set());
|
||||
this.temporaryPlayerOverrideState.set(null);
|
||||
this.switchPendingState.set(false);
|
||||
this.resumePosition.set(null);
|
||||
this.activeBindingState.set(null);
|
||||
this.generation.update((generation) => generation + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
beginPlayback(target: InlinePlaybackPlayer): PlaybackBinding {
|
||||
const generation = this.generation() + 1;
|
||||
const binding: PlaybackBinding = Object.freeze({
|
||||
generation,
|
||||
target,
|
||||
});
|
||||
this.generation.set(generation);
|
||||
this.activeBindingState.set(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
clearPlaybackBinding(): void {
|
||||
this.invalidatePlaybackBinding();
|
||||
}
|
||||
|
||||
recordFailure(binding: PlaybackBinding): boolean {
|
||||
if (!this.accepts(binding)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.recordAttempt(binding.target);
|
||||
this.switchPendingState.set(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
recordInlineAttempt(target: InlinePlaybackPlayer): void {
|
||||
this.recordAttempt(target);
|
||||
}
|
||||
|
||||
recordExternalAttempt(target: ExternalPlayerName): void {
|
||||
this.recordAttempt(target);
|
||||
}
|
||||
|
||||
recordTimeUpdate(
|
||||
event: { readonly currentTime: number; readonly duration: number },
|
||||
isLive: boolean
|
||||
): void {
|
||||
if (
|
||||
!isLive &&
|
||||
Number.isFinite(event.currentTime) &&
|
||||
event.currentTime >= 0
|
||||
) {
|
||||
this.resumePosition.set(event.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
beginPlayerSwitch(target: InlinePlaybackPlayer, isLive: boolean): boolean {
|
||||
if (this.switchPendingState()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.recordInlineAttempt(target);
|
||||
if (isLive) {
|
||||
this.resumePosition.set(null);
|
||||
}
|
||||
this.temporaryPlayerOverrideState.set(target);
|
||||
this.switchPendingState.set(true);
|
||||
this.invalidatePlaybackBinding();
|
||||
return true;
|
||||
}
|
||||
|
||||
beginRetry(): boolean {
|
||||
if (this.switchPendingState() || this.activeBindingState() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.switchPendingState.set(true);
|
||||
this.invalidatePlaybackBinding();
|
||||
return true;
|
||||
}
|
||||
|
||||
settle(binding: PlaybackBinding): void {
|
||||
if (this.accepts(binding)) {
|
||||
this.switchPendingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
resumeStartTime(inputStartTime: number, isLive: boolean): number {
|
||||
if (isLive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const position = this.resumePosition();
|
||||
return position !== null && Number.isFinite(position) && position >= 0
|
||||
? position
|
||||
: inputStartTime;
|
||||
}
|
||||
|
||||
accepts(binding: PlaybackBinding): boolean {
|
||||
const activeBinding = this.activeBindingState();
|
||||
return (
|
||||
activeBinding !== null &&
|
||||
activeBinding.generation === binding.generation &&
|
||||
activeBinding.target === binding.target
|
||||
);
|
||||
}
|
||||
|
||||
private recordAttempt(target: PlaybackRecommendationTarget): void {
|
||||
const attempts = new Set(this.attemptedTargetsState());
|
||||
attempts.add(target);
|
||||
this.attemptedTargetsState.set(attempts);
|
||||
}
|
||||
|
||||
private invalidatePlaybackBinding(): void {
|
||||
this.generation.update((generation) => generation + 1);
|
||||
this.activeBindingState.set(null);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user