feat(playback): record recently viewed only after the stream plays

A channel, movie or episode used to enter Recently Viewed (and the
dashboard's Continue Watching hero) the moment it was selected or its
link was resolved, so streams that failed straight away cluttered the
history.

Writers now defer the write to a root PlaybackHistoryGate, keyed by the
stream URL and/or the playback session key. The inline players confirm
those keys once the owned engine's position has advanced by two seconds
(seeks, stalls, pauses and a previous stream's progress do not count),
the radio player does the same, and a launched MPV/VLC session confirms
on `opened`/`playing`. M3U with MPV/VLC configured keeps recording on
selection. Covers M3U (live, radio, movie detail), Stalker (live, radio,
VOD, series), Xtream VOD and series, and the global live collection.

The M3U host's embeddedPlayback is now compared by value: the history
write updates the playlist meta mid-playback, and a new but identical
playback object remounted the engine and restarted the stream.

E2E flows that relied on recording-on-click now play local fixtures
(HLS/TS/WebM routed in place of unreachable or public streams) and wait
for confirmed playback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:23:00 +02:00
1 parent 254d1fd922
commit 4b7a2449a5
37 files changed
+1634 -118

No files matched your search

@@ -0,0 +1,6 @@
---
type: feature
area: playback
---
Channels, movies and episodes now join Recently Viewed and the dashboard's Continue Watching hero only after they have actually played for a couple of seconds, so streams that fail right away no longer clutter your history.
@@ -31,6 +31,7 @@ import {
waitForM3uCatalog,
workspaceRoot,
} from './electron-test-fixtures';
import { startAndConfirmPlayback } from './playable-stream-fixture';
/**
* DASH + ClearKey playback in the real Electron runtime — the only automated
@@ -541,7 +542,8 @@ for (const configuredPlayer of ['videojs', 'mpv', 'artplayer']) {
buildDashPlaylist(fixtureServer.origin)
);
const channel = channelItemByTitle(page, 'ClearKey DASH').first();
await channel.click();
// Recent history records the channel once it has really played.
await startAndConfirmPlayback(page, () => channel.click());
await channel.locator('.favorite-button').click();
await expect(
channel.locator('.favorite-button mat-icon')
@@ -25,6 +25,10 @@ import {
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
test.describe('Dashboard Activation', () => {
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
@@ -76,6 +80,7 @@ test.describe('Dashboard Activation', () => {
return new RegExp(titles.join('|'));
};
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await addXtreamPortal(app.mainWindow, {
@@ -99,7 +104,10 @@ test.describe('Dashboard Activation', () => {
);
const movieTitle = await clickFirstGridListCard(app.mainWindow);
await addCurrentDetailToFavorites(app.mainWindow);
await playCurrentDetail(app.mainWindow);
// Recorded as recently viewed once it has really played.
await startAndConfirmPlayback(app.mainWindow, () =>
playCurrentDetail(app.mainWindow)
);
await goBackFromDetail(app.mainWindow);
await app.mainWindow
@@ -111,7 +119,9 @@ test.describe('Dashboard Activation', () => {
);
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
await addCurrentDetailToFavorites(app.mainWindow);
await playFirstSeriesEpisode(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playFirstSeriesEpisode(app.mainWindow)
);
await goToDashboard(app.mainWindow);
@@ -176,11 +186,13 @@ test.describe('Dashboard Activation', () => {
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
liveTitle
).click();
await startAndConfirmPlayback(app.mainWindow, () =>
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
liveTitle
).click()
);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/favorites$/
);
@@ -20,6 +20,10 @@ import {
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import { fetchXtreamLiveFixture } from './portal-mock-fixtures';
import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
/**
* Issue #1458, second report: "all channels disappear after clearing the
@@ -136,6 +140,7 @@ test.describe('Live channel rail collapse (#1458)', () => {
CHANNELS
);
const app: LaunchedElectronApp = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
@@ -143,9 +148,12 @@ test.describe('Live channel rail collapse (#1458)', () => {
const playlistId = playlistIdFromUrl(app.mainWindow);
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
await channelItemByTitle(app.mainWindow, 'Channel Alpha')
.first()
.click();
// History records a channel only once it has really played.
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, 'Channel Alpha')
.first()
.click()
);
await openPlaylistRecent(app.mainWindow);
await expect(
@@ -0,0 +1,159 @@
import { expect, type Page, type Route } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { xtreamMockServer } from './electron-test-fixtures';
/**
* Local, deterministic media for flows that must really play.
*
* A channel, movie or episode is only recorded as recently viewed once its
* stream has advanced for two seconds, so history tests can no longer use
* unreachable URLs or the public HLS streams the portal mocks redirect to.
* These routes answer those requests from the renderer with fixtures that
* the Electron player actually decodes:
* - `.m3u8` → a one-segment HLS playlist over the H.264/AAC MPEG-TS clip;
* - `.ts` → that clip (6 s) directly;
* - anything else (mp4, mkv, avi, webm) → the VP8 WebM clip (30 s), which
* Chromium sniffs from its bytes regardless of the declared type.
*/
const SEGMENT_URL = 'https://playable-stream-fixture.test/segment.ts';
const transportStream = readFileSync(
join(__dirname, '../../xtream-mock-server/src/fixtures/live.mpegts')
);
const webmClip = readFileSync(
join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm')
);
const hlsPlaylist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:6',
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXTINF:6,',
SEGMENT_URL,
'#EXT-X-ENDLIST',
'',
].join('\n');
/** Hosts whose streams tests play: fixtures, portal mocks, mock redirects. */
const PLAYABLE_STREAM_HOSTS = [
'https://streams.example.test',
'https://playable-stream-fixture.test',
// Stalker mock `create_link` answers and the Xtream mock redirect target.
'https://test-streams.mux.dev',
'https://devstreaming-cdn.apple.com',
'https://playertest.longtailvideo.com',
];
const XTREAM_MEDIA_PATH = /^\/(live|movie|series)\//;
export async function routePlayableStreams(page: Page): Promise<void> {
await page.route(
(url) =>
PLAYABLE_STREAM_HOSTS.includes(url.origin) ||
(url.origin === new URL(xtreamMockServer).origin &&
XTREAM_MEDIA_PATH.test(url.pathname)),
(route) => fulfillPlayableStream(route)
);
}
/**
* Runs `start` and waits until a media element it brought up has played for
* `seconds` of real stream time — longer than the two seconds after which
* the app records the item. Elements already on the page are ignored, so a
* still-mounted previous player cannot satisfy the wait.
*/
export async function startAndConfirmPlayback(
page: Page,
start: () => Promise<void>,
seconds = 2.5
): Promise<void> {
await page.evaluate(() =>
document
.querySelectorAll('video, audio')
.forEach((media) => media.setAttribute('data-e2e-previous', ''))
);
await start();
await expect
.poll(
() =>
page.evaluate((minimum) => {
const starts = ((
window as unknown as {
__e2eMediaStarts?: WeakMap<Element, number>;
}
).__e2eMediaStarts ??= new WeakMap());
return [
...document.querySelectorAll<HTMLMediaElement>(
'video:not([data-e2e-previous]), audio:not([data-e2e-previous])'
),
].some((media) => {
if (media.paused || media.readyState < 2) {
return false;
}
if (!starts.has(media)) {
starts.set(media, media.currentTime);
}
return (
media.currentTime - (starts.get(media) ?? 0) >=
minimum
);
});
}, seconds),
{ timeout: 45_000 }
)
.toBe(true);
}
async function fulfillPlayableStream(route: Route): Promise<void> {
const pathname = new URL(route.request().url()).pathname;
if (pathname.endsWith('.m3u8')) {
await route.fulfill({
status: 200,
contentType: 'application/vnd.apple.mpegurl',
body: hlsPlaylist,
});
return;
}
if (pathname.endsWith('.ts')) {
await route.fulfill({
status: 200,
contentType: 'video/mp2t',
body: transportStream,
});
return;
}
await fulfillRange(route, webmClip, 'video/webm');
}
/**
* Chromium's media pipeline reads files through byte ranges; answering a
* Range request with the whole body would clamp every read to the start.
*/
async function fulfillRange(
route: Route,
body: Buffer,
contentType: string
): Promise<void> {
const range = /^bytes=(\d*)-(\d*)$/.exec(
route.request().headers()['range'] ?? ''
);
const last = body.length - 1;
const start = range?.[1]
? Number(range[1])
: range?.[2]
? Math.max(0, body.length - Number(range[2]))
: 0;
const end =
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
await route.fulfill({
status: range ? 206 : 200,
headers: {
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
'content-type': contentType,
...(range
? { 'content-range': `bytes ${start}-${end}/${body.length}` }
: {}),
},
body: body.subarray(start, end + 1),
});
}
@@ -139,15 +139,20 @@ for (const engine of [
)
.toBeGreaterThan(0);
} else {
// Past the two seconds after which the channel is recorded
// as recently viewed: the HTML5 pass reopens it from there.
await expect
.poll(() =>
app.mainWindow
.locator('app-web-player-view video')
.evaluate(
(video: HTMLVideoElement) => video.currentTime
)
.poll(
() =>
app.mainWindow
.locator('app-web-player-view video')
.evaluate(
(video: HTMLVideoElement) =>
video.currentTime
),
{ timeout: 20000 }
)
.toBeGreaterThan(0);
.toBeGreaterThan(2.5);
}
const controls = app.mainWindow.locator(
engine === 'native'
@@ -159,6 +164,10 @@ for (const engine of [
exact: true,
});
await expect(pause).toBeEnabled({ timeout: 20000 });
if (engine !== 'native') {
// Seconds of playback let the controls auto-hide; reveal them.
await app.mainWindow.locator('app-web-player-view').hover();
}
await pause.click();
const play = controls.getByRole('button', {
name: 'Play',
@@ -30,6 +30,10 @@ import {
writeTemporaryM3uFile,
xtreamMockServer,
} from './electron-test-fixtures';
import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
const xtreamStressUsername = 'stress';
const xtreamStressPassword = 'stress';
@@ -202,6 +206,7 @@ test.describe('Electron Playlist Switcher', () => {
},
]);
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
@@ -213,9 +218,12 @@ test.describe('Electron Playlist Switcher', () => {
.click();
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, localAChannelName);
await channelItemByTitle(app.mainWindow, localAChannelName)
.first()
.click();
// Recorded as recently viewed once it has really played.
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, localAChannelName)
.first()
.click()
);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localBDisplayName)
@@ -223,9 +231,12 @@ test.describe('Electron Playlist Switcher', () => {
.click();
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, localBChannelName);
await channelItemByTitle(app.mainWindow, localBChannelName)
.first()
.click();
// Recorded as recently viewed once it has really played.
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, localBChannelName)
.first()
.click()
);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
+66 -25
View File
@@ -41,6 +41,10 @@ import {
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
test.describe('Electron Recently Viewed', () => {
test('keeps unified live detail open when re-clicking the active M3U recent item', async ({
@@ -58,14 +62,17 @@ test.describe('Electron Recently Viewed', () => {
]
);
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, 'Stable Recent Channel')
.first()
.click();
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, 'Stable Recent Channel')
.first()
.click()
);
await openPlaylistRecent(app.mainWindow);
@@ -109,17 +116,22 @@ test.describe('Electron Recently Viewed', () => {
},
]);
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, 'Recent Channel One')
.first()
.click();
await channelItemByTitle(app.mainWindow, 'Recent Channel Two')
.first()
.click();
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, 'Recent Channel One')
.first()
.click()
);
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, 'Recent Channel Two')
.first()
.click()
);
await openPlaylistRecent(app.mainWindow);
await expect
@@ -188,17 +200,22 @@ test.describe('Electron Recently Viewed', () => {
]
);
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, 'Context Recent One')
.first()
.click();
await channelItemByTitle(app.mainWindow, 'Context Recent Two')
.first()
.click();
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, 'Context Recent One')
.first()
.click()
);
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, 'Context Recent Two')
.first()
.click()
);
await openPlaylistRecent(app.mainWindow);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
@@ -247,6 +264,7 @@ test.describe('Electron Recently Viewed', () => {
);
const portalTitle = 'Xtream Recent Source';
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await addXtreamPortal(app.mainWindow, {
@@ -261,7 +279,9 @@ test.describe('Electron Recently Viewed', () => {
);
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
await openPlaylistFavorites(app.mainWindow);
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, liveTitle).first().click()
);
await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle);
await app.mainWindow
@@ -277,7 +297,9 @@ test.describe('Electron Recently Viewed', () => {
// card, avoiding a race where the grid re-renders between title read and
// a separate search-by-title click.
const movieTitle = await clickFirstGridListCard(app.mainWindow);
await playCurrentDetail(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playCurrentDetail(app.mainWindow)
);
await goBackFromDetail(app.mainWindow);
await app.mainWindow
@@ -288,7 +310,9 @@ test.describe('Electron Recently Viewed', () => {
seriesFixture.categoryName
);
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
await playFirstSeriesEpisode(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playFirstSeriesEpisode(app.mainWindow)
);
await openPlaylistRecent(app.mainWindow);
await expect(
@@ -362,6 +386,7 @@ test.describe('Electron Recently Viewed', () => {
);
const portalTitle = 'Xtream Recent Detail Source';
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await addXtreamPortal(app.mainWindow, {
@@ -377,7 +402,9 @@ test.describe('Electron Recently Viewed', () => {
vodFixture.categoryName
);
const movieTitle = await clickFirstGridListCard(app.mainWindow);
await playCurrentDetail(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playCurrentDetail(app.mainWindow)
);
await goBackFromDetail(app.mainWindow);
await app.mainWindow
@@ -388,7 +415,9 @@ test.describe('Electron Recently Viewed', () => {
seriesFixture.categoryName
);
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
await playFirstSeriesEpisode(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playFirstSeriesEpisode(app.mainWindow)
);
await openPlaylistRecent(app.mainWindow);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
@@ -455,6 +484,7 @@ test.describe('Electron Recently Viewed', () => {
);
const portalTitle = 'Stalker Recent Source';
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await addStalkerPortal(app.mainWindow, {
@@ -468,7 +498,9 @@ test.describe('Electron Recently Viewed', () => {
await clickCategoryById(app.mainWindow, liveFixture.categoryId);
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
await openPlaylistFavorites(app.mainWindow);
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, liveTitle).first().click()
);
await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle);
await app.mainWindow
@@ -476,7 +508,9 @@ test.describe('Electron Recently Viewed', () => {
.click();
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
await playCurrentDetail(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playCurrentDetail(app.mainWindow)
);
await goBackFromDetail(app.mainWindow);
await app.mainWindow
@@ -484,7 +518,9 @@ test.describe('Electron Recently Viewed', () => {
.click();
await clickCategoryById(app.mainWindow, seriesFixture.categoryId);
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
await playFirstSeriesEpisode(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playFirstSeriesEpisode(app.mainWindow)
);
await openPlaylistRecent(app.mainWindow);
await expect(
@@ -559,6 +595,7 @@ test.describe('Electron Recently Viewed', () => {
);
const portalTitle = 'Stalker Recent Detail Source';
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await addStalkerPortal(app.mainWindow, {
@@ -571,7 +608,9 @@ test.describe('Electron Recently Viewed', () => {
.click();
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
await playCurrentDetail(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playCurrentDetail(app.mainWindow)
);
await goBackFromDetail(app.mainWindow);
await app.mainWindow
@@ -579,7 +618,9 @@ test.describe('Electron Recently Viewed', () => {
.click();
await clickCategoryById(app.mainWindow, seriesFixture.categoryId);
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
await playFirstSeriesEpisode(app.mainWindow);
await startAndConfirmPlayback(app.mainWindow, () =>
playFirstSeriesEpisode(app.mainWindow)
);
await openPlaylistRecent(app.mainWindow);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
+13
View File
@@ -189,6 +189,19 @@ test('@web @m3u @dash ClearKey reopens from recent and favorites collections', a
await importDashPlaylist(page);
await page.getByText('1. ClearKey DASH').click();
await expectVideoPlaying(page);
// Recent history records the channel once it has played two seconds.
await expect
.poll(
() =>
page
.locator('app-web-player-view video')
.first()
.evaluate(
(element: HTMLVideoElement) => element.currentTime
),
{ timeout: 20_000 }
)
.toBeGreaterThan(2.5);
const playlistUrl = page.url().replace(/\/all$/, '');
const channel = page.locator('.channel-list-item').filter({
hasText: '1. ClearKey DASH',
+14 -1
View File
@@ -431,7 +431,7 @@ for (const theme of ['light', 'dark']) {
const channels = Array.from(
{ length: 60 },
(_, index) =>
`#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.m3u8`
`#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.webm`
);
await importPlaylist(page, ['#EXTM3U', ...channels].join('\n'), 60);
await page.evaluate(
@@ -462,6 +462,19 @@ for (const theme of ['light', 'dark']) {
await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass(
/active/
);
// Recently viewed lists the station once it has played two seconds.
await expect
.poll(
() =>
page
.locator('app-web-player-view video')
.first()
.evaluate(
(video: HTMLVideoElement) => video.currentTime
),
{ timeout: 20_000 }
)
.toBeGreaterThan(2.5);
await pressTab(page, browserName);
const favorite = viewport.locator('.favorite-button').nth(1);
await expect(favorite).toBeFocused();
@@ -1,3 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { PlaybackHistoryGate } from '@iptvnator/services';
import { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { ExternalPlaybackService } from './external-playback.service';
@@ -40,7 +42,36 @@ describe('ExternalPlaybackService', () => {
},
});
service = new ExternalPlaybackService();
service = TestBed.runInInjectionContext(
() => new ExternalPlaybackService()
);
});
it('commits deferred history once the external player has opened', () => {
const commit = jest.fn();
TestBed.inject(PlaybackHistoryGate).defer(
['https://example.com/video.m3u8'],
commit
);
listener?.(createSession({ status: 'launching' }));
expect(commit).not.toHaveBeenCalled();
listener?.(createSession({ status: 'opened' }));
expect(commit).toHaveBeenCalledTimes(1);
});
it('does not commit history for a launch that failed', () => {
const commit = jest.fn();
TestBed.inject(PlaybackHistoryGate).defer(
['https://example.com/video.m3u8'],
commit
);
listener?.(createSession({ status: 'launching' }));
listener?.(createSession({ status: 'error', error: 'not found' }));
expect(commit).not.toHaveBeenCalled();
});
it('tracks the latest launch and hides dismissed sessions until the next launch', () => {
@@ -1,4 +1,5 @@
import { computed, Injectable, signal } from '@angular/core';
import { computed, inject, Injectable, signal } from '@angular/core';
import { PlaybackHistoryGate } from '@iptvnator/services';
import {
ExternalPlayerSession,
PlayerContentInfo,
@@ -8,6 +9,7 @@ import {
providedIn: 'root',
})
export class ExternalPlaybackService {
private readonly historyGate = inject(PlaybackHistoryGate);
readonly activeSession = signal<ExternalPlayerSession | null>(null);
private readonly dismissedSessionId = signal<string | null>(null);
@@ -102,6 +104,12 @@ export class ExternalPlaybackService {
}
private handleSessionUpdate(session: ExternalPlayerSession): void {
// 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]);
}
const current = this.activeSession();
const restoresCurrentReplacement =
current?.id === session.restoredFromSessionId;
@@ -1129,6 +1129,39 @@ This keeps:
- series and VOD-as-series support intact
- external MPV/VLC launches unchanged
## Recently Viewed Confirmation
Selecting or resolving an item is not watching it. A channel, movie or series
becomes a recently viewed item — and with it the dashboard hero — only once
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.
- `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.
- 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 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
by value — a new object would remount the engine and restart the stream.
## Playback Position Saving
The old dialog path saved playback positions from inside the removed Xtream
+3
View File
@@ -74,6 +74,9 @@ Render rules:
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
full-bleed. All rails and the hero are skipped.
3. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel.
An item enters recent history only after its stream has really played
(see "Recently Viewed Confirmation" in `embedded-inline-playback.md`), so
a channel that failed at once never becomes the hero.
4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden
— there is no "empty widget" placeholder.
5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it
@@ -0,0 +1,254 @@
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { of } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import { selectActivePlaylist } from '@iptvnator/m3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import {
DataService,
PlaybackHistoryGate,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
PlaylistMeta,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
import {
dataServiceMock,
epgServiceMock,
epgUrlSetting,
epgViewMode,
externalSession,
player,
playlistId,
playlistsServiceMock,
routerMock,
sampleChannel,
showCaptions,
storeMock,
stripCountryPrefix,
syncStoreState,
translateServiceProvider,
} from './video-player.spec-harness';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
/**
* When a selected M3U channel becomes a recently viewed item (and with it
* the dashboard hero). Kept apart from `video-player.component.spec.ts`,
* which sits at the spec line budget; the template is reduced to nothing,
* because the players themselves are stood in for by the history gate.
*/
describe('VideoPlayerComponent — recently viewed history', () => {
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
let component: VideoPlayerComponentInstance;
let gate: PlaybackHistoryGate;
const activePlaylist = signal<Partial<PlaylistMeta> | null>(null);
const harnessSelectSignal = storeMock.selectSignal.getMockImplementation();
beforeAll(async () => {
({ VideoPlayerComponent } = await import('./video-player.component'));
});
beforeEach(async () => {
syncStoreState(null);
playlistId.set('playlist-1');
player.set(VideoPlayer.VideoJs);
showCaptions.set(false);
stripCountryPrefix.set(false);
externalSession.set(null);
storeMock.dispatch.mockClear();
activePlaylist.set({ _id: 'playlist-1', recentlyViewed: [] });
storeMock.selectSignal.mockImplementation((selector: unknown) =>
selector === selectActivePlaylist
? activePlaylist
: harnessSelectSignal?.(selector)
);
playlistsServiceMock.addM3uRecentlyViewed.mockClear();
await TestBed.configureTestingModule({
imports: [VideoPlayerComponent],
schemas: [NO_ERRORS_SCHEMA],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
},
{ provide: Router, useValue: routerMock },
{ provide: Store, useValue: storeMock },
translateServiceProvider,
{ provide: DataService, useValue: dataServiceMock },
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: false,
isElectron: false,
supportsRemoteControl: false,
},
},
{ provide: PlaylistsService, useValue: playlistsServiceMock },
{ provide: EpgService, useValue: epgServiceMock },
{
provide: PlaylistContextFacade,
useValue: { resolvedPlaylistId: playlistId },
},
{
provide: TmdbEnrichmentService,
useValue: { isEnabled: () => false },
},
{
provide: SettingsStore,
useValue: {
player,
showCaptions,
stripCountryPrefix,
m3uVodDetails: signal(true),
resolvedEpgViewMode: epgViewMode,
resolvedEpgOffsetMinutes: signal(0),
epgUrl: epgUrlSetting,
},
},
{
provide: StorageMap,
useValue: {
get: jest.fn(() =>
of({ player: player() } as Partial<Settings>)
),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: externalSession },
},
],
})
.overrideComponent(VideoPlayerComponent, {
set: {
imports: [],
template:
'<ng-template #fullscreenChannelPanel></ng-template>',
},
})
.compileComponents();
gate = TestBed.inject(PlaybackHistoryGate);
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
fixture?.destroy();
storeMock.selectSignal.mockImplementation(harnessSelectSignal);
});
function select(channel = sampleChannel): void {
syncStoreState(channel);
fixture.detectChanges();
}
it('records an inline channel only once the player confirms it plays', () => {
select();
// Selected, but the stream has not played yet (or failed at once).
expect(
playlistsServiceMock.addM3uRecentlyViewed
).not.toHaveBeenCalled();
gate.confirm([component.playbackSessionKey()]);
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith(
'playlist-1',
expect.objectContaining({
source: 'm3u',
url: sampleChannel.url,
title: 'Sample TV',
})
);
});
it('keeps the playing source when recording updates the playlist', () => {
select();
const playback = component.embeddedPlayback();
expect(playback).not.toBeNull();
// What the confirmed write dispatches back into the store.
activePlaylist.set({
_id: 'playlist-1',
recentlyViewed: [{ source: 'm3u', id: sampleChannel.url } as never],
});
fixture.detectChanges();
// A new source object would remount the engine and restart the
// stream right after its first two seconds.
expect(component.embeddedPlayback()).toBe(playback);
});
it('never records a channel whose stream did not play', () => {
select();
select({
...sampleChannel,
id: 'channel-2',
url: 'http://localhost/second.m3u8',
name: 'Second TV',
});
gate.confirm(['http://localhost/second.m3u8']);
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes(
1
);
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith(
'playlist-1',
expect.objectContaining({ title: 'Second TV' })
);
});
it('records a radio station once the audio player confirms its URL', () => {
const radio = { ...sampleChannel, radio: 'true' };
select(radio);
expect(
playlistsServiceMock.addM3uRecentlyViewed
).not.toHaveBeenCalled();
gate.confirm([radio.url]);
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes(
1
);
});
it('records on selection with an external player, which cannot confirm', () => {
player.set(VideoPlayer.MPV);
fixture.detectChanges();
select();
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes(
1
);
});
});
@@ -114,6 +114,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
PlaybackHistoryGate,
PlaylistsService,
RecordingsService,
RuntimeCapabilitiesService,
@@ -237,6 +238,7 @@ export class VideoPlayerComponent
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly dataService = inject(DataService);
private readonly playlistsService = inject(PlaylistsService);
private readonly historyGate = inject(PlaybackHistoryGate);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly router = inject(Router);
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -485,7 +487,17 @@ export class VideoPlayerComponent
epgParams: '',
} as Channel;
});
readonly embeddedPlayback = computed<ResolvedPortalPlayback | null>(() => {
/**
* Compared by value: it also reads the playlist meta, which changes while
* a channel plays (the recently viewed write, a favourite toggle). A new
* but identical object would hand the player a new source and restart
* the stream.
*/
readonly embeddedPlayback = computed<ResolvedPortalPlayback | null>(
() => this.resolveEmbeddedPlayback(),
{ equal: (a, b) => JSON.stringify(a) === JSON.stringify(b) }
);
private resolveEmbeddedPlayback(): ResolvedPortalPlayback | null {
const activeChannel = this.activeChannel();
const playbackTarget = this.playbackChannel();
@@ -528,7 +540,7 @@ export class VideoPlayerComponent
// — extract lazily so they work without a re-import.
drm: playbackTarget.drm ?? extractDrmFromRaw(playbackTarget.raw),
};
});
}
readonly sidebarStorageKey = computed(() =>
this.activeView() === 'groups'
? M3U_GROUPS_SIDEBAR_STORAGE_KEY
@@ -842,7 +854,9 @@ export class VideoPlayerComponent
}
this.lastRecordedRecentKey = nextKey;
void this.persistRecentlyViewedChannel(playlistId, activeChannel);
untracked(() =>
this.recordRecentlyViewedChannel(playlistId, activeChannel)
);
});
effect(() => {
@@ -1198,6 +1212,29 @@ export class VideoPlayerComponent
);
}
/**
* Inline playback (video, radio, movie detail) records the channel only
* once it has really played, so a stream that fails right away never
* reaches history or the dashboard hero. MPV/VLC cannot report that for
* a live stream, so they keep recording on selection.
*/
private recordRecentlyViewedChannel(
playlistId: string,
channel: Channel
): void {
const record = () =>
void this.persistRecentlyViewedChannel(playlistId, channel);
if (channel.radio !== 'true' && !this.shouldShowInlinePlayer(channel)) {
record();
return;
}
this.historyGate.defer(
[this.playbackSessionKey(), channel.url],
record
);
}
private async persistRecentlyViewedChannel(
playlistId: string,
channel: Channel
@@ -8,6 +8,7 @@ import {
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { PlaybackHistoryGate } from '@iptvnator/services';
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
import { UnifiedLiveTimeshift } from './unified-live-catchup';
import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation';
@@ -71,6 +72,7 @@ export function createUnifiedLiveSelection(options: {
const recentData = inject(UnifiedRecentDataService);
const streamHeaders = inject(ElectronStreamHeadersService);
const portalPlayer = inject(PORTAL_PLAYER);
const historyGate = inject(PlaybackHistoryGate);
/** Stream URL of the radio playback whose header override this configured. */
let radioHeaderScopeUrl: string | null = null;
@@ -119,6 +121,20 @@ export function createUnifiedLiveSelection(options: {
});
};
const recordLivePlayback = async (
item: UnifiedCollectionItem,
generation: number
): Promise<void> => {
try {
const updatedItem = await recentData.recordLivePlayback(item);
if (generation === options.generation.current()) {
options.onItemPlayed(updatedItem);
}
} catch {
// Keep playback/EPG visible even if history persistence fails.
}
};
const close = (): void => {
options.generation.next();
pendingActivation = null;
@@ -223,14 +239,12 @@ export function createUnifiedLiveSelection(options: {
void portalPlayer.openResolvedPlayback(detail.playback);
}
try {
const updatedItem = await recentData.recordLivePlayback(item);
if (generation === options.generation.current()) {
options.onItemPlayed(updatedItem);
}
} catch {
// Keep playback/EPG visible even if history persistence fails.
}
// 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)
);
if (
generation === options.generation.current() &&
@@ -26,7 +26,11 @@ import {
} from '@iptvnator/ui/epg';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ResizableDirective } from '@iptvnator/ui/components';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
PlaybackHistoryGate,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgItem, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
PORTAL_PLAYER,
@@ -157,6 +161,12 @@ describe('UnifiedLiveTabComponent', () => {
window.electron = originalElectron;
});
/** What the mounted player reports once the stream has really played. */
async function playStream(streamUrl: string): Promise<void> {
TestBed.inject(PlaybackHistoryGate).confirm([streamUrl]);
await fixture.whenStable();
}
it('renders controlled M3U EPG and records recent history on selection', async () => {
const item = buildLiveItem('m3u');
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
@@ -200,6 +210,8 @@ describe('UnifiedLiveTabComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).not.toHaveBeenCalled();
await playStream('https://example.com/m3u.m3u8');
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalledWith(
item,
@@ -658,6 +670,8 @@ describe('UnifiedLiveTabComponent', () => {
streamUrl: 'https://example.com/m3u.m3u8',
})
);
expect(recentData.recordLivePlayback).not.toHaveBeenCalled();
await playStream('https://example.com/m3u.m3u8');
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalled();
@@ -711,6 +725,8 @@ describe('UnifiedLiveTabComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).not.toHaveBeenCalled();
await playStream('https://example.com/radio.m3u8');
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(
@@ -781,6 +797,8 @@ describe('UnifiedLiveTabComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).not.toHaveBeenCalled();
await playStream('https://example.com/jazz.mp3');
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(
@@ -840,6 +858,8 @@ describe('UnifiedLiveTabComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).not.toHaveBeenCalled();
await playStream('https://example.com/xtream.m3u8');
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
const timeline = fixture.debugElement.query(
By.directive(StubEpgTimelineComponent)
@@ -873,6 +893,8 @@ describe('UnifiedLiveTabComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).not.toHaveBeenCalled();
await playStream('https://example.com/stalker.m3u8');
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
const timeline = fixture.debugElement.query(
By.directive(StubEpgTimelineComponent)
@@ -896,6 +918,10 @@ describe('UnifiedLiveTabComponent', () => {
component.autoOpenHandled,
'emit'
);
// Like the collection page: a handled auto-open is cleared.
component.autoOpenHandled.subscribe(() =>
fixture.componentRef.setInput('autoOpenItem', null)
);
streamResolver.resolveLiveDetail.mockReturnValue(pendingDetail.promise);
recentData.recordLivePlayback.mockResolvedValue({
@@ -4,7 +4,11 @@ import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
DataService,
PlaybackHistoryGate,
PlaylistsService,
} from '@iptvnator/services';
import { of } from 'rxjs';
import {
PlaylistMeta,
@@ -13,6 +17,11 @@ import {
import { StalkerSessionService } from '../../stalker-session.service';
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]);
}
jest.mock('@iptvnator/portal/shared/util', () => ({
...jest.requireActual('@iptvnator/portal/shared/util'),
createLogger: () => ({
@@ -160,6 +169,7 @@ describe('withStalkerPlayer', () => {
'Movie Title',
'thumb.jpg'
);
confirmPlayback(playback);
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
1,
@@ -225,6 +235,7 @@ describe('withStalkerPlayer', () => {
1,
3000001
);
confirmPlayback(playback);
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
PLAYLIST._id,
@@ -266,6 +277,7 @@ describe('withStalkerPlayer', () => {
logo: 'jazz.png',
category_id: '4001',
});
confirmPlayback(playback);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
@@ -372,6 +384,42 @@ describe('withStalkerPlayer', () => {
category_id: '1001',
};
it('records a resolved channel only once its stream has played', async () => {
store.setSelectedContentType('itv');
dataService.sendIpcEvent.mockResolvedValueOnce({
js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=first' },
});
const failed = await store.resolveItvPlayback({
...CHANNEL,
use_http_tmp_link: '1',
});
// A link that never plays (dead stream, player error) is not a view.
expect(
playlistService.addPortalRecentlyViewed
).not.toHaveBeenCalled();
dataService.sendIpcEvent.mockResolvedValueOnce({
js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=second' },
});
const played = await store.resolveItvPlayback({
...CHANNEL,
use_http_tmp_link: '1',
});
confirmPlayback(played);
expect(failed.streamUrl).not.toBe(played.streamUrl);
expect(
playlistService.addPortalRecentlyViewed
).toHaveBeenCalledTimes(1);
expect(
playlistService.addPortalRecentlyViewed
).toHaveBeenCalledWith(
PLAYLIST._id,
expect.objectContaining({ id: '10001', cmd: CHANNEL.cmd })
);
});
it('plays an unflagged ITV channel straight from its static cmd', async () => {
store.setSelectedContentType('itv');
@@ -520,7 +568,9 @@ describe('withStalkerPlayer', () => {
dataService.sendIpcEvent.mockResolvedValueOnce(response);
}
await store.resolveItvPlayback({ ...CHANNEL, ...flags });
confirmPlayback(
await store.resolveItvPlayback({ ...CHANNEL, ...flags })
);
expect(
playlistService.addPortalRecentlyViewed
@@ -4,7 +4,11 @@ import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYER, createLogger } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
DataService,
PlaybackHistoryGate,
PlaylistsService,
} from '@iptvnator/services';
import {
PlaylistMeta,
ResolvedPortalPlayback,
@@ -60,7 +64,8 @@ export function withStalkerPlayer() {
portalRepair = inject(StalkerPortalRepairService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService),
ngrxStore = inject(Store)
ngrxStore = inject(Store),
historyGate = inject(PlaybackHistoryGate)
) => {
const storeState = store as typeof store &
StalkerPlayerFeatureStoreContract;
@@ -122,7 +127,15 @@ export function withStalkerPlayer() {
storeState.selectedContentType()
);
/**
* Resolving a link is not watching it: the write waits until
* the player (inline ≥2 s of progress, or a launched MPV/VLC)
* confirms `streamUrl`. The item and its portal are captured
* now, so switching portal or section meanwhile cannot
* misfile it.
*/
const recordRecentlyViewed = (
streamUrl: string,
item: StalkerPlayableItem | null | undefined,
cmd?: string,
cover?: string,
@@ -139,13 +152,12 @@ export function withStalkerPlayer() {
cover,
title
);
if (typeof storeState.addToRecentlyViewed === 'function') {
storeState.addToRecentlyViewed(recentItem);
return;
}
persistRecentlyViewed(playlistId, recentItem);
historyGate.defer([streamUrl], () =>
persistRecentlyViewed(playlistId, {
...recentItem,
added_at: Date.now(),
})
);
};
const resolveVodPlaybackInternal = async (
@@ -157,9 +169,7 @@ export function withStalkerPlayer() {
startTime?: number
): Promise<ResolvedPortalPlayback> => {
const item = storeState.selectedItem() as
| StalkerPlayableItem
| null
| undefined;
StalkerPlayableItem | null | undefined;
let cmdToUse = cmd ?? item?.cmd;
if (!cmdToUse) {
@@ -195,7 +205,13 @@ export function withStalkerPlayer() {
}
);
recordRecentlyViewed(item, cmd, thumbnail, title);
recordRecentlyViewed(
streamUrl,
item,
cmd,
thumbnail,
title
);
const isEpisode =
episodeNum !== undefined || episodeId !== undefined;
@@ -285,6 +301,7 @@ export function withStalkerPlayer() {
const portalOrigin = getStalkerPortalOrigin(playlist);
recordRecentlyViewed(
streamUrl,
item,
item.cmd,
item.logo ?? item.cover,
@@ -350,6 +367,7 @@ export function withStalkerPlayer() {
);
recordRecentlyViewed(
streamUrl,
item,
item.cmd,
item.logo ?? item.cover,
@@ -59,10 +59,6 @@ export interface StalkerSeriesFeatureStoreContract
vodSeriesSeasonsResource: ResourceState<StalkerVodSeriesSeason[]>;
}
export interface StalkerRecentCallbackStoreContract {
addToRecentlyViewed?: (item: StalkerRecentlyViewedItem) => void;
}
export type StalkerRecentlyViewedItem = StalkerPortalItem & {
id: string | number;
title: string;
@@ -74,8 +70,7 @@ export interface StalkerPlayerFeatureStoreContract
Pick<
StalkerSelectionStoreContract,
'selectedContentType' | 'selectedItem'
>,
StalkerRecentCallbackStoreContract {}
> {}
export interface StalkerEpgFeatureStoreContract
extends
@@ -36,6 +36,7 @@ import {
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
} from '@iptvnator/ui/playback';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import {
@@ -66,6 +67,7 @@ export class SerialDetailsPlaybackService {
);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly recordRecentItem = injectXtreamRecentHistory();
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
private readonly seasonWatch = inject(SerialDetailsSeasonWatchService);
@@ -209,9 +211,12 @@ export class SerialDetailsPlaybackService {
return;
}
this.addToRecentlyViewed(this.route.snapshot.params.serialId);
const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode);
this.recordRecentItem(streamUrl, {
xtreamId: this.route.snapshot.params.serialId,
contentType: 'series',
backdropUrl: selectedItem.info?.backdrop_path?.[0],
});
const contentInfo: PlayerContentInfo = {
playlistId: playlist.id,
contentXtreamId: Number(episode.id),
@@ -404,15 +409,6 @@ export class SerialDetailsPlaybackService {
return this.bindings()?.selectedItem() ?? null;
}
private addToRecentlyViewed(xtreamId: number): void {
this.xtreamStore.addRecentItem({
xtreamId,
contentType: 'series',
playlist: this.xtreamStore.currentPlaylist,
backdropUrl: this.selectedItem()?.info?.backdrop_path?.[0],
});
}
private startPlayback(
playback: ResolvedPortalPlayback,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
@@ -25,7 +25,10 @@ import {
} from '@iptvnator/portal/shared/util';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
PlaybackHistoryGate,
PlaybackPositionRuntimeBridgeService,
} from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { BehaviorSubject, EMPTY, of } from 'rxjs';
@@ -564,12 +567,20 @@ describe('SerialDetailsComponent', () => {
expect(constructEpisodeStreamUrl).toHaveBeenCalledWith(
expect.objectContaining({ id: '1001' })
);
// 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',
]);
expect(addRecentItem).toHaveBeenCalledWith({
xtreamId: '103',
contentType: 'series',
playlist: currentPlaylist,
playlist: expect.any(Function),
backdropUrl: undefined,
});
expect(addRecentItem.mock.calls[0][0].playlist()).toEqual(
currentPlaylist()
);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'http://xtream.example/series/1001.mp4',
@@ -923,7 +934,10 @@ describe('SerialDetailsComponent', () => {
SerialDetailsPlaybackService
);
const snackBar = TestBed.inject(MatSnackBar);
const seasonPosition = (contentXtreamId: number, episodeNumber: number) => ({
const seasonPosition = (
contentXtreamId: number,
episodeNumber: number
) => ({
playlistId: 'xtream-1',
contentXtreamId,
contentType: 'episode' as const,
@@ -958,9 +972,9 @@ describe('SerialDetailsComponent', () => {
expect.objectContaining({ contentXtreamId: 1002 }),
]);
expect(savePlaybackPosition).not.toHaveBeenCalled();
expect(
playbackService.episodePlaybackPositions().get(1001)
).toEqual(expect.objectContaining({ positionSeconds: 1200 }));
expect(playbackService.episodePlaybackPositions().get(1001)).toEqual(
expect.objectContaining({ positionSeconds: 1200 })
);
expect(
playbackService.episodePlaybackPositions().get(1002)
).toBeDefined();
@@ -1065,9 +1079,7 @@ describe('SerialDetailsComponent', () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
savePlaybackPositionsBatch.mockRejectedValue(
new Error('batch failed')
);
savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed'));
fixture.detectChanges();
await fixture.whenStable();
@@ -6,7 +6,10 @@ import {
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
PlaybackHistoryGate,
PlaybackPositionRuntimeBridgeService,
} from '@iptvnator/services';
import type {
PlaybackPositionData,
PlayerContentInfo,
@@ -147,6 +150,34 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
});
});
it('records the movie as recently viewed only once its stream played', async () => {
await service.startResolvedPlayback({
streamUrl: 'https://example.com/broken.mkv',
title: 'Broken source',
});
await service.startResolvedPlayback({
streamUrl: 'https://example.com/route.mkv',
title: 'Working source',
});
currentPlaylist.set({ id: 'playlist-switched-meanwhile' });
expect(addRecentItem).not.toHaveBeenCalled();
TestBed.inject(PlaybackHistoryGate).confirm([
'https://example.com/route.mkv',
]);
expect(addRecentItem).toHaveBeenCalledTimes(1);
const [recentItem] = addRecentItem.mock.calls[0];
expect(recentItem).toEqual(
expect.objectContaining({
xtreamId: ROUTE_VOD_ID,
contentType: 'movie',
})
);
expect(recentItem.playlist()).toEqual({ id: ROUTE_PLAYLIST });
});
it('owns a session launched for the route’s own stream', () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
@@ -341,6 +372,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
contentType: 'vod',
},
});
TestBed.inject(PlaybackHistoryGate).confirm([
'https://example.com/alt.mkv',
]);
expect(addRecentItem).toHaveBeenCalled();
});
@@ -38,6 +38,7 @@ import {
createExternalLaunchOwner,
startRouteOwnedPlayback,
} from './vod-details-external-launch-owner';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { settleOwnedExternalLaunch } from './vod-details-external-launch';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
import { isResumablePosition } from './vod-primary-action-position';
@@ -68,6 +69,7 @@ export class VodDetailsPlaybackService {
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly recordRecentItem = injectXtreamRecentHistory();
private readonly playbackPositionBridge = inject(
PlaybackPositionRuntimeBridgeService
);
@@ -377,15 +379,6 @@ export class VodDetailsPlaybackService {
this.positionLoaded.set(true);
}
private addToRecentlyViewed(): void {
this.xtreamStore.addRecentItem({
xtreamId: this.bindings()?.vodId() ?? NaN,
contentType: 'movie',
playlist: this.xtreamStore.currentPlaylist,
backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0],
});
}
/**
* The single inline-vs-external fork. Public so multi-source can switch
* the playing source through exactly the same path a normal Play takes —
@@ -469,7 +462,11 @@ export class VodDetailsPlaybackService {
}
// Same movie, different source: still a view.
this.addToRecentlyViewed();
this.recordRecentItem(playback.streamUrl, {
xtreamId: this.bindings()?.vodId() ?? NaN,
contentType: 'movie',
backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0],
});
return await this.applyPlayback(playback, isCurrent);
} finally {
this.pendingStart.settle(startId);
@@ -0,0 +1,33 @@
import { inject, signal } from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackHistoryGate } from '@iptvnator/services';
export interface XtreamRecentItemRequest {
readonly xtreamId: number | string;
readonly contentType: 'movie' | 'series';
readonly backdropUrl?: string;
}
/**
* Records a movie or series as recently viewed once `streamUrl` has really
* played — inline for a couple of seconds, or launched in MPV/VLC — so a
* source that fails straight away never reaches history or the dashboard
* hero. The playlist is captured when playback starts, so navigating to
* another one meanwhile cannot misfile the item.
*
* Must run in an injection context.
*/
export function injectXtreamRecentHistory(): (
streamUrl: string,
request: XtreamRecentItemRequest
) => void {
const gate = inject(PlaybackHistoryGate);
const store = inject(XtreamStore);
return (streamUrl, request) => {
const playlist = signal(store.currentPlaylist()).asReadonly();
gate.defer([streamUrl], () =>
store.addRecentItem({ ...request, playlist })
);
};
}
+1
View File
@@ -6,6 +6,7 @@ export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/recordings.service';
export * from './lib/playback-position-runtime-bridge.service';
export * from './lib/playback-history-gate.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-delete-cleanup.token';
export * from './lib/playlist-delete-action.service';
@@ -0,0 +1,100 @@
import { PlaybackHistoryGate } from './playback-history-gate.service';
describe('PlaybackHistoryGate', () => {
let gate: PlaybackHistoryGate;
beforeEach(() => {
gate = new PlaybackHistoryGate();
});
it('holds a write until one of its keys is confirmed', () => {
const commit = jest.fn();
gate.defer(['http://stream/1', 'live:p1:c1'], commit);
expect(commit).not.toHaveBeenCalled();
gate.confirm(['live:p1:c1']);
expect(commit).toHaveBeenCalledTimes(1);
});
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.confirm(['http://stream/played']);
expect(failed).not.toHaveBeenCalled();
expect(played).toHaveBeenCalledTimes(1);
});
it('commits each confirmed write once', () => {
const commit = jest.fn();
gate.defer(['http://stream/1'], commit);
gate.confirm(['http://stream/1']);
gate.confirm(['http://stream/1']);
expect(commit).toHaveBeenCalledTimes(1);
});
it('commits every writer deferred under the same key', () => {
const first = jest.fn();
const second = jest.fn();
gate.defer(['http://stream/1'], first);
gate.defer(['http://stream/1'], second);
gate.confirm(['http://stream/1']);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it('commits immediately when a write has no usable key', () => {
const commit = jest.fn();
gate.defer([undefined, null, ' '], commit);
expect(commit).toHaveBeenCalledTimes(1);
});
it('ignores confirmations without usable keys', () => {
const commit = jest.fn();
gate.defer(['http://stream/1'], commit);
gate.confirm([undefined, '']);
expect(commit).not.toHaveBeenCalled();
});
it('drops the oldest unconfirmed writes beyond its bound', () => {
const oldest = jest.fn();
gate.defer(['http://stream/oldest'], oldest);
for (let index = 0; index < 20; index += 1) {
gate.defer([`http://stream/${index}`], jest.fn());
}
gate.confirm(['http://stream/oldest']);
expect(oldest).not.toHaveBeenCalled();
});
it('keeps committing the other writes when one throws', () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const failing = jest.fn(() => {
throw new Error('db down');
});
const healthy = jest.fn();
gate.defer(['http://stream/1'], failing);
gate.defer(['http://stream/1'], healthy);
gate.confirm(['http://stream/1']);
expect(healthy).toHaveBeenCalledTimes(1);
consoleError.mockRestore();
});
});
@@ -0,0 +1,83 @@
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)[];
interface PendingHistoryWrite {
readonly keys: ReadonlySet<string>;
readonly commit: () => void;
}
/**
* Upper bound on unconfirmed writes. Each click on a channel that never
* plays leaves one behind; the oldest is dropped rather than kept forever.
*/
const MAX_PENDING_HISTORY_WRITES = 20;
/**
* Holds "recently viewed" writes back until the stream really plays.
*
* 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.
*
* 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
* 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) {
runCommit(commit);
return;
}
this.pending.push({ keys: 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;
}
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);
}
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 runCommit(commit: () => void): void {
try {
commit();
} catch (error) {
// History is best effort: a failed write must not break playback or
// the other writes confirmed by the same stream.
console.error('Failed to record recently viewed item:', error);
}
}
@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PlaybackHistoryGate } from '@iptvnator/services';
import { AudioPlayerComponent } from './audio-player.component';
describe('AudioPlayerComponent', () => {
@@ -176,6 +177,43 @@ describe('AudioPlayerComponent', () => {
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
it('records the station in history only after it has played', () => {
const audio = createComponent('https://example.com/station.mp3');
const commit = jest.fn();
TestBed.inject(PlaybackHistoryGate).defer(
['https://example.com/station.mp3'],
commit
);
const playTo = (position: number) => {
Object.defineProperty(audio, 'currentTime', {
configurable: true,
value: position,
});
audio.dispatchEvent(new Event('timeupdate'));
};
playTo(0);
playTo(1);
expect(commit).not.toHaveBeenCalled();
playTo(2);
expect(commit).toHaveBeenCalledTimes(1);
});
it('does not record a station that never advances', () => {
const audio = createComponent('https://example.com/dead.mp3');
const commit = jest.fn();
TestBed.inject(PlaybackHistoryGate).defer(
['https://example.com/dead.mp3'],
commit
);
audio.dispatchEvent(new Event('timeupdate'));
audio.dispatchEvent(new Event('timeupdate'));
expect(commit).not.toHaveBeenCalled();
});
it('pauses the stream when the component is destroyed', () => {
createComponent();
@@ -22,6 +22,8 @@ import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelActions } from '@iptvnator/m3u-state';
import { PlaybackHistoryGate } from '@iptvnator/services';
import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation';
@Component({
selector: 'app-audio-player',
@@ -156,7 +158,12 @@ import { ChannelActions } from '@iptvnator/m3u-state';
</div>
</div>
<audio preload="metadata" autoplay #audio></audio>
<audio
preload="metadata"
autoplay
#audio
(timeupdate)="onTimeUpdate()"
></audio>
</div>
`,
styleUrls: ['./audio-player.component.scss'],
@@ -198,6 +205,11 @@ export class AudioPlayerComponent {
private destroyRef = inject(DestroyRef);
private hostEl = inject(ElementRef);
private fallbackVolume = 1;
/** Commits the deferred "recently viewed" write once the station plays. */
private readonly historyConfirmation = new PlaybackHistoryConfirmation({
gate: inject(PlaybackHistoryGate),
keys: () => [this.url()],
});
constructor() {
const saved = parseFloat(localStorage.getItem('volume') ?? '1');
@@ -268,6 +280,11 @@ export class AudioPlayerComponent {
}
}
onTimeUpdate(): void {
const audio = this.audioRef()?.nativeElement;
if (audio) this.historyConfirmation.record(audio.currentTime);
}
play() {
const audio = this.audioRef()?.nativeElement;
if (!audio) return;
@@ -0,0 +1,59 @@
import type { PlaybackHistoryKeys } from '@iptvnator/services';
import { PlaybackHistoryConfirmation } from './playback-history-confirmation';
describe('PlaybackHistoryConfirmation', () => {
let keys: PlaybackHistoryKeys;
let revision: symbol;
let confirm: jest.Mock;
let confirmation: PlaybackHistoryConfirmation;
const play = (...positions: number[]) =>
positions.forEach((position) => confirmation.record(position));
beforeEach(() => {
keys = ['live:p1:a', 'http://stream/a'];
revision = Symbol('first');
confirm = jest.fn();
confirmation = new PlaybackHistoryConfirmation({
gate: { confirm },
keys: () => keys,
sourceRevision: () => revision,
});
});
it('confirms the keys of the stream that played', () => {
play(0, 1, 2);
expect(confirm).toHaveBeenCalledWith(['live:p1:a', 'http://stream/a']);
});
it('does not credit a new stream with the previous stream progress', () => {
play(0, 1.5);
keys = ['live:p1:b', 'http://stream/b'];
play(0, 0.5);
expect(confirm).not.toHaveBeenCalled();
play(1, 2);
expect(confirm).toHaveBeenCalledTimes(1);
expect(confirm).toHaveBeenCalledWith(['live:p1:b', 'http://stream/b']);
});
it('confirms a returning stream again after another one played', () => {
play(0, 1, 2);
keys = ['live:p1:b', 'http://stream/b'];
play(0, 1, 2);
keys = ['live:p1:a', 'http://stream/a'];
play(0, 1, 2);
expect(confirm).toHaveBeenCalledTimes(3);
});
it('keeps progress across an engine swap of the same stream', () => {
play(0, 1.5);
revision = Symbol('fallback');
play(30, 30.5);
expect(confirm).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,48 @@
import type {
PlaybackHistoryGate,
PlaybackHistoryKeys,
} 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;
/**
* Engine/source generation within the same keys (engine switch, live
* format fallback, reload). A change restarts the position clock but
* keeps the progress already seen.
*/
readonly sourceRevision?: () => unknown;
}
/**
* Confirms a player's current stream to the {@link PlaybackHistoryGate} once
* it has really played. What is playing is re-read on every report rather
* than tracked by an effect, so a report of a new stream can never be added
* to the progress of the one before it.
*/
export class PlaybackHistoryConfirmation {
private identity: string | null = null;
private sourceRevision: unknown = null;
private readonly progress = new PlaybackProgressConfirmation(() =>
this.options.gate.confirm(this.options.keys())
);
constructor(private readonly options: PlaybackHistoryConfirmationOptions) {}
record(position: number): void {
const identity = JSON.stringify(this.options.keys());
const sourceRevision = this.options.sourceRevision?.() ?? null;
if (identity !== this.identity) {
this.identity = identity;
this.sourceRevision = sourceRevision;
this.progress.reset();
} else if (sourceRevision !== this.sourceRevision) {
this.sourceRevision = sourceRevision;
this.progress.rebase();
}
this.progress.record(position);
}
}
@@ -0,0 +1,87 @@
import { PlaybackProgressConfirmation } from './playback-progress-confirmation';
describe('PlaybackProgressConfirmation', () => {
let onConfirmed: jest.Mock;
let confirmation: PlaybackProgressConfirmation;
const play = (...positions: number[]) =>
positions.forEach((position) => confirmation.record(position));
beforeEach(() => {
onConfirmed = jest.fn();
confirmation = new PlaybackProgressConfirmation(onConfirmed);
});
it('confirms once the stream has advanced for two seconds', () => {
play(0, 0.5, 1, 1.5);
expect(onConfirmed).not.toHaveBeenCalled();
play(2);
expect(onConfirmed).toHaveBeenCalledTimes(1);
});
it('confirms whole-second position reports (embedded MPV)', () => {
play(10, 10, 11, 11, 12);
expect(onConfirmed).toHaveBeenCalledTimes(1);
});
it('confirms only once per stream', () => {
play(0, 1, 2, 3, 4, 5);
expect(onConfirmed).toHaveBeenCalledTimes(1);
});
it('does not confirm a stream that stalls at its first frames', () => {
play(0, 0.4, 0.4, 0.4, 0.4, 0.4);
expect(onConfirmed).not.toHaveBeenCalled();
});
it('does not count a seek as watched time', () => {
// A VOD resume jumps straight to the saved position.
play(0, 1200, 1200.5);
expect(onConfirmed).not.toHaveBeenCalled();
});
it('does not count backwards jumps', () => {
play(5, 4, 3, 2, 1);
expect(onConfirmed).not.toHaveBeenCalled();
});
it('ignores non-finite positions', () => {
play(0, Number.NaN, Number.POSITIVE_INFINITY, 1);
expect(onConfirmed).not.toHaveBeenCalled();
});
it('starts over after reset', () => {
play(0, 1.5);
confirmation.reset();
play(100, 101);
expect(onConfirmed).not.toHaveBeenCalled();
play(102);
expect(onConfirmed).toHaveBeenCalledTimes(1);
});
it('confirms again for a new stream after reset', () => {
play(0, 1, 2);
confirmation.reset();
play(0, 1, 2);
expect(onConfirmed).toHaveBeenCalledTimes(2);
});
it('keeps the progress across a rebase but not the position', () => {
play(0, 1.5);
confirmation.rebase();
// The new engine's clock starts elsewhere; that jump is not counted.
play(50, 50.5);
expect(onConfirmed).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,61 @@
/** Seconds a stream must actually advance before it counts as watched. */
export const PLAYBACK_CONFIRMATION_SECONDS = 2;
/**
* Largest position step still counted as playback. Engines report every
* 250–500 ms, so anything larger is a seek (resume position, live-edge
* catch-up, ±10 s keys) rather than seconds the viewer has seen.
*/
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.
*/
export class PlaybackProgressConfirmation {
private lastPosition: number | null = null;
private progressedSeconds = 0;
private confirmed = false;
constructor(private readonly onConfirmed: () => void) {}
/** Starts over for a new stream. */
reset(): void {
this.lastPosition = null;
this.progressedSeconds = 0;
this.confirmed = false;
}
/**
* Keeps the progress but forgets the last position, for an engine or
* source swap within the same stream whose clock restarts elsewhere.
*/
rebase(): void {
this.lastPosition = null;
}
record(position: number): void {
if (this.confirmed || !Number.isFinite(position)) {
return;
}
const previous = this.lastPosition;
this.lastPosition = position;
if (previous === null) {
return;
}
const step = position - previous;
if (step <= 0 || step > MAX_PLAYBACK_STEP_SECONDS) {
return;
}
this.progressedSeconds += step;
if (this.progressedSeconds >= PLAYBACK_CONFIRMATION_SECONDS) {
this.confirmed = true;
this.onConfirmed();
}
}
}
@@ -0,0 +1,191 @@
import {
ComponentFixture,
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '@iptvnator/playback/util';
import {
PlaybackHistoryGate,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
STORE_KEY,
VideoPlayer,
type ExternalPlayerSession,
} from '@iptvnator/shared/interfaces';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { of } from 'rxjs';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
jest.unstable_mockModule('video.js', () => ({ default: jest.fn() }));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
describe('WebPlayerViewComponent playback history', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
let gate: PlaybackHistoryGate;
beforeAll(async () => {
({ WebPlayerViewComponent } =
await import('./web-player-view.component'));
});
beforeEach(async () => {
const externalSession = signal<ExternalPlayerSession | null>(null);
await TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Playthrough,
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
providers: [
{
provide: StorageMap,
useValue: {
get: (key: string) =>
of(
key === STORE_KEY.Settings
? { player: VideoPlayer.VideoJs }
: undefined
),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsManagedExternalPlayers: true },
},
{
provide: ElectronStreamHeadersService,
useValue: { apply: () => null, clear: jest.fn() },
},
{
provide: SettingsStore,
useValue: {
showCaptions: () => false,
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: externalSession,
visibleSession: externalSession,
dismissActiveSession: jest.fn(),
closeSession: jest.fn(),
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
set: {
imports: [
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
],
// The diagnostic panel itself is not under test here.
schemas: [CUSTOM_ELEMENTS_SCHEMA],
},
})
.compileComponents();
gate = TestBed.inject(PlaybackHistoryGate);
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('streamUrl', 'https://example.com/a');
fixture.componentRef.setInput('title', 'Channel A');
fixture.componentRef.setInput('playbackSessionKey', 'live:p1:a');
await render();
});
afterEach(() => fixture.destroy());
it('records a channel once its stream has played for two seconds', () => {
const commit = jest.fn();
gate.defer(['live:p1:a'], commit);
playTo(0, 1);
expect(commit).not.toHaveBeenCalled();
playTo(2);
expect(commit).toHaveBeenCalledTimes(1);
});
it('confirms by stream URL for writers that only know the link', () => {
const commit = jest.fn();
gate.defer(['https://example.com/a'], commit);
playTo(0, 1, 2);
expect(commit).toHaveBeenCalledTimes(1);
});
it('does not record a channel whose stream fails before playing', () => {
const commit = jest.fn();
gate.defer(['live:p1:a'], commit);
playTo(0);
vjs().playbackIssue.emit({
code: PlaybackDiagnosticCode.MediaDecodeError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://example.com/a',
container: 'm3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
});
fixture.detectChanges();
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);
await render();
playTo(0, 0.5);
expect(next).not.toHaveBeenCalled();
playTo(1, 2);
expect(next).toHaveBeenCalledTimes(1);
});
async function render(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
function playTo(...positions: number[]): void {
positions.forEach((currentTime) =>
vjs().timeUpdate.emit({ currentTime, duration: 0 })
);
}
function vjs(): StubVjsPlayerComponent {
return fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
.componentInstance as StubVjsPlayerComponent;
}
});
@@ -22,7 +22,11 @@ import {
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
PlaybackHistoryGate,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
VideoPlayer,
type Channel,
@@ -36,6 +40,7 @@ import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-
import { FullscreenChannelPanelComponent } from '../fullscreen-channel-panel/fullscreen-channel-panel.component';
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 PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
@@ -213,6 +218,16 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly resolvedIsLive = this.applicationState.isLive;
readonly playbackSourceRevisionToken = this.applicationState.sourceRevision;
readonly playbackApplicationToken = this.applicationState.token;
/** 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,
],
sourceRevision: () => this.playbackSourceRevisionToken(),
});
readonly playbackExternallyTransferable = computed(() =>
isPlaybackExternallyTransferable(this.resolvedPlayback())
);
@@ -377,6 +392,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
this.recoverySession.recordTimeUpdate(event, ownership.isLive);
this.historyConfirmation.record(event.currentTime);
this.timeUpdate.emit(event);
}