From b6bf9a7bb7c3cf588cce91cd1b81355b97d9a701 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 4 Aug 2026 19:38:27 +0200 Subject: [PATCH] fix(playback): reject stale progress updates --- AGENTS.md | 7 +- CLAUDE.md | 7 +- docs/architecture/embedded-inline-playback.md | 5 +- .../web-player-view.component.html | 165 ++++++++++-------- ...web-player-view.component.recovery.spec.ts | 76 +++++++- .../web-player-view.component.ts | 66 ++++++- 6 files changed, 240 insertions(+), 86 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 62e0ba343..2ab274ff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index cb4f0f126..b41c8bc2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 4c7e035f1..643188099 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 54478bf3f..d42b684fa 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -1,85 +1,100 @@ -@for (binding of renderedBindings(); track binding.generation) { - @if (binding.target === 'videojs') { - @defer (on immediate) { - @if (vjsOptions) { - +@for (application of renderedApplications(); track application.token) { + @if (application.binding; as binding) { + @if (binding.target === 'videojs') { + @defer (on immediate) { + @if (vjsOptions) { + + } + } @placeholder { + } - } @placeholder { - - } - } @else if (binding.target === 'html5') { - @defer (on immediate) { - @if (channel) { - + } @else if (binding.target === 'html5') { + @defer (on immediate) { + @if (channel) { + + } + } @placeholder { + } - } @placeholder { - - } - } @else if (binding.target === 'artplayer') { - @defer (on immediate) { - @if (channel) { - + } @else if (binding.target === 'artplayer') { + @defer (on immediate) { + @if (channel) { + + } + } @placeholder { + } - } @placeholder { - } + } @else if (application.embeddedMpv) { + } } -@if (selectedPlayer() === 'embedded-mpv') { - -} - @if (visiblePlaybackDiagnostic(); as issue) { { 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 ).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( diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 2e1b06fbd..7ecf1c092 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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 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 + ); + } }