feat(playback): track session recovery attempts

This commit is contained in:
4gray committed 2026-08-04 21:27:33 +02:00
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);
}
}