fix(playback): reject stale progress updates

This commit is contained in:
4gray committed 2026-08-04 21:27:34 +02:00
1 parent 349e19097a
commit b6bf9a7bb7
6 files changed
+240 -86

No files matched your search

+5 -2
View File
@@ -256,8 +256,11 @@ Key files:
fieldless opaque `Symbol` token. Source applications also advance a second
fieldless revision `Symbol` that clears only the VOD handoff position;
target-only switches and Retry leave it stable. None of these ownership
primitives contains URLs, headers, DRM material, or credentials. A
recommended built-in player temporarily
primitives contains URLs, headers, DRM material, or credentials. Each
rendered web or Embedded MPV application captures its nullable binding, both
tokens, and live/VOD flag; a time update changes resume state only while that
exact capture still owns the current application. A recommended built-in
player temporarily
outranks the host override and saved player for that mounted content session,
never mutates `Settings.player`, and resumes finite VOD position on a
best-effort basis; live playback returns to the live edge. Retry and
+5 -2
View File
@@ -780,8 +780,11 @@ app as a real argument, so it is not an option.
fieldless opaque `Symbol` token. Source applications also advance a second
fieldless revision `Symbol` that clears only the VOD handoff position;
target-only switches and Retry leave it stable. None of these ownership
primitives contains URLs, headers, DRM material, or credentials. A
recommended built-in player temporarily
primitives contains URLs, headers, DRM material, or credentials. Each
rendered web or Embedded MPV application captures its nullable binding, both
tokens, and live/VOD flag; a time update changes resume state only while that
exact capture still owns the current application. A recommended built-in
player temporarily
outranks the host override and saved player for that mounted content session,
never mutates `Settings.player`, and resumes finite VOD position on a
best-effort basis; live playback returns to the live edge. Retry and
@@ -624,7 +624,10 @@ applications also advance a second fieldless source-revision `Symbol` that
resets the VOD handoff position; target-only switches and Retry leave that
revision stable. None of these ownership objects embed source material, and
recovery ownership state never stores URLs, headers, DRM keys, error payloads,
or credentials.
or credentials. Each rendered web or Embedded MPV application captures the
nullable binding, both opaque tokens, and its live/VOD flag. A time update can
change the resume position only while that exact capture still owns the current
application, so a replaced source cannot repopulate cleared handoff state.
Selecting a built-in recommendation records the target, clears the diagnostic,
and installs a temporary local override ahead of the host override and saved
@@ -1,85 +1,100 @@
@for (binding of renderedBindings(); track binding.generation) {
@if (binding.target === 'videojs') {
@defer (on immediate) {
@if (vjsOptions) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@for (application of renderedApplications(); track application.token) {
@if (application.binding; as binding) {
@if (binding.target === 'videojs') {
@defer (on immediate) {
@if (vjsOptions) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (binding.target === 'html5') {
@defer (on immediate) {
@if (channel) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else if (binding.target === 'html5') {
@defer (on immediate) {
@if (channel) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (binding.target === 'artplayer') {
@defer (on immediate) {
@if (channel) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @else if (binding.target === 'artplayer') {
@defer (on immediate) {
@if (channel) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="effectiveStartTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div
class="web-player-defer-placeholder"
aria-hidden="true"
></div>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (application.embeddedMpv) {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
}
@if (selectedPlayer() === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[mediaTitle]="resolvedMediaTitle()"
[recordingFolder]="recordingFolder()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
@if (visiblePlaybackDiagnostic(); as issue) {
<app-playback-diagnostic-panel
[diagnostic]="issue"
@@ -320,6 +320,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
await fixture.whenStable();
fixture.detectChanges();
expect(html5().startTime()).toBe(48);
const sourceAOwnership = captureTimeUpdateOwnership();
setPlayback({
streamUrl: 'https://example.com/program-b.m3u8',
@@ -330,6 +331,13 @@ describe('WebPlayerViewComponent recovery integration', () => {
fixture.detectChanges();
expect(html5().startTime()).toBe(0);
deliverTimeUpdate({ currentTime: 79, duration: 120 }, sourceAOwnership);
component.retryPlayback();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(html5().startTime()).toBe(0);
html5().playbackIssue.emit(mediaIssue('html5', 'program-b.m3u8'));
fixture.detectChanges();
expect(playerActionIds()).toEqual([
@@ -338,6 +346,35 @@ describe('WebPlayerViewComponent recovery integration', () => {
]);
});
it('rejects a late live Embedded MPV time update after a same-key VOD source replaces it', async () => {
fixture.componentRef.setInput(
'playerOverride',
VideoPlayer.EmbeddedMpv
);
setPlayback({
streamUrl: 'https://example.com/live-program.m3u8',
isLive: true,
});
await render();
const sourceAOwnership = captureTimeUpdateOwnership();
setPlayback({
streamUrl: 'https://example.com/vod-program.m3u8',
isLive: false,
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
deliverTimeUpdate({ currentTime: 91, duration: 120 }, sourceAOwnership);
fixture.componentRef.setInput('playerOverride', VideoPlayer.VideoJs);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(vjs().startTime()).toBe(0);
});
it('preserves the latest VOD position across a same-source retry', async () => {
setPlayback({
streamUrl: 'https://example.com/retry-movie.m3u8',
@@ -543,13 +580,22 @@ describe('WebPlayerViewComponent recovery integration', () => {
};
const applicationToken = tokens.playbackApplicationToken?.();
const sourceRevisionToken = tokens.playbackSourceRevisionToken?.();
const ownership = captureTimeUpdateOwnership();
expect(applicationToken).toBeDefined();
expect(sourceRevisionToken).toBeDefined();
expect(typeof applicationToken).toBe('symbol');
expect(typeof sourceRevisionToken).toBe('symbol');
expect(Reflect.ownKeys(Object(applicationToken))).toEqual([]);
expect(Reflect.ownKeys(Object(sourceRevisionToken))).toEqual([]);
const inspected = `${String(applicationToken)} ${String(sourceRevisionToken)} ${JSON.stringify({ applicationToken, sourceRevisionToken })}`;
expect(Object.isFrozen(ownership)).toBe(true);
expect(Object.keys(ownership)).toEqual([
'binding',
'embeddedMpv',
'isLive',
'sourceRevision',
'token',
]);
const inspected = `${String(applicationToken)} ${String(sourceRevisionToken)} ${JSON.stringify({ applicationToken, sourceRevisionToken, ownership })}`;
for (const sentinel of sentinels) {
expect(inspected).not.toContain(sentinel);
}
@@ -1089,6 +1135,34 @@ describe('WebPlayerViewComponent recovery integration', () => {
) as NodeListOf<HTMLElement>
).map((element) => element.dataset['testId'] ?? '');
}
interface TestTimeUpdateOwnership {
readonly binding: unknown;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: symbol;
readonly token: symbol;
}
function captureTimeUpdateOwnership(): TestTimeUpdateOwnership {
const ownership = component.renderedApplications()[0];
expect(ownership).toBeDefined();
if (!ownership) {
throw new Error('Expected a rendered playback application');
}
return ownership;
}
function deliverTimeUpdate(
event: { readonly currentTime: number; readonly duration: number },
ownership: TestTimeUpdateOwnership
): void {
const handler = component.handleTimeUpdate as unknown as (
event: { readonly currentTime: number; readonly duration: number },
ownership: TestTimeUpdateOwnership
) => void;
handler.call(component, event, ownership);
}
});
function mediaIssue(
@@ -46,7 +46,11 @@ import {
PlaybackRecoverySession,
} from './playback-recovery-session';
import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff';
import { createWebPlayerApplicationState } from './web-player-application-state';
import {
createWebPlayerApplicationState,
type WebPlayerApplicationToken,
type WebPlayerSourceRevisionToken,
} from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import {
createWebPlayerRecommendations,
@@ -61,6 +65,14 @@ function resolveWebPlayerSharedControls(): boolean {
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
}
interface PlaybackApplicationOwnership {
readonly binding: PlaybackBinding | null;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: WebPlayerSourceRevisionToken;
readonly token: WebPlayerApplicationToken;
}
@Component({
selector: 'app-web-player-view',
templateUrl: './web-player-view.component.html',
@@ -189,9 +201,25 @@ export class WebPlayerViewComponent implements OnDestroy {
alternativeSourceCount: this.alternativeSources().length,
});
});
readonly renderedBindings = computed<readonly PlaybackBinding[]>(() => {
readonly renderedApplications = computed<
readonly PlaybackApplicationOwnership[]
>(() => {
const binding = this.activeBinding();
return binding ? [binding] : [];
const embeddedMpv =
this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding;
if (!binding && !embeddedMpv) {
return [];
}
return [
Object.freeze({
binding,
embeddedMpv,
isLive: this.resolvedIsLive(),
sourceRevision: this.playbackSourceRevisionToken(),
token: this.playbackApplicationToken(),
}),
];
});
constructor() {
@@ -269,8 +297,15 @@ export class WebPlayerViewComponent implements OnDestroy {
this.playbackFailed.emit(issue.code);
}
handleTimeUpdate(event: { currentTime: number; duration: number }): void {
this.recoverySession.recordTimeUpdate(event, this.resolvedIsLive());
handleTimeUpdate(
event: { currentTime: number; duration: number },
ownership: PlaybackApplicationOwnership
): void {
if (!this.ownsPlaybackApplication(ownership)) {
return;
}
this.recoverySession.recordTimeUpdate(event, ownership.isLive);
this.timeUpdate.emit(event);
}
@@ -313,4 +348,25 @@ export class WebPlayerViewComponent implements OnDestroy {
this.playbackDiagnostic.set(null);
}
}
private ownsPlaybackApplication(
ownership: PlaybackApplicationOwnership
): boolean {
if (
ownership.token !== this.playbackApplicationToken() ||
ownership.sourceRevision !== this.playbackSourceRevisionToken()
) {
return false;
}
if (ownership.binding) {
return (
!ownership.embeddedMpv &&
this.applicationHandoff.owns(ownership.binding, ownership.token)
);
}
return (
ownership.embeddedMpv &&
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
);
}
}