mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(playback): reject stale progress updates
This commit is contained in:
1 parent
349e19097a
commit
b6bf9a7bb7
6 files changed
+240
-86
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+75
-1
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user