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
+ );
+ }
}