fix(playback): tighten recently viewed confirmation per review

- Correlate by session key first: when both the deferred write and the
  confirmation carry a playbackSessionKey, only that is compared, so the
  same stream URL played in another playlist no longer records a failed
  attempt. URLs remain the fallback (portal writes, MPV/VLC sessions).
  The M3U radio player now receives the host's session key.
- Count only playing progress: engines report `playing` (not paused, not
  seeking) with each time update, so short seeks of paused media no
  longer confirm a view.
- Xtream: a write confirmed after a playlist switch still saves to its own
  playlist but no longer replaces the current playlist's recent list.
- Global live tab: a row confirmed after another row was selected still
  moves to the top of an open Recently Viewed list (only a disposed tab
  skips the notification).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:40:24 +02:00
1 parent 4b7a2449a5
commit 96f1707b2d
37 files changed
+462 -157

No files matched your search

@@ -50,7 +50,7 @@ describe('ExternalPlaybackService', () => {
it('commits deferred history once the external player has opened', () => {
const commit = jest.fn();
TestBed.inject(PlaybackHistoryGate).defer(
['https://example.com/video.m3u8'],
{ streamUrls: ['https://example.com/video.m3u8'] },
commit
);
@@ -64,7 +64,7 @@ describe('ExternalPlaybackService', () => {
it('does not commit history for a launch that failed', () => {
const commit = jest.fn();
TestBed.inject(PlaybackHistoryGate).defer(
['https://example.com/video.m3u8'],
{ streamUrls: ['https://example.com/video.m3u8'] },
commit
);
@@ -107,7 +107,7 @@ export class ExternalPlaybackService {
// MPV/VLC cannot report whether a live stream really plays, so a
// successful launch is what commits the deferred history write.
if (session.status === 'opened' || session.status === 'playing') {
this.historyGate.confirm([session.streamUrl]);
this.historyGate.confirm({ streamUrls: [session.streamUrl] });
}
const current = this.activeSession();
+24 -14
View File
@@ -1137,26 +1137,36 @@ its stream has really played, so a stream that fails straight away never
reaches history.
- Writers do not persist on selection. They hand the write to
`PlaybackHistoryGate` (`@iptvnator/services`) with `defer(keys, commit)`,
keyed by what the playback will be known by: the stream URL and, for M3U,
the host's `playbackSessionKey`. The Stalker resolver defers by the
resolved (possibly temporary) link; the persisted row still stores the
portal `cmd`, never that link. Writers capture the item and its playlist
when they defer, so navigating meanwhile cannot misfile it.
`PlaybackHistoryGate` (`@iptvnator/services`) with `defer(target, commit)`,
where the target is what the playback will be known by: the host's
`playbackSessionKey` (M3U) and/or stream URLs. The Stalker resolver defers
by the resolved (possibly temporary) link; the persisted row still stores
the portal `cmd`, never that link. Writers capture the item and its
playlist when they defer, so navigating meanwhile cannot misfile it; an
Xtream write confirmed after a playlist switch refreshes the store's recent
list only if its playlist is still current.
- Matching: when both the write and the confirmation carry a session key,
only the session key is compared — the same URL in two playlists must not
let playback in one record a failed attempt in the other. Stream URLs are
the fallback when either side has none.
- `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl`
and `playback.streamUrl` once the owned engine's reported position has
advanced by 2 seconds (`PlaybackProgressConfirmation`). Steps above
3 seconds (seeks, a resume jump, live-edge catch-up), stalls, pauses and
backwards jumps do not count; a report of a new stream never adds to the
progress of the previous one; an engine or format swap of the same stream
keeps its progress. The radio `AudioPlayerComponent` confirms its URL the
same way.
advanced by 2 seconds while playing (`PlaybackProgressConfirmation`).
Engines report `playing` (not paused, not seeking) with each time update,
so seeks of paused media do not count; neither do steps above 3 seconds
(seeks, a resume jump, live-edge catch-up), stalls or backwards jumps. A
report of a new stream never adds to the progress of the previous one; an
engine or format swap of the same stream keeps its progress. The radio
`AudioPlayerComponent` confirms the same way (with the host's session key
when given).
- MPV/VLC cannot report whether a live stream plays, so the Electron
`ExternalPlaybackService` confirms a session's `streamUrl` once it is
`opened` or `playing`; a launch that ends in `error` is not recorded. M3U
keeps recording on selection when MPV/VLC is the configured player.
- A confirmation commits every write deferred under any of its keys, once.
Unconfirmed writes are bounded (oldest dropped) and simply never commit.
- A confirmation commits every write matching it, once. Unconfirmed writes
are bounded (oldest dropped) and simply never commit. The global live tab
moves a confirmed row to the top of an open Recently Viewed list even if
another row was selected meanwhile.
- A committed write updates the source while the item keeps playing. Hosts
must not hand the player a new but identical playback for it: the M3U
host's `embeddedPlayback` also reads the playlist meta, so it is compared
@@ -177,7 +177,7 @@ describe('VideoPlayerComponent — recently viewed history', () => {
playlistsServiceMock.addM3uRecentlyViewed
).not.toHaveBeenCalled();
gate.confirm([component.playbackSessionKey()]);
gate.confirm({ sessionKey: component.playbackSessionKey() });
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith(
'playlist-1',
@@ -215,7 +215,7 @@ describe('VideoPlayerComponent — recently viewed history', () => {
name: 'Second TV',
});
gate.confirm(['http://localhost/second.m3u8']);
gate.confirm({ streamUrls: ['http://localhost/second.m3u8'] });
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes(
1
@@ -234,7 +234,7 @@ describe('VideoPlayerComponent — recently viewed history', () => {
playlistsServiceMock.addM3uRecentlyViewed
).not.toHaveBeenCalled();
gate.confirm([radio.url]);
gate.confirm({ streamUrls: [radio.url] });
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes(
1
@@ -106,10 +106,13 @@
<app-audio-player
[url]="activeChannel.url"
[icon]="
$safeNavigationMigration(activeChannel?.tvg?.logo)!
$safeNavigationMigration(
activeChannel?.tvg?.logo
)!
"
[channelName]="displayChannelName()"
[volume]="volume()"
[playbackSessionKey]="playbackSessionKey()"
(volumeChange)="onInlineVolumeChange($event)"
/>
} @else {
@@ -1230,7 +1230,10 @@ export class VideoPlayerComponent
}
this.historyGate.defer(
[this.playbackSessionKey(), channel.url],
{
sessionKey: this.playbackSessionKey(),
streamUrls: [channel.url],
},
record
);
}
@@ -97,6 +97,7 @@ export class StubAudioPlayerComponent {
readonly channelName = input('');
readonly channelLogo = input('');
readonly volume = input<number | null>(null);
readonly playbackSessionKey = input<string | null>(null);
readonly volumeChange = output<number>();
}
@@ -121,13 +121,20 @@ export function createUnifiedLiveSelection(options: {
});
};
/** Set by `dispose`: the host is gone and must not be notified. */
let disposed = false;
/**
* Runs once the row has really played. That is seconds after selection,
* so the user may have moved on — the write still happened, and an open
* Recently Viewed list must still move the row to the top.
*/
const recordLivePlayback = async (
item: UnifiedCollectionItem,
generation: number
item: UnifiedCollectionItem
): Promise<void> => {
try {
const updatedItem = await recentData.recordLivePlayback(item);
if (generation === options.generation.current()) {
if (!disposed) {
options.onItemPlayed(updatedItem);
}
} catch {
@@ -242,8 +249,8 @@ export function createUnifiedLiveSelection(options: {
// Selecting a channel is not watching it: the row moves to the
// top of Recently Viewed once its stream has really played.
historyGate.defer(
[detail.playback.streamUrl],
() => void recordLivePlayback(item, generation)
{ streamUrls: [detail.playback.streamUrl] },
() => void recordLivePlayback(item)
);
if (
@@ -278,6 +285,7 @@ export function createUnifiedLiveSelection(options: {
activate,
close,
dispose(): void {
disposed = true;
// Invalidate a playback continuation still awaiting its header
// IPC and drop any radio credentials owned by this tab.
options.generation.next();
@@ -163,7 +163,9 @@ describe('UnifiedLiveTabComponent', () => {
/** What the mounted player reports once the stream has really played. */
async function playStream(streamUrl: string): Promise<void> {
TestBed.inject(PlaybackHistoryGate).confirm([streamUrl]);
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: [streamUrl],
});
await fixture.whenStable();
}
@@ -9,7 +9,11 @@ import {
} from '@iptvnator/ui/playback';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import { ResizableDirective } from '@iptvnator/ui/components';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
PlaybackHistoryGate,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
PORTAL_PLAYER,
@@ -173,6 +177,49 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
}
);
it('moves a row that played to the top of Recently Viewed after another row was selected', async () => {
const first = buildM3uLiveItem();
const second: UnifiedCollectionItem = {
...first,
uid: 'm3u::pl-1::m3u-channel-2',
name: 'M3U Live 2',
channelId: 'm3u-channel-2',
tvgId: 'm3u-channel-2',
streamUrl: 'https://example.com/m3u-2.m3u8',
};
streamResolver.resolveM3uPlaybackDetail.mockImplementation(
async (item: UnifiedCollectionItem) => ({
epgMode: 'm3u',
playback: { streamUrl: item.streamUrl, title: item.name },
epgPrograms: [],
})
);
recentData.recordLivePlayback.mockImplementation(
async (item: UnifiedCollectionItem) => item
);
const played = jest.fn();
component.itemPlayed.subscribe(played);
fixture.componentRef.setInput('items', [first, second]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
const row = (name: string) =>
component
.channelsForList()
.find((channel) => channel.name === name)!;
await component.onChannelSelected(row('M3U Live'));
await component.onChannelSelected(row('M3U Live 2'));
// The first row's playback is confirmed only now, after the switch.
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: [first.streamUrl],
});
await fixture.whenStable();
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(first);
expect(played).toHaveBeenCalledWith(first);
});
it('keeps the current detail (and its fullscreen player) mounted while the next selection resolves', async () => {
const first = buildM3uLiveItem();
const second: UnifiedCollectionItem = {
@@ -19,7 +19,9 @@ import { withStalkerPlayer } from './with-stalker-player.feature';
/** What the player does once the resolved stream has really played. */
function confirmPlayback(playback: { streamUrl: string }): void {
TestBed.inject(PlaybackHistoryGate).confirm([playback.streamUrl]);
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: [playback.streamUrl],
});
}
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -152,7 +152,7 @@ export function withStalkerPlayer() {
cover,
title
);
historyGate.defer([streamUrl], () =>
historyGate.defer({ streamUrls: [streamUrl] }, () =>
persistRecentlyViewed(playlistId, {
...recentItem,
added_at: Date.now(),
@@ -1,6 +1,6 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { signalStore, withState } from '@ngrx/signals';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
@@ -16,6 +16,11 @@ jest.mock('@iptvnator/portal/shared/util', () => ({
}));
const TestRecentItemsStore = signalStore(withRecentItems());
/** Like `XtreamStore`: `withPortal` (current playlist) precedes the feature. */
const TestCurrentPlaylistStore = signalStore(
withState({ currentPlaylist: { id: 'playlist-2' } }),
withRecentItems()
);
describe('withRecentItems', () => {
const originalElectron = window.electron;
@@ -95,6 +100,7 @@ describe('withRecentItems', () => {
TestBed.configureTestingModule({
providers: [
TestRecentItemsStore,
TestCurrentPlaylistStore,
{
provide: DatabaseService,
useValue: databaseService,
@@ -157,6 +163,32 @@ describe('withRecentItems', () => {
]);
});
it('saves a late write to its playlist without replacing the current playlist list', async () => {
// Playback confirmed after the user moved on to playlist-2.
const currentStore = TestBed.inject(TestCurrentPlaylistStore);
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
xtream_id: 290,
});
currentStore.addRecentItem({
xtreamId: 290,
contentType: 'series',
playlist: signal({ id: 'playlist-1' }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1',
undefined
);
expect(dataSource.getRecentItems).not.toHaveBeenCalled();
expect(currentStore.recentItems()).toEqual([]);
});
it('uses the Xtream ID as the PWA recent key when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
@@ -50,6 +50,21 @@ function mapDbRecentItem(
};
}
/**
* Whether `playlistId` is the store's current playlist. `withPortal`, composed
* before this feature in `XtreamStore`, owns it; a store without one (a
* feature-only test store) treats every playlist as current.
*/
function isCurrentPlaylist(store: object, playlistId: string): boolean {
const currentPlaylist = (
store as {
currentPlaylist?: () => { id?: string } | null | undefined;
}
).currentPlaylist;
const currentId = currentPlaylist?.()?.id;
return !currentId || currentId === playlistId;
}
export const withRecentItems = function () {
const logger = createLogger('withRecentItems');
return signalStoreFeature(
@@ -123,6 +138,12 @@ export const withRecentItems = function () {
backdropUrl
);
// A write confirmed after the user switched
// playlists is saved to its own playlist,
// but the list in state is the current one's.
if (!isCurrentPlaylist(store, playlistId)) {
return;
}
// Reload after add/update so re-watched items
// immediately move to the top in recently-viewed.
const items =
@@ -569,9 +569,9 @@ describe('SerialDetailsComponent', () => {
);
// The series is a recent view only once the episode has played.
expect(addRecentItem).not.toHaveBeenCalled();
TestBed.inject(PlaybackHistoryGate).confirm([
'http://xtream.example/series/1001.mp4',
]);
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: ['http://xtream.example/series/1001.mp4'],
});
expect(addRecentItem).toHaveBeenCalledWith({
xtreamId: '103',
contentType: 'series',
@@ -163,9 +163,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(addRecentItem).not.toHaveBeenCalled();
TestBed.inject(PlaybackHistoryGate).confirm([
'https://example.com/route.mkv',
]);
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: ['https://example.com/route.mkv'],
});
expect(addRecentItem).toHaveBeenCalledTimes(1);
const [recentItem] = addRecentItem.mock.calls[0];
@@ -372,9 +372,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
contentType: 'vod',
},
});
TestBed.inject(PlaybackHistoryGate).confirm([
'https://example.com/alt.mkv',
]);
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: ['https://example.com/alt.mkv'],
});
expect(addRecentItem).toHaveBeenCalled();
});
@@ -26,7 +26,7 @@ export function injectXtreamRecentHistory(): (
return (streamUrl, request) => {
const playlist = signal(store.currentPlaylist()).asReadonly();
gate.defer([streamUrl], () =>
gate.defer({ streamUrls: [streamUrl] }, () =>
store.addRecentItem({ ...request, playlist })
);
};
@@ -7,13 +7,16 @@ describe('PlaybackHistoryGate', () => {
gate = new PlaybackHistoryGate();
});
it('holds a write until one of its keys is confirmed', () => {
it('holds a write until its playback is confirmed', () => {
const commit = jest.fn();
gate.defer(['http://stream/1', 'live:p1:c1'], commit);
gate.defer(
{ sessionKey: 'live:p1:c1', streamUrls: ['http://stream/1'] },
commit
);
expect(commit).not.toHaveBeenCalled();
gate.confirm(['live:p1:c1']);
gate.confirm({ sessionKey: 'live:p1:c1' });
expect(commit).toHaveBeenCalledTimes(1);
});
@@ -21,62 +24,104 @@ describe('PlaybackHistoryGate', () => {
it('never commits a write whose stream is not confirmed', () => {
const failed = jest.fn();
const played = jest.fn();
gate.defer(['http://stream/failed'], failed);
gate.defer(['http://stream/played'], played);
gate.defer({ streamUrls: ['http://stream/failed'] }, failed);
gate.defer({ streamUrls: ['http://stream/played'] }, played);
gate.confirm(['http://stream/played']);
gate.confirm({ streamUrls: ['http://stream/played'] });
expect(failed).not.toHaveBeenCalled();
expect(played).toHaveBeenCalledTimes(1);
});
it('does not let the same URL played in another playlist confirm a write', () => {
// Playlist A's attempt failed; the same stream then plays in B.
const failedInA = jest.fn();
const playedInB = jest.fn();
gate.defer(
{ sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] },
failedInA
);
gate.defer(
{ sessionKey: 'live:b:c9', streamUrls: ['http://shared/1'] },
playedInB
);
gate.confirm({
sessionKey: 'live:b:c9',
streamUrls: ['http://shared/1'],
});
expect(failedInA).not.toHaveBeenCalled();
expect(playedInB).toHaveBeenCalledTimes(1);
});
it('falls back to the stream URL when either side has no session key', () => {
const portalWrite = jest.fn();
const radioWrite = jest.fn();
gate.defer({ streamUrls: ['http://portal/tmp'] }, portalWrite);
gate.defer(
{ sessionKey: 'live:p1:radio', streamUrls: ['http://radio/1'] },
radioWrite
);
// A player with a session key; a player (MPV/VLC session) without.
gate.confirm({
sessionKey: 'live:portal:9',
streamUrls: ['http://portal/tmp'],
});
gate.confirm({ streamUrls: ['http://radio/1'] });
expect(portalWrite).toHaveBeenCalledTimes(1);
expect(radioWrite).toHaveBeenCalledTimes(1);
});
it('commits each confirmed write once', () => {
const commit = jest.fn();
gate.defer(['http://stream/1'], commit);
gate.defer({ streamUrls: ['http://stream/1'] }, commit);
gate.confirm(['http://stream/1']);
gate.confirm(['http://stream/1']);
gate.confirm({ streamUrls: ['http://stream/1'] });
gate.confirm({ streamUrls: ['http://stream/1'] });
expect(commit).toHaveBeenCalledTimes(1);
});
it('commits every writer deferred under the same key', () => {
it('commits every writer deferred for the same playback', () => {
const first = jest.fn();
const second = jest.fn();
gate.defer(['http://stream/1'], first);
gate.defer(['http://stream/1'], second);
gate.defer({ streamUrls: ['http://stream/1'] }, first);
gate.defer({ streamUrls: ['http://stream/1'] }, second);
gate.confirm(['http://stream/1']);
gate.confirm({ streamUrls: ['http://stream/1'] });
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it('commits immediately when a write has no usable key', () => {
it('commits immediately when a write has nothing to match on', () => {
const commit = jest.fn();
gate.defer([undefined, null, ' '], commit);
gate.defer({ sessionKey: ' ', streamUrls: [undefined, null] }, commit);
expect(commit).toHaveBeenCalledTimes(1);
});
it('ignores confirmations without usable keys', () => {
it('ignores confirmations with nothing to match on', () => {
const commit = jest.fn();
gate.defer(['http://stream/1'], commit);
gate.defer({ streamUrls: ['http://stream/1'] }, commit);
gate.confirm([undefined, '']);
gate.confirm({ streamUrls: [undefined, ''] });
expect(commit).not.toHaveBeenCalled();
});
it('drops the oldest unconfirmed writes beyond its bound', () => {
const oldest = jest.fn();
gate.defer(['http://stream/oldest'], oldest);
gate.defer({ streamUrls: ['http://stream/oldest'] }, oldest);
for (let index = 0; index < 20; index += 1) {
gate.defer([`http://stream/${index}`], jest.fn());
gate.defer({ streamUrls: [`http://stream/${index}`] }, jest.fn());
}
gate.confirm(['http://stream/oldest']);
gate.confirm({ streamUrls: ['http://stream/oldest'] });
expect(oldest).not.toHaveBeenCalled();
});
@@ -89,10 +134,10 @@ describe('PlaybackHistoryGate', () => {
throw new Error('db down');
});
const healthy = jest.fn();
gate.defer(['http://stream/1'], failing);
gate.defer(['http://stream/1'], healthy);
gate.defer({ streamUrls: ['http://stream/1'] }, failing);
gate.defer({ streamUrls: ['http://stream/1'] }, healthy);
gate.confirm(['http://stream/1']);
gate.confirm({ streamUrls: ['http://stream/1'] });
expect(healthy).toHaveBeenCalledTimes(1);
consoleError.mockRestore();
@@ -1,10 +1,23 @@
import { Injectable } from '@angular/core';
/** Keys a history write can be confirmed by: stream URLs or session keys. */
export type PlaybackHistoryKeys = readonly (string | null | undefined)[];
/** What a playback is known by, for correlating a write with its playback. */
export interface PlaybackHistoryTarget {
/**
* The playing host's `playbackSessionKey` (source and content scoped).
* When both sides carry one, it is the only thing that is compared.
*/
readonly sessionKey?: string | null;
/** Stream URLs; the fallback when either side has no session key. */
readonly streamUrls?: readonly (string | null | undefined)[];
}
interface NormalizedTarget {
readonly sessionKey: string | null;
readonly streamUrls: ReadonlySet<string>;
}
interface PendingHistoryWrite {
readonly keys: ReadonlySet<string>;
readonly target: NormalizedTarget;
readonly commit: () => void;
}
@@ -20,56 +33,74 @@ const MAX_PENDING_HISTORY_WRITES = 20;
* The code that resolves a channel or movie is not the code that plays it:
* a Stalker link is resolved in the store, played by whichever view mounts
* the player, or handed to MPV/VLC. Writers therefore `defer` the write under
* the keys the playback will be known by (its stream URL, or a playback
* session key), and whoever observes the playback `confirm`s those keys:
* the inline players once the stream has advanced for a couple of seconds,
* and the external-player session as soon as MPV/VLC is launched. A stream
* that fails before that point never reaches history or the dashboard hero.
* what the playback will be known by, and whoever observes the playback
* `confirm`s it: the inline players once the stream has advanced for a
* couple of seconds, and the external-player session as soon as MPV/VLC is
* launched. A stream that fails before that point never reaches history or
* the dashboard hero.
*
* Several writers may defer under the same key; one confirmation commits
* all of them. A write whose keys are all empty cannot be confirmed and is
* A session key is the stronger correlation: the same stream URL can sit in
* two playlists, and playing it in one must not record a failed attempt in
* the other. Stream URLs only match when either side has no session key.
* Several writers may defer for the same playback; one confirmation commits
* all of them. A write with nothing to match on cannot be confirmed and is
* committed immediately, as before this gate existed.
*/
@Injectable({ providedIn: 'root' })
export class PlaybackHistoryGate {
private pending: PendingHistoryWrite[] = [];
defer(keys: PlaybackHistoryKeys, commit: () => void): void {
const normalized = normalizeKeys(keys);
if (normalized.size === 0) {
defer(target: PlaybackHistoryTarget, commit: () => void): void {
const normalized = normalizeTarget(target);
if (!normalized.sessionKey && normalized.streamUrls.size === 0) {
runCommit(commit);
return;
}
this.pending.push({ keys: normalized, commit });
this.pending.push({ target: normalized, commit });
if (this.pending.length > MAX_PENDING_HISTORY_WRITES) {
this.pending.shift();
}
}
confirm(keys: PlaybackHistoryKeys): void {
const normalized = normalizeKeys(keys);
if (normalized.size === 0 || this.pending.length === 0) {
return;
}
confirm(target: PlaybackHistoryTarget): void {
const confirmed = normalizeTarget(target);
const matched: PendingHistoryWrite[] = [];
const remaining: PendingHistoryWrite[] = [];
for (const write of this.pending) {
const matches = [...write.keys].some((key) => normalized.has(key));
(matches ? matched : remaining).push(write);
(matchesTarget(write.target, confirmed) ? matched : remaining).push(
write
);
}
this.pending = remaining;
matched.forEach((write) => runCommit(write.commit));
}
}
function normalizeKeys(keys: PlaybackHistoryKeys): ReadonlySet<string> {
return new Set(
keys
.map((key) => key?.trim() ?? '')
.filter((key): key is string => key.length > 0)
);
function matchesTarget(
write: NormalizedTarget,
confirmed: NormalizedTarget
): boolean {
if (write.sessionKey && confirmed.sessionKey) {
return write.sessionKey === confirmed.sessionKey;
}
return [...write.streamUrls].some((url) => confirmed.streamUrls.has(url));
}
function normalizeTarget(target: PlaybackHistoryTarget): NormalizedTarget {
return {
sessionKey: normalizeKey(target.sessionKey),
streamUrls: new Set(
(target.streamUrls ?? [])
.map(normalizeKey)
.filter((url): url is string => url !== null)
),
};
}
function normalizeKey(key: string | null | undefined): string | null {
const trimmed = key?.trim() ?? '';
return trimmed.length > 0 ? trimmed : null;
}
function runCommit(commit: () => void): void {
@@ -29,7 +29,9 @@ describe('ArtPlayerVideoSession', () => {
player.currentTime = 45;
player.duration = 120;
player.emit('video:timeupdate');
expect(times).toEqual([{ currentTime: 45, duration: 120 }]);
expect(times).toEqual([
{ currentTime: 45, duration: 120, playing: false },
]);
Object.defineProperty(player.video, 'error', {
configurable: true,
@@ -70,7 +72,7 @@ describe('ArtPlayerVideoSession', () => {
it('uses the source-resolved duration for time updates', () => {
const player = createPlayer();
const times: Array<{ currentTime: number; duration: number }> = [];
const times: unknown[] = [];
player.currentTime = 45;
player.duration = Number.POSITIVE_INFINITY;
const session = new ArtPlayerVideoSession({
@@ -87,7 +89,9 @@ describe('ArtPlayerVideoSession', () => {
player.emit('video:timeupdate');
expect(times).toEqual([{ currentTime: 45, duration: 135 }]);
expect(times).toEqual([
{ currentTime: 45, duration: 135, playing: false },
]);
});
it('removes native and ArtPlayer listeners exactly on destroy', () => {
@@ -5,6 +5,7 @@ import {
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
export interface ArtPlayerVideoSessionConfig {
player: Artplayer;
@@ -13,7 +14,7 @@ export interface ArtPlayerVideoSessionConfig {
getDuration: () => number;
persistSharedVolume: boolean;
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitTimeUpdate: (value: PlayerTimeUpdate) => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
@@ -71,6 +72,7 @@ export class ArtPlayerVideoSession {
this.config.emitTimeUpdate({
currentTime: player.currentTime,
duration: this.config.getDuration(),
playing: !player.video.paused && !player.video.seeking,
});
};
@@ -34,6 +34,7 @@ import {
} from './art-player-setup';
import { ArtPlayerSourceSession } from './art-player-source-session';
import { ArtPlayerVideoSession } from './art-player-video-session';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
const debugArtPlayer = createDevLogger('ArtPlayer');
@@ -62,10 +63,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly timeUpdate = output<PlayerTimeUpdate>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
@@ -181,9 +181,13 @@ describe('AudioPlayerComponent', () => {
const audio = createComponent('https://example.com/station.mp3');
const commit = jest.fn();
TestBed.inject(PlaybackHistoryGate).defer(
['https://example.com/station.mp3'],
{ streamUrls: ['https://example.com/station.mp3'] },
commit
);
Object.defineProperty(audio, 'paused', {
configurable: true,
value: false,
});
const playTo = (position: number) => {
Object.defineProperty(audio, 'currentTime', {
configurable: true,
@@ -204,7 +208,7 @@ describe('AudioPlayerComponent', () => {
const audio = createComponent('https://example.com/dead.mp3');
const commit = jest.fn();
TestBed.inject(PlaybackHistoryGate).defer(
['https://example.com/dead.mp3'],
{ streamUrls: ['https://example.com/dead.mp3'] },
commit
);
@@ -183,6 +183,8 @@ export class AudioPlayerComponent {
readonly channelName = input<string>('');
readonly externalVolume = input<number | null>(null, { alias: 'volume' });
readonly dispatchAdjacentChannelAction = input(true);
/** The host's playback session key, for the history confirmation. */
readonly playbackSessionKey = input<string | null>(null);
readonly channelSwitchRequested = output<'next' | 'previous'>();
readonly volumeChange = output<number>();
@@ -208,7 +210,10 @@ export class AudioPlayerComponent {
/** Commits the deferred "recently viewed" write once the station plays. */
private readonly historyConfirmation = new PlaybackHistoryConfirmation({
gate: inject(PlaybackHistoryGate),
keys: () => [this.url()],
target: () => ({
sessionKey: this.playbackSessionKey(),
streamUrls: [this.url()],
}),
});
constructor() {
@@ -282,7 +287,12 @@ export class AudioPlayerComponent {
onTimeUpdate(): void {
const audio = this.audioRef()?.nativeElement;
if (audio) this.historyConfirmation.record(audio.currentTime);
if (audio) {
this.historyConfirmation.record(
audio.currentTime,
!audio.paused && !audio.seeking
);
}
}
play() {
@@ -53,6 +53,7 @@ import {
subtitleTrackLabel,
volumeIcon,
} from './embedded-mpv-format.utils';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
@@ -85,10 +86,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly timeUpdate = output<PlayerTimeUpdate>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -563,6 +561,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
this.timeUpdate.emit({
currentTime: session.positionSeconds,
duration: session.durationSeconds ?? 0,
playing: session.status === 'playing',
});
this.legacyInteractions.scheduleControlsHide();
});
@@ -4,8 +4,11 @@ describe('HtmlVideoElementSession', () => {
it('owns native video events and detaches them idempotently', () => {
const video = document.createElement('video');
const playbackIssues: unknown[] = [];
const timeUpdates: Array<{ currentTime: number; duration: number }> =
[];
const timeUpdates: Array<{
currentTime: number;
duration: number;
playing?: boolean;
}> = [];
const playbackEnded = jest.fn();
const removeEventListener = jest.spyOn(video, 'removeEventListener');
const session = new HtmlVideoElementSession({
@@ -40,7 +43,20 @@ describe('HtmlVideoElementSession', () => {
video.dispatchEvent(new Event('ended'));
expect(video.currentTime).toBe(18);
expect(timeUpdates).toEqual([{ currentTime: 18, duration: 90 }]);
// A paused element's position moves too (a seek): not playing.
expect(timeUpdates).toEqual([
{ currentTime: 18, duration: 90, playing: false },
]);
Object.defineProperty(video, 'paused', {
configurable: true,
value: false,
});
video.dispatchEvent(new Event('timeupdate'));
expect(timeUpdates.at(-1)).toEqual({
currentTime: 18,
duration: 90,
playing: true,
});
expect(playbackIssues[0]).toEqual(
expect.objectContaining({
code: 'unsupported-container',
@@ -5,6 +5,7 @@ import {
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
@@ -13,7 +14,7 @@ export interface HtmlVideoElementSessionConfig {
getChannelUrl: () => string | undefined;
getStartTime: () => number;
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitTimeUpdate: (value: PlayerTimeUpdate) => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
@@ -62,6 +63,7 @@ export class HtmlVideoElementSession {
this.config.emitTimeUpdate({
currentTime: video.currentTime,
duration: video.duration,
playing: !video.paused && !video.seeking,
});
};
@@ -53,6 +53,7 @@ import {
HtmlVideoPlayerControlsBridge,
type HtmlVideoControlsSource,
} from './html-video-player-controls.bridge';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
@@ -83,10 +84,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
@Output() timeUpdate = new EventEmitter<PlayerTimeUpdate>();
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() playbackStarted = new EventEmitter<void>();
@@ -1,8 +1,16 @@
import type { PlaybackHistoryKeys } from '@iptvnator/services';
import type { PlaybackHistoryTarget } from '@iptvnator/services';
import { PlaybackHistoryConfirmation } from './playback-history-confirmation';
describe('PlaybackHistoryConfirmation', () => {
let keys: PlaybackHistoryKeys;
const streamA: PlaybackHistoryTarget = {
sessionKey: 'live:p1:a',
streamUrls: ['http://stream/a'],
};
const streamB: PlaybackHistoryTarget = {
sessionKey: 'live:p1:b',
streamUrls: ['http://stream/b'],
};
let target: PlaybackHistoryTarget;
let revision: symbol;
let confirm: jest.Mock;
let confirmation: PlaybackHistoryConfirmation;
@@ -11,39 +19,39 @@ describe('PlaybackHistoryConfirmation', () => {
positions.forEach((position) => confirmation.record(position));
beforeEach(() => {
keys = ['live:p1:a', 'http://stream/a'];
target = streamA;
revision = Symbol('first');
confirm = jest.fn();
confirmation = new PlaybackHistoryConfirmation({
gate: { confirm },
keys: () => keys,
target: () => target,
sourceRevision: () => revision,
});
});
it('confirms the keys of the stream that played', () => {
it('confirms what played', () => {
play(0, 1, 2);
expect(confirm).toHaveBeenCalledWith(['live:p1:a', 'http://stream/a']);
expect(confirm).toHaveBeenCalledWith(streamA);
});
it('does not credit a new stream with the previous stream progress', () => {
play(0, 1.5);
keys = ['live:p1:b', 'http://stream/b'];
target = streamB;
play(0, 0.5);
expect(confirm).not.toHaveBeenCalled();
play(1, 2);
expect(confirm).toHaveBeenCalledTimes(1);
expect(confirm).toHaveBeenCalledWith(['live:p1:b', 'http://stream/b']);
expect(confirm).toHaveBeenCalledWith(streamB);
});
it('confirms a returning stream again after another one played', () => {
play(0, 1, 2);
keys = ['live:p1:b', 'http://stream/b'];
target = streamB;
play(0, 1, 2);
keys = ['live:p1:a', 'http://stream/a'];
target = streamA;
play(0, 1, 2);
expect(confirm).toHaveBeenCalledTimes(3);
@@ -56,4 +64,12 @@ describe('PlaybackHistoryConfirmation', () => {
expect(confirm).toHaveBeenCalledTimes(1);
});
it('ignores position changes reported while not playing', () => {
confirmation.record(0, false);
confirmation.record(1, false);
confirmation.record(2, false);
expect(confirm).not.toHaveBeenCalled();
});
});
@@ -1,13 +1,13 @@
import type {
PlaybackHistoryGate,
PlaybackHistoryKeys,
PlaybackHistoryTarget,
} from '@iptvnator/services';
import { PlaybackProgressConfirmation } from './playback-progress-confirmation';
export interface PlaybackHistoryConfirmationOptions {
readonly gate: Pick<PlaybackHistoryGate, 'confirm'>;
/** Keys of what is playing now: session key and/or stream URLs. */
readonly keys: () => PlaybackHistoryKeys;
/** What is playing now: its session key and/or stream URLs. */
readonly target: () => PlaybackHistoryTarget;
/**
* Engine/source generation within the same keys (engine switch, live
* format fallback, reload). A change restarts the position clock but
@@ -26,13 +26,14 @@ export class PlaybackHistoryConfirmation {
private identity: string | null = null;
private sourceRevision: unknown = null;
private readonly progress = new PlaybackProgressConfirmation(() =>
this.options.gate.confirm(this.options.keys())
this.options.gate.confirm(this.options.target())
);
constructor(private readonly options: PlaybackHistoryConfirmationOptions) {}
record(position: number): void {
const identity = JSON.stringify(this.options.keys());
/** @param playing see {@link PlaybackProgressConfirmation.record}. */
record(position: number, playing?: boolean): void {
const identity = JSON.stringify(this.options.target());
const sourceRevision = this.options.sourceRevision?.() ?? null;
if (identity !== this.identity) {
this.identity = identity;
@@ -43,6 +44,6 @@ export class PlaybackHistoryConfirmation {
this.progress.rebase();
}
this.progress.record(position);
this.progress.record(position, playing);
}
}
@@ -45,6 +45,28 @@ describe('PlaybackProgressConfirmation', () => {
expect(onConfirmed).not.toHaveBeenCalled();
});
it('does not count short seeks of paused media', () => {
confirmation.record(0, false);
confirmation.record(1, false);
confirmation.record(2, false);
confirmation.record(2.5, false);
expect(onConfirmed).not.toHaveBeenCalled();
});
it('counts only the steps reported while playing', () => {
confirmation.record(0, true);
confirmation.record(1.5, true);
// Paused, then seeked a second ahead: not watched.
confirmation.record(2.5, false);
confirmation.record(2.9, true);
expect(onConfirmed).not.toHaveBeenCalled();
confirmation.record(3.5, true);
expect(onConfirmed).toHaveBeenCalledTimes(1);
});
it('does not count backwards jumps', () => {
play(5, 4, 3, 2, 1);
@@ -12,7 +12,8 @@ const MAX_PLAYBACK_STEP_SECONDS = 3;
* Turns an engine's position reports into a single "this stream plays"
* signal. `playing` alone is not enough: a broken stream can fire it and
* stall or error a moment later, so only position that really advanced
* counts. Pauses, stalls, seeks and backwards jumps add nothing.
* counts. Pauses, stalls, seeks and backwards jumps add nothing — including
* short seeks of paused media, which engines report as `playing: false`.
*/
export class PlaybackProgressConfirmation {
private lastPosition: number | null = null;
@@ -36,14 +37,18 @@ export class PlaybackProgressConfirmation {
this.lastPosition = null;
}
record(position: number): void {
/**
* @param playing whether the media was actually playing (not paused, not
* seeking) at this report; engines that cannot tell leave it undefined.
*/
record(position: number, playing?: boolean): void {
if (this.confirmed || !Number.isFinite(position)) {
return;
}
const previous = this.lastPosition;
this.lastPosition = position;
if (previous === null) {
if (previous === null || playing === false) {
return;
}
@@ -0,0 +1,12 @@
/** Position report an engine emits on every media time update. */
export interface PlayerTimeUpdate {
readonly currentTime: number;
readonly duration: number;
/**
* Whether the media was actually playing — not paused and not seeking.
* Seeks of paused media also move the position; only playing reports
* count towards the "really played" history confirmation. Left undefined
* by an engine that cannot tell.
*/
readonly playing?: boolean;
}
@@ -50,6 +50,7 @@ import {
getVideoJsTechVideo,
} from './vjs-player.types';
import { VjsVideoElementSession } from './vjs-video-element-session';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
const debugVjsPlayer = createDevLogger('VjsPlayer');
@@ -80,10 +81,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly timeUpdate = output<PlayerTimeUpdate>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
@@ -280,6 +278,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.timeUpdate.emit({
currentTime: this.player.currentTime() ?? 0,
duration: this.player.duration() ?? 0,
playing: !this.player.paused() && !this.player.seeking(),
});
};
@@ -118,7 +118,7 @@ describe('WebPlayerViewComponent playback history', () => {
it('records a channel once its stream has played for two seconds', () => {
const commit = jest.fn();
gate.defer(['live:p1:a'], commit);
gate.defer({ sessionKey: 'live:p1:a' }, commit);
playTo(0, 1);
expect(commit).not.toHaveBeenCalled();
@@ -129,7 +129,7 @@ describe('WebPlayerViewComponent playback history', () => {
it('confirms by stream URL for writers that only know the link', () => {
const commit = jest.fn();
gate.defer(['https://example.com/a'], commit);
gate.defer({ streamUrls: ['https://example.com/a'] }, commit);
playTo(0, 1, 2);
@@ -138,7 +138,7 @@ describe('WebPlayerViewComponent playback history', () => {
it('does not record a channel whose stream fails before playing', () => {
const commit = jest.fn();
gate.defer(['live:p1:a'], commit);
gate.defer({ sessionKey: 'live:p1:a' }, commit);
playTo(0);
vjs().playbackIssue.emit({
@@ -156,13 +156,24 @@ describe('WebPlayerViewComponent playback history', () => {
expect(commit).not.toHaveBeenCalled();
});
it('does not count seeks of paused media as watched time', () => {
const commit = jest.fn();
gate.defer({ sessionKey: 'live:p1:a' }, commit);
[0, 1, 2, 3].forEach((currentTime) =>
vjs().timeUpdate.emit({ currentTime, duration: 0, playing: false })
);
expect(commit).not.toHaveBeenCalled();
});
it('does not credit the next channel with the previous one', async () => {
const next = jest.fn();
playTo(0, 1.5);
fixture.componentRef.setInput('streamUrl', 'https://example.com/b');
fixture.componentRef.setInput('playbackSessionKey', 'live:p1:b');
gate.defer(['live:p1:b'], next);
gate.defer({ sessionKey: 'live:p1:b' }, next);
await render();
playTo(0, 0.5);
@@ -41,6 +41,7 @@ import { FullscreenChannelPanelComponent } from '../fullscreen-channel-panel/ful
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
import {
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
@@ -221,11 +222,10 @@ export class WebPlayerViewComponent implements OnDestroy {
/** Commits deferred "recently viewed" writes once this stream plays. */
private readonly historyConfirmation = new PlaybackHistoryConfirmation({
gate: inject(PlaybackHistoryGate),
keys: () => [
this.playbackSessionKey(),
this.streamUrl(),
this.playback()?.streamUrl,
],
target: () => ({
sessionKey: this.playbackSessionKey(),
streamUrls: [this.streamUrl(), this.playback()?.streamUrl],
}),
sourceRevision: () => this.playbackSourceRevisionToken(),
});
readonly playbackExternallyTransferable = computed(() =>
@@ -370,7 +370,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
handleTimeUpdate(
event: { currentTime: number; duration: number },
event: PlayerTimeUpdate,
ownership: PlaybackApplicationOwnership
): void {
if (
@@ -392,7 +392,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
this.recoverySession.recordTimeUpdate(event, ownership.isLive);
this.historyConfirmation.record(event.currentTime);
this.historyConfirmation.record(event.currentTime, event.playing);
this.timeUpdate.emit(event);
}
@@ -5,6 +5,7 @@ import type {
RecordingStartMetadata,
RecordingStoppedEvent,
} from '@iptvnator/shared/interfaces';
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
/**
* Player stand-ins for WebPlayerViewComponent specs. They mirror the real
@@ -25,7 +26,7 @@ export class StubVjsPlayerComponent {
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly timeUpdate = output<PlayerTimeUpdate>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
@@ -47,7 +48,7 @@ export class StubHtmlVideoPlayerComponent {
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly timeUpdate = output<PlayerTimeUpdate>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
@@ -69,7 +70,7 @@ export class StubArtPlayerComponent {
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly timeUpdate = output<PlayerTimeUpdate>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
@@ -98,7 +99,7 @@ export class StubEmbeddedMpvPlayerComponent {
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly timeUpdate = output<PlayerTimeUpdate>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();