From 4b7a2449a5881eb9cc4d658c651654057438ffc7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 12:23:00 +0200 Subject: [PATCH] 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 --- .changes/playback-history-after-playback.md | 6 + .../src/dash-clearkey.e2e.ts | 4 +- .../src/dashboard-activation.e2e.ts | 26 +- .../src/live-sidebar-collapse.e2e.ts | 14 +- .../src/playable-stream-fixture.ts | 159 +++++++++++ .../src/player-theme.e2e.ts | 23 +- .../src/playlist-switcher.e2e.ts | 23 +- apps/electron-backend-e2e/src/recent.e2e.ts | 91 +++++-- apps/web-e2e/src/dash-clearkey.e2e.ts | 13 + apps/web-e2e/src/m3u-movie-details.e2e.ts | 15 +- .../external-playback.service.spec.ts | 33 ++- .../app/services/external-playback.service.ts | 10 +- docs/architecture/embedded-inline-playback.md | 33 +++ docs/architecture/workspace-dashboard.md | 3 + .../video-player-recent-history.spec.ts | 254 ++++++++++++++++++ .../video-player/video-player.component.ts | 43 ++- .../unified-live-selection.ts | 30 ++- .../unified-live-tab.component.spec.ts | 28 +- .../with-stalker-player.feature.spec.ts | 54 +++- .../features/with-stalker-player.feature.ts | 44 ++- .../src/lib/stores/stalker-store.contracts.ts | 7 +- .../serial-details-playback.service.ts | 18 +- .../serial-details.component.spec.ts | 30 ++- .../vod-details-playback.service.spec.ts | 36 ++- .../vod-details-playback.service.ts | 17 +- .../feature/src/lib/xtream-recent-history.ts | 33 +++ libs/services/src/index.ts | 1 + .../lib/playback-history-gate.service.spec.ts | 100 +++++++ .../src/lib/playback-history-gate.service.ts | 83 ++++++ .../audio-player.component.spec.ts | 38 +++ .../audio-player/audio-player.component.ts | 19 +- .../playback-history-confirmation.spec.ts | 59 ++++ .../playback-history-confirmation.ts | 48 ++++ .../playback-progress-confirmation.spec.ts | 87 ++++++ .../playback-progress-confirmation.ts | 61 +++++ .../web-player-view.component.history.spec.ts | 191 +++++++++++++ .../web-player-view.component.ts | 18 +- 37 files changed, 1634 insertions(+), 118 deletions(-) create mode 100644 .changes/playback-history-after-playback.md create mode 100644 apps/electron-backend-e2e/src/playable-stream-fixture.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-recent-history.ts create mode 100644 libs/services/src/lib/playback-history-gate.service.spec.ts create mode 100644 libs/services/src/lib/playback-history-gate.service.ts create mode 100644 libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts create mode 100644 libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts diff --git a/.changes/playback-history-after-playback.md b/.changes/playback-history-after-playback.md new file mode 100644 index 000000000..0b6935611 --- /dev/null +++ b/.changes/playback-history-after-playback.md @@ -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. diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index 511f6f070..89deaa4de 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -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') diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 86a19d0d0..5dfb3e3e0 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -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$/ ); diff --git a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts index 9c99fcad8..704a8b920 100644 --- a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts +++ b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts @@ -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( diff --git a/apps/electron-backend-e2e/src/playable-stream-fixture.ts b/apps/electron-backend-e2e/src/playable-stream-fixture.ts new file mode 100644 index 000000000..e161f457c --- /dev/null +++ b/apps/electron-backend-e2e/src/playable-stream-fixture.ts @@ -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 { + 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, + seconds = 2.5 +): Promise { + 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; + } + ).__e2eMediaStarts ??= new WeakMap()); + return [ + ...document.querySelectorAll( + '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 { + 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 { + 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), + }); +} diff --git a/apps/electron-backend-e2e/src/player-theme.e2e.ts b/apps/electron-backend-e2e/src/player-theme.e2e.ts index d7df3f324..5933ea7bc 100644 --- a/apps/electron-backend-e2e/src/player-theme.e2e.ts +++ b/apps/electron-backend-e2e/src/player-theme.e2e.ts @@ -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', diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts index dbf6e0271..024972a30 100644 --- a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -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) diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index d419d180e..a82a4f086 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -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( diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts index fcb0b5f06..1f2efc953 100644 --- a/apps/web-e2e/src/dash-clearkey.e2e.ts +++ b/apps/web-e2e/src/dash-clearkey.e2e.ts @@ -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', diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index be2a49cd9..b14be957d 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -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(); diff --git a/apps/web/src/app/services/external-playback.service.spec.ts b/apps/web/src/app/services/external-playback.service.spec.ts index ab7fee381..043bb1a3f 100644 --- a/apps/web/src/app/services/external-playback.service.spec.ts +++ b/apps/web/src/app/services/external-playback.service.spec.ts @@ -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', () => { diff --git a/apps/web/src/app/services/external-playback.service.ts b/apps/web/src/app/services/external-playback.service.ts index 161848f00..4e389ee8b 100644 --- a/apps/web/src/app/services/external-playback.service.ts +++ b/apps/web/src/app/services/external-playback.service.ts @@ -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(null); private readonly dismissedSessionId = signal(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; diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 590e8bbfa..68bd585ea 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 51f61c67c..995e2ba63 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -74,6 +74,9 @@ Render rules: 2. `hasPlaylists() === false` → render `` 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 diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts new file mode 100644 index 000000000..a94714dc5 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts @@ -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; + let component: VideoPlayerComponentInstance; + let gate: PlaybackHistoryGate; + const activePlaylist = signal | 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) + ), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: externalSession }, + }, + ], + }) + .overrideComponent(VideoPlayerComponent, { + set: { + imports: [], + 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 + ); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 7148734de..832c8572c 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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); 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(() => { + /** + * 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( + () => 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 diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts index aa91b7fe9..ac43d9ab1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts @@ -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 => { + 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() && diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 1ef25911a..5c6844f4d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -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 { + 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({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index ce6850aba..97c68a535 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 7d43ce4c1..9f5dd9f8d 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -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 => { 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, diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts index d8b238dd3..882e93d16 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts @@ -59,10 +59,6 @@ export interface StalkerSeriesFeatureStoreContract vodSeriesSeasonsResource: ResourceState; } -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 diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 07078021d..cfb30358d 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -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 | null diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 518bdafba..47510bac3 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -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(); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 63c39333f..31586ddda 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -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(); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 62529c89f..8a8967df8 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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); diff --git a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts new file mode 100644 index 000000000..5a182f6b1 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -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 }) + ); + }; +} diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index cf052bf34..987fc1118 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/playback-history-gate.service.spec.ts b/libs/services/src/lib/playback-history-gate.service.spec.ts new file mode 100644 index 000000000..84b7b109c --- /dev/null +++ b/libs/services/src/lib/playback-history-gate.service.spec.ts @@ -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(); + }); +}); diff --git a/libs/services/src/lib/playback-history-gate.service.ts b/libs/services/src/lib/playback-history-gate.service.ts new file mode 100644 index 000000000..a304d0bd7 --- /dev/null +++ b/libs/services/src/lib/playback-history-gate.service.ts @@ -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; + 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 { + 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); + } +} diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts index 6ff831007..a0fbee4bf 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts @@ -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(); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index e49cc7ef7..116c2563e 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -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'; - + `, 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; diff --git a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts new file mode 100644 index 000000000..8dd568a78 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts new file mode 100644 index 000000000..4819b6156 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts @@ -0,0 +1,48 @@ +import type { + PlaybackHistoryGate, + PlaybackHistoryKeys, +} from '@iptvnator/services'; +import { PlaybackProgressConfirmation } from './playback-progress-confirmation'; + +export interface PlaybackHistoryConfirmationOptions { + readonly gate: Pick; + /** 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); + } +} diff --git a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts new file mode 100644 index 000000000..3a8351a7f --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts new file mode 100644 index 000000000..c8ad01ade --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts @@ -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(); + } + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts new file mode 100644 index 000000000..f0e5a098d --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts @@ -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; + let gate: PlaybackHistoryGate; + + beforeAll(async () => { + ({ WebPlayerViewComponent } = + await import('./web-player-view.component')); + }); + + beforeEach(async () => { + const externalSession = signal(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 { + 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; + } +}); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 75be963e1..c55853208 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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); }