mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(playback): record recently viewed only after the stream plays (#1732)
* feat(playback): record recently viewed only after the stream plays A channel, movie or episode used to enter Recently Viewed (and the dashboard's Continue Watching hero) the moment it was selected or its link was resolved, so streams that failed straight away cluttered the history. Writers now defer the write to a root PlaybackHistoryGate, keyed by the stream URL and/or the playback session key. The inline players confirm those keys once the owned engine's position has advanced by two seconds (seeks, stalls, pauses and a previous stream's progress do not count), the radio player does the same, and a launched MPV/VLC session confirms on `opened`/`playing`. M3U with MPV/VLC configured keeps recording on selection. Covers M3U (live, radio, movie detail), Stalker (live, radio, VOD, series), Xtream VOD and series, and the global live collection. The M3U host's embeddedPlayback is now compared by value: the history write updates the playlist meta mid-playback, and a new but identical playback object remounted the engine and restarted the stream. E2E flows that relied on recording-on-click now play local fixtures (HLS/TS/WebM routed in place of unreachable or public streams) and wait for confirmed playback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): tighten recently viewed confirmation per review - Correlate by session key first: when both the deferred write and the confirmation carry a playbackSessionKey, only that is compared, so the same stream URL played in another playlist no longer records a failed attempt. URLs remain the fallback (portal writes, MPV/VLC sessions). The M3U radio player now receives the host's session key. - Count only playing progress: engines report `playing` (not paused, not seeking) with each time update, so short seeks of paused media no longer confirm a view. - Xtream: a write confirmed after a playlist switch still saves to its own playlist but no longer replaces the current playlist's recent list. - Global live tab: a row confirmed after another row was selected still moves to the top of an open Recently Viewed list (only a disposed tab skips the notification). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): confirm session-keyed history only by its own session A write deferred with a playback session key (M3U) is now confirmed only by that key. The app-wide MPV/VLC session confirmation carries just the URL, so opening the same stream externally from another playlist could still commit an abandoned attempt. An "Open in MPV/VLC" recovery launch is instead confirmed by the WebPlayerViewComponent that requested it, under its own session key, once the launch has opened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(playback): keep the history gate off the initial bundle The `@iptvnator/services` barrel ships in the initial bundle, so adding PlaybackHistoryGate there (and subscribing to it from the app-wide ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes. - Move the gate to a new lazy-only `playback-data-access` project (`@iptvnator/playback/data-access`; scope:shared, domain:playback, type:data-access) and register it in the coverage policy. - The gate subscribes to MPV/VLC session updates itself; it is created by the first deferred write, which precedes the launch it waits for. ExternalPlaybackService is back to master. - The Xtream "playlist switched before confirmation" check moves to the lazy helper; the initial-path store only takes a `skipListRefresh` flag. Net effect on this branch: +27 bytes over master (master itself is 108 bytes over the ratchet baseline already). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(playback): drop late Xtream confirmations off the initial path The Xtream store ships in the initial bundle, so even the small `skipListRefresh` flag cost 27 bytes there. A confirmation can only arrive after a switch to another playlist from a slow MPV/VLC launch (the inline player goes with the page), so the lazy helper now drops it instead: recording it would misfile the item or replace the other playlist's recent list. with-recent-items is back to master. This branch is now 3 bytes below master on renderer.initialBytes; the ratchet still reports master's pre-existing overage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(playback): pass the spec type-check gate from master - playback-data-access: align tsconfig.spec.json with the epg-data-access config #1705 updated (bundler resolution, global.d.ts for window.electron). - M3U recent-history spec: type the selectSignal override. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep late Xtream confirmations in their own playlist history A confirmation that arrives after a switch to another playlist (a slow MPV/VLC launch) is no longer dropped: the lazy helper saves it to the captured playlist through the data source, without reloading the store's recent list, which belongs to the other playlist by then. The store and its barrel ship in the initial bundle, so the save path stays in the feature helper; renderer.initialBytes stays under the baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): correlate global live-tab history by its session key The unified Favorites/Recent live tab deferred its history write by stream URL only, so the same URL played from another playlist could confirm a failed selection, and a switch to catch-up before confirmation could never match. It now defers with the tab's playlist-scoped playbackSessionKey (the key its players confirm with), and the tab's radio player receives it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep MPV/VLC rows of the live tab confirmable by URL The live tab's session key can only be confirmed by its own inline players; MPV/VLC confirm the launched URL alone. A row that goes to an external player (also later, after a double-click) now defers by URL, and only rows played inline carry the session key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): per-channel M3U history attempts, capability-based Xtream fallback - M3U: the recently-viewed dedupe key now includes the channel id, so a second row of the same URL defers its own write (its session key) and is recorded when it plays after the first row failed. - Xtream late write: key uncached content by Xtream id per supportsXtreamSqliteDataSource (the data-source factory's contract), not by a generic Electron bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
61 files changed
+2348
-133
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
---
|
||||
|
||||
Channels, movies and episodes now join Recently Viewed and the dashboard's Continue Watching hero only after they have actually played for a couple of seconds, so streams that fail right away no longer clutter your history.
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
waitForM3uCatalog,
|
||||
workspaceRoot,
|
||||
} from './electron-test-fixtures';
|
||||
import { startAndConfirmPlayback } from './playable-stream-fixture';
|
||||
|
||||
/**
|
||||
* DASH + ClearKey playback in the real Electron runtime — the only automated
|
||||
@@ -541,7 +542,8 @@ for (const configuredPlayer of ['videojs', 'mpv', 'artplayer']) {
|
||||
buildDashPlaylist(fixtureServer.origin)
|
||||
);
|
||||
const channel = channelItemByTitle(page, 'ClearKey DASH').first();
|
||||
await channel.click();
|
||||
// Recent history records the channel once it has really played.
|
||||
await startAndConfirmPlayback(page, () => channel.click());
|
||||
await channel.locator('.favorite-button').click();
|
||||
await expect(
|
||||
channel.locator('.favorite-button mat-icon')
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
getXtreamTitle,
|
||||
pickDistinctTitles,
|
||||
} from './portal-mock-fixtures';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
|
||||
test.describe('Dashboard Activation', () => {
|
||||
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
|
||||
@@ -76,6 +80,7 @@ test.describe('Dashboard Activation', () => {
|
||||
return new RegExp(titles.join('|'));
|
||||
};
|
||||
const app = await launchElectronApp(dataDir);
|
||||
await routePlayableStreams(app.mainWindow);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
@@ -99,7 +104,10 @@ test.describe('Dashboard Activation', () => {
|
||||
);
|
||||
const movieTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await playCurrentDetail(app.mainWindow);
|
||||
// Recorded as recently viewed once it has really played.
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
playCurrentDetail(app.mainWindow)
|
||||
);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
@@ -111,7 +119,9 @@ test.describe('Dashboard Activation', () => {
|
||||
);
|
||||
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await playFirstSeriesEpisode(app.mainWindow);
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
playFirstSeriesEpisode(app.mainWindow)
|
||||
);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
|
||||
@@ -176,11 +186,13 @@ test.describe('Dashboard Activation', () => {
|
||||
liveTitle
|
||||
).locator('.rail__channel-now')
|
||||
).toContainText(liveNowTitle(), { timeout: 30000 });
|
||||
await dashboardRailCardByTitle(
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-live-favorites-rail',
|
||||
liveTitle
|
||||
).click();
|
||||
).click()
|
||||
);
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/favorites$/
|
||||
);
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamLiveFixture } from './portal-mock-fixtures';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
|
||||
/**
|
||||
* Issue #1458, second report: "all channels disappear after clearing the
|
||||
@@ -136,6 +140,7 @@ test.describe('Live channel rail collapse (#1458)', () => {
|
||||
CHANNELS
|
||||
);
|
||||
const app: LaunchedElectronApp = await launchElectronApp(dataDir);
|
||||
await routePlayableStreams(app.mainWindow);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
@@ -143,9 +148,12 @@ test.describe('Live channel rail collapse (#1458)', () => {
|
||||
const playlistId = playlistIdFromUrl(app.mainWindow);
|
||||
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
|
||||
|
||||
await channelItemByTitle(app.mainWindow, 'Channel Alpha')
|
||||
// History records a channel only once it has really played.
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
channelItemByTitle(app.mainWindow, 'Channel Alpha')
|
||||
.first()
|
||||
.click();
|
||||
.click()
|
||||
);
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { expect, type Page, type Route } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { xtreamMockServer } from './electron-test-fixtures';
|
||||
|
||||
/**
|
||||
* Local, deterministic media for flows that must really play.
|
||||
*
|
||||
* A channel, movie or episode is only recorded as recently viewed once its
|
||||
* stream has advanced for two seconds, so history tests can no longer use
|
||||
* unreachable URLs or the public HLS streams the portal mocks redirect to.
|
||||
* These routes answer those requests from the renderer with fixtures that
|
||||
* the Electron player actually decodes:
|
||||
* - `.m3u8` → a one-segment HLS playlist over the H.264/AAC MPEG-TS clip;
|
||||
* - `.ts` → that clip (6 s) directly;
|
||||
* - anything else (mp4, mkv, avi, webm) → the VP8 WebM clip (30 s), which
|
||||
* Chromium sniffs from its bytes regardless of the declared type.
|
||||
*/
|
||||
const SEGMENT_URL = 'https://playable-stream-fixture.test/segment.ts';
|
||||
const transportStream = readFileSync(
|
||||
join(__dirname, '../../xtream-mock-server/src/fixtures/live.mpegts')
|
||||
);
|
||||
const webmClip = readFileSync(
|
||||
join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm')
|
||||
);
|
||||
const hlsPlaylist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXTINF:6,',
|
||||
SEGMENT_URL,
|
||||
'#EXT-X-ENDLIST',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/** Hosts whose streams tests play: fixtures, portal mocks, mock redirects. */
|
||||
const PLAYABLE_STREAM_HOSTS = [
|
||||
'https://streams.example.test',
|
||||
'https://playable-stream-fixture.test',
|
||||
// Stalker mock `create_link` answers and the Xtream mock redirect target.
|
||||
'https://test-streams.mux.dev',
|
||||
'https://devstreaming-cdn.apple.com',
|
||||
'https://playertest.longtailvideo.com',
|
||||
];
|
||||
const XTREAM_MEDIA_PATH = /^\/(live|movie|series)\//;
|
||||
|
||||
export async function routePlayableStreams(page: Page): Promise<void> {
|
||||
await page.route(
|
||||
(url) =>
|
||||
PLAYABLE_STREAM_HOSTS.includes(url.origin) ||
|
||||
(url.origin === new URL(xtreamMockServer).origin &&
|
||||
XTREAM_MEDIA_PATH.test(url.pathname)),
|
||||
(route) => fulfillPlayableStream(route)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `start` and waits until a media element it brought up has played for
|
||||
* `seconds` of real stream time — longer than the two seconds after which
|
||||
* the app records the item. Elements already on the page are ignored, so a
|
||||
* still-mounted previous player cannot satisfy the wait.
|
||||
*/
|
||||
export async function startAndConfirmPlayback(
|
||||
page: Page,
|
||||
start: () => Promise<void>,
|
||||
seconds = 2.5
|
||||
): Promise<void> {
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.querySelectorAll('video, audio')
|
||||
.forEach((media) => media.setAttribute('data-e2e-previous', ''))
|
||||
);
|
||||
await start();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((minimum) => {
|
||||
const starts = ((
|
||||
window as unknown as {
|
||||
__e2eMediaStarts?: WeakMap<Element, number>;
|
||||
}
|
||||
).__e2eMediaStarts ??= new WeakMap());
|
||||
return [
|
||||
...document.querySelectorAll<HTMLMediaElement>(
|
||||
'video:not([data-e2e-previous]), audio:not([data-e2e-previous])'
|
||||
),
|
||||
].some((media) => {
|
||||
if (media.paused || media.readyState < 2) {
|
||||
return false;
|
||||
}
|
||||
if (!starts.has(media)) {
|
||||
starts.set(media, media.currentTime);
|
||||
}
|
||||
return (
|
||||
media.currentTime - (starts.get(media) ?? 0) >=
|
||||
minimum
|
||||
);
|
||||
});
|
||||
}, seconds),
|
||||
{ timeout: 45_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function fulfillPlayableStream(route: Route): Promise<void> {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname.endsWith('.m3u8')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/vnd.apple.mpegurl',
|
||||
body: hlsPlaylist,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname.endsWith('.ts')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'video/mp2t',
|
||||
body: transportStream,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillRange(route, webmClip, 'video/webm');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromium's media pipeline reads files through byte ranges; answering a
|
||||
* Range request with the whole body would clamp every read to the start.
|
||||
*/
|
||||
async function fulfillRange(
|
||||
route: Route,
|
||||
body: Buffer,
|
||||
contentType: string
|
||||
): Promise<void> {
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = body.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, body.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': contentType,
|
||||
...(range
|
||||
? { 'content-range': `bytes ${start}-${end}/${body.length}` }
|
||||
: {}),
|
||||
},
|
||||
body: body.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
@@ -139,15 +139,20 @@ for (const engine of [
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
} else {
|
||||
// Past the two seconds after which the channel is recorded
|
||||
// as recently viewed: the HTML5 pass reopens it from there.
|
||||
await expect
|
||||
.poll(() =>
|
||||
.poll(
|
||||
() =>
|
||||
app.mainWindow
|
||||
.locator('app-web-player-view video')
|
||||
.evaluate(
|
||||
(video: HTMLVideoElement) => video.currentTime
|
||||
(video: HTMLVideoElement) =>
|
||||
video.currentTime
|
||||
),
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
.toBeGreaterThan(2.5);
|
||||
}
|
||||
const controls = app.mainWindow.locator(
|
||||
engine === 'native'
|
||||
@@ -159,6 +164,10 @@ for (const engine of [
|
||||
exact: true,
|
||||
});
|
||||
await expect(pause).toBeEnabled({ timeout: 20000 });
|
||||
if (engine !== 'native') {
|
||||
// Seconds of playback let the controls auto-hide; reveal them.
|
||||
await app.mainWindow.locator('app-web-player-view').hover();
|
||||
}
|
||||
await pause.click();
|
||||
const play = controls.getByRole('button', {
|
||||
name: 'Play',
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
writeTemporaryM3uFile,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
|
||||
const xtreamStressUsername = 'stress';
|
||||
const xtreamStressPassword = 'stress';
|
||||
@@ -202,6 +206,7 @@ test.describe('Electron Playlist Switcher', () => {
|
||||
},
|
||||
]);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
await routePlayableStreams(app.mainWindow);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localAPath);
|
||||
@@ -213,9 +218,12 @@ test.describe('Electron Playlist Switcher', () => {
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await toggleFavoriteForChannel(app.mainWindow, localAChannelName);
|
||||
await channelItemByTitle(app.mainWindow, localAChannelName)
|
||||
// Recorded as recently viewed once it has really played.
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
channelItemByTitle(app.mainWindow, localAChannelName)
|
||||
.first()
|
||||
.click();
|
||||
.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)
|
||||
// Recorded as recently viewed once it has really played.
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
channelItemByTitle(app.mainWindow, localBChannelName)
|
||||
.first()
|
||||
.click();
|
||||
.click()
|
||||
);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, localADisplayName)
|
||||
|
||||
@@ -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')
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
channelItemByTitle(app.mainWindow, 'Stable Recent Channel')
|
||||
.first()
|
||||
.click();
|
||||
.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')
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
channelItemByTitle(app.mainWindow, 'Recent Channel One')
|
||||
.first()
|
||||
.click();
|
||||
await channelItemByTitle(app.mainWindow, 'Recent Channel Two')
|
||||
.click()
|
||||
);
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
channelItemByTitle(app.mainWindow, 'Recent Channel Two')
|
||||
.first()
|
||||
.click();
|
||||
.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')
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
channelItemByTitle(app.mainWindow, 'Context Recent One')
|
||||
.first()
|
||||
.click();
|
||||
await channelItemByTitle(app.mainWindow, 'Context Recent Two')
|
||||
.click()
|
||||
);
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
channelItemByTitle(app.mainWindow, 'Context Recent Two')
|
||||
.first()
|
||||
.click();
|
||||
.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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1129,6 +1129,58 @@ 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/playback/data-access`) with
|
||||
`defer(target, commit)`, where the target is what the playback will be
|
||||
known by: the host's `playbackSessionKey` (M3U) and/or stream URLs. The Stalker resolver defers
|
||||
by the resolved (possibly temporary) link; the persisted row still stores
|
||||
the portal `cmd`, never that link. Writers capture the item and its
|
||||
playlist when they defer, so navigating meanwhile cannot misfile it; an
|
||||
Xtream write confirmed after a switch to another playlist (only a slow
|
||||
MPV/VLC launch can) is saved to its own playlist without reloading the
|
||||
store's recent list, which belongs to the other playlist by then.
|
||||
- Matching: a write deferred with a session key is confirmed only by that
|
||||
same key — the same URL in two playlists must not let playback in one
|
||||
(inline, or in MPV/VLC) record a failed attempt in the other. Writes
|
||||
without one (portal resolvers) match any confirmation of their stream URL.
|
||||
The global live tab defers with its own playlist-scoped session key, which
|
||||
also survives a switch to catch-up, when the row plays inline; a row that
|
||||
goes to MPV/VLC defers by URL, the only thing that launch confirms.
|
||||
- `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl`
|
||||
and `playback.streamUrl` once the owned engine's reported position has
|
||||
advanced by 2 seconds while playing (`PlaybackProgressConfirmation`).
|
||||
Engines report `playing` (not paused, not seeking) with each time update,
|
||||
so seeks of paused media do not count; neither do steps above 3 seconds
|
||||
(seeks, a resume jump, live-edge catch-up), stalls or backwards jumps. A
|
||||
report of a new stream never adds to the progress of the previous one; an
|
||||
engine or format swap of the same stream keeps its progress. The radio
|
||||
`AudioPlayerComponent` confirms the same way (with the host's session key
|
||||
when given).
|
||||
- MPV/VLC cannot report whether a live stream plays, so the gate itself
|
||||
subscribes to the Electron external-player session updates and confirms a
|
||||
session's `streamUrl` once it is `opened` or `playing`; a launch that ends
|
||||
in `error` is not recorded. (Subscribing in the gate, which the first
|
||||
deferred write creates, keeps it off the initial bundle.) That
|
||||
confirmation carries no session key, so an "Open in MPV/VLC" recovery
|
||||
launch is also confirmed by the `WebPlayerViewComponent` that requested
|
||||
it, under its own session key, once the launch has opened. M3U keeps
|
||||
recording on selection when MPV/VLC is the configured player.
|
||||
- A confirmation commits every write matching it, once. Unconfirmed writes
|
||||
are bounded (oldest dropped) and simply never commit. The global live tab
|
||||
moves a confirmed row to the top of an open Recently Viewed list even if
|
||||
another row was selected meanwhile.
|
||||
- A committed write updates the source while the item keeps playing. Hosts
|
||||
must not hand the player a new but identical playback for it: the M3U
|
||||
host's `embeddedPlayback` also reads the playlist meta, so it is compared
|
||||
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
|
||||
|
||||
@@ -189,6 +189,14 @@ other utility projects, including shared interface contracts, while
|
||||
`ui-playback` and feature hosts may depend on it to render and execute
|
||||
session-local recovery actions.
|
||||
|
||||
`libs/playback/data-access` is the `playback-data-access` Nx project,
|
||||
imported through `@iptvnator/playback/data-access` (tags `scope:shared`,
|
||||
`domain:playback`, `type:data-access`). It owns renderer playback state that
|
||||
store, feature and player layers share — currently `PlaybackHistoryGate`,
|
||||
which holds "recently viewed" writes until playback is confirmed. It is kept
|
||||
out of `@iptvnator/services` on purpose: that barrel ships in the initial
|
||||
bundle, and this project is only reached from lazy player and portal code.
|
||||
|
||||
## Project Tags
|
||||
|
||||
Every Nx project keeps one tag from each family in `project.json`:
|
||||
|
||||
@@ -75,7 +75,10 @@ Render rules:
|
||||
full-bleed. All rails and the hero are skipped.
|
||||
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
|
||||
see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while
|
||||
the first history load runs.
|
||||
the first history load runs. 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 a hero slide.
|
||||
4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden
|
||||
— there is no "empty widget" placeholder.
|
||||
5. Hero slides prefer a stored `backdrop_url`, then the TMDB backdrop; when
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
displayName: 'playback-data-access',
|
||||
preset: '../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../coverage/libs/playback/data-access',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "playback-data-access",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playback/data-access/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:playback", "type:data-access"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/playback/data-access/jest.config.ts",
|
||||
"tsConfig": "libs/playback/data-access/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/playback-history-gate.service';
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackHistoryGate } from './playback-history-gate.service';
|
||||
|
||||
describe('PlaybackHistoryGate', () => {
|
||||
let gate: PlaybackHistoryGate;
|
||||
|
||||
beforeEach(() => {
|
||||
gate = new PlaybackHistoryGate();
|
||||
});
|
||||
|
||||
it('holds a write until its playback is confirmed', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer(
|
||||
{ sessionKey: 'live:p1:c1', streamUrls: ['http://stream/1'] },
|
||||
commit
|
||||
);
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
|
||||
gate.confirm({ sessionKey: '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({ streamUrls: ['http://stream/failed'] }, failed);
|
||||
gate.defer({ streamUrls: ['http://stream/played'] }, played);
|
||||
|
||||
gate.confirm({ streamUrls: ['http://stream/played'] });
|
||||
|
||||
expect(failed).not.toHaveBeenCalled();
|
||||
expect(played).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let the same URL played in another playlist confirm a write', () => {
|
||||
// Playlist A's attempt failed; the same stream then plays in B.
|
||||
const failedInA = jest.fn();
|
||||
const playedInB = jest.fn();
|
||||
gate.defer(
|
||||
{ sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] },
|
||||
failedInA
|
||||
);
|
||||
gate.defer(
|
||||
{ sessionKey: 'live:b:c9', streamUrls: ['http://shared/1'] },
|
||||
playedInB
|
||||
);
|
||||
|
||||
gate.confirm({
|
||||
sessionKey: 'live:b:c9',
|
||||
streamUrls: ['http://shared/1'],
|
||||
});
|
||||
|
||||
expect(failedInA).not.toHaveBeenCalled();
|
||||
expect(playedInB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let a URL-only confirmation commit a write deferred with a session key', () => {
|
||||
// Playlist A's attempt failed; the same URL then opens in MPV/VLC,
|
||||
// whose app-wide session confirmation carries no session key.
|
||||
const failedInA = jest.fn();
|
||||
gate.defer(
|
||||
{ sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] },
|
||||
failedInA
|
||||
);
|
||||
|
||||
gate.confirm({ streamUrls: ['http://shared/1'] });
|
||||
|
||||
expect(failedInA).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches writes without a session key by stream URL', () => {
|
||||
const portalWrite = jest.fn();
|
||||
const externalWrite = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://portal/tmp'] }, portalWrite);
|
||||
gate.defer({ streamUrls: ['http://portal/vod'] }, externalWrite);
|
||||
|
||||
// An inline player (with its own key) and an MPV/VLC session.
|
||||
gate.confirm({
|
||||
sessionKey: 'live:portal:9',
|
||||
streamUrls: ['http://portal/tmp'],
|
||||
});
|
||||
gate.confirm({ streamUrls: ['http://portal/vod'] });
|
||||
|
||||
expect(portalWrite).toHaveBeenCalledTimes(1);
|
||||
expect(externalWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits each confirmed write once', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://stream/1'] }, commit);
|
||||
|
||||
gate.confirm({ streamUrls: ['http://stream/1'] });
|
||||
gate.confirm({ streamUrls: ['http://stream/1'] });
|
||||
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits every writer deferred for the same playback', () => {
|
||||
const first = jest.fn();
|
||||
const second = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://stream/1'] }, first);
|
||||
gate.defer({ streamUrls: ['http://stream/1'] }, second);
|
||||
|
||||
gate.confirm({ streamUrls: ['http://stream/1'] });
|
||||
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits immediately when a write has nothing to match on', () => {
|
||||
const commit = jest.fn();
|
||||
|
||||
gate.defer({ sessionKey: ' ', streamUrls: [undefined, null] }, commit);
|
||||
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores confirmations with nothing to match on', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://stream/1'] }, commit);
|
||||
|
||||
gate.confirm({ streamUrls: [undefined, ''] });
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the oldest unconfirmed writes beyond its bound', () => {
|
||||
const oldest = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://stream/oldest'] }, oldest);
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
gate.defer({ streamUrls: [`http://stream/${index}`] }, jest.fn());
|
||||
}
|
||||
|
||||
gate.confirm({ streamUrls: ['http://stream/oldest'] });
|
||||
|
||||
expect(oldest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('MPV/VLC sessions', () => {
|
||||
const originalElectron = window.electron;
|
||||
let emit: (session: Partial<ExternalPlayerSession>) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onExternalPlayerSessionUpdate: (
|
||||
callback: (session: ExternalPlayerSession) => void
|
||||
) => {
|
||||
emit = (session) =>
|
||||
callback({
|
||||
streamUrl: 'http://vod/1.mkv',
|
||||
...session,
|
||||
} as ExternalPlayerSession);
|
||||
return () => undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
gate = new PlaybackHistoryGate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: originalElectron,
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms a stream once its external player has opened', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit);
|
||||
|
||||
emit({ status: 'launching' });
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
|
||||
emit({ status: 'opened' });
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not confirm a launch that failed', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit);
|
||||
|
||||
emit({ status: 'launching' });
|
||||
emit({ status: 'error' });
|
||||
|
||||
expect(commit).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({ streamUrls: ['http://stream/1'] }, failing);
|
||||
gate.defer({ streamUrls: ['http://stream/1'] }, healthy);
|
||||
|
||||
gate.confirm({ streamUrls: ['http://stream/1'] });
|
||||
|
||||
expect(healthy).toHaveBeenCalledTimes(1);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/** What a playback is known by, for correlating a write with its playback. */
|
||||
export interface PlaybackHistoryTarget {
|
||||
/**
|
||||
* The playing host's `playbackSessionKey` (source and content scoped).
|
||||
* A write deferred with one is only confirmed by that same key.
|
||||
*/
|
||||
readonly sessionKey?: string | null;
|
||||
/** Stream URLs; what a write without a session key is matched by. */
|
||||
readonly streamUrls?: readonly (string | null | undefined)[];
|
||||
}
|
||||
|
||||
interface NormalizedTarget {
|
||||
readonly sessionKey: string | null;
|
||||
readonly streamUrls: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface PendingHistoryWrite {
|
||||
readonly target: NormalizedTarget;
|
||||
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
|
||||
* what the playback will be known by, and whoever observes the playback
|
||||
* `confirm`s it: the inline players once the stream has advanced for a
|
||||
* couple of seconds, and the external-player session as soon as MPV/VLC is
|
||||
* launched. A stream that fails before that point never reaches history or
|
||||
* the dashboard hero.
|
||||
*
|
||||
* A write deferred with a session key is only confirmed by the same key: the
|
||||
* same stream URL can sit in two playlists, and playing it in one — inline
|
||||
* or in MPV/VLC, whose app-wide confirmation knows only the URL — must not
|
||||
* record a failed attempt in the other. Writers that cannot know the
|
||||
* playing host's key (portal resolvers, collection tabs) defer by stream
|
||||
* URL, which any confirmation of that URL matches.
|
||||
* Several writers may defer for the same playback; one confirmation commits
|
||||
* all of them. A write with nothing to match on cannot be confirmed and is
|
||||
* committed immediately, as before this gate existed.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaybackHistoryGate {
|
||||
private pending: PendingHistoryWrite[] = [];
|
||||
|
||||
constructor() {
|
||||
// MPV/VLC cannot report whether a live stream plays, so a launch
|
||||
// that opened is the confirmation. Subscribed here rather than in
|
||||
// the app-wide external-playback service so the gate stays off the
|
||||
// initial bundle: it is created by the first deferred write, which
|
||||
// always precedes the launch it waits for.
|
||||
window.electron?.onExternalPlayerSessionUpdate?.((session) => {
|
||||
if (session.status === 'opened' || session.status === 'playing') {
|
||||
this.confirm({ streamUrls: [session.streamUrl] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
defer(target: PlaybackHistoryTarget, commit: () => void): void {
|
||||
const normalized = normalizeTarget(target);
|
||||
if (!normalized.sessionKey && normalized.streamUrls.size === 0) {
|
||||
runCommit(commit);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.push({ target: normalized, commit });
|
||||
if (this.pending.length > MAX_PENDING_HISTORY_WRITES) {
|
||||
this.pending.shift();
|
||||
}
|
||||
}
|
||||
|
||||
confirm(target: PlaybackHistoryTarget): void {
|
||||
const confirmed = normalizeTarget(target);
|
||||
const matched: PendingHistoryWrite[] = [];
|
||||
const remaining: PendingHistoryWrite[] = [];
|
||||
for (const write of this.pending) {
|
||||
(matchesTarget(write.target, confirmed) ? matched : remaining).push(
|
||||
write
|
||||
);
|
||||
}
|
||||
this.pending = remaining;
|
||||
matched.forEach((write) => runCommit(write.commit));
|
||||
}
|
||||
}
|
||||
|
||||
function matchesTarget(
|
||||
write: NormalizedTarget,
|
||||
confirmed: NormalizedTarget
|
||||
): boolean {
|
||||
if (write.sessionKey) {
|
||||
return write.sessionKey === confirmed.sessionKey;
|
||||
}
|
||||
return [...write.streamUrls].some((url) => confirmed.streamUrls.has(url));
|
||||
}
|
||||
|
||||
function normalizeTarget(target: PlaybackHistoryTarget): NormalizedTarget {
|
||||
return {
|
||||
sessionKey: normalizeKey(target.sessionKey),
|
||||
streamUrls: new Set(
|
||||
(target.streamUrls ?? [])
|
||||
.map(normalizeKey)
|
||||
.filter((url): url is string => url !== null)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKey(key: string | null | undefined): string | null {
|
||||
const trimmed = key?.trim() ?? '';
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function runCommit(commit: () => void): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Keep Zone's fakeAsync helpers while preserving the Angular 21 test scheduler.
|
||||
import 'zone.js';
|
||||
import 'zone.js/testing';
|
||||
import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless';
|
||||
|
||||
setupZonelessTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve",
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts", "../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
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,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import {
|
||||
dataServiceMock,
|
||||
epgServiceMock,
|
||||
epgUrlSetting,
|
||||
epgViewMode,
|
||||
externalSession,
|
||||
player,
|
||||
playlistId,
|
||||
playlistsServiceMock,
|
||||
routerMock,
|
||||
sampleChannel,
|
||||
showCaptions,
|
||||
storeMock,
|
||||
stripCountryPrefix,
|
||||
syncStoreState,
|
||||
translateServiceProvider,
|
||||
} from './video-player.spec-harness';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
|
||||
/**
|
||||
* When a selected M3U channel becomes a recently viewed item (and with it
|
||||
* the dashboard hero). Kept apart from `video-player.component.spec.ts`,
|
||||
* which sits at the spec line budget; the template is reduced to nothing,
|
||||
* because the players themselves are stood in for by the history gate.
|
||||
*/
|
||||
describe('VideoPlayerComponent — recently viewed history', () => {
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
let gate: PlaybackHistoryGate;
|
||||
const activePlaylist = signal<Partial<PlaylistMeta> | null>(null);
|
||||
const harnessSelectSignal = storeMock.selectSignal.getMockImplementation()!;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
syncStoreState(null);
|
||||
playlistId.set('playlist-1');
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
showCaptions.set(false);
|
||||
stripCountryPrefix.set(false);
|
||||
externalSession.set(null);
|
||||
storeMock.dispatch.mockClear();
|
||||
activePlaylist.set({ _id: 'playlist-1', recentlyViewed: [] });
|
||||
// The harness has no playlist meta; the history write updates it.
|
||||
storeMock.selectSignal.mockImplementation(((selector: unknown) =>
|
||||
selector === selectActivePlaylist
|
||||
? activePlaylist
|
||||
: harnessSelectSignal(selector)) as typeof harnessSelectSignal);
|
||||
playlistsServiceMock.addM3uRecentlyViewed.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VideoPlayerComponent],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ id: playlistId(), view: 'all' }),
|
||||
queryParams: of({}),
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
queryParams: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provide: Router, useValue: routerMock },
|
||||
{ provide: Store, useValue: storeMock },
|
||||
translateServiceProvider,
|
||||
{ provide: DataService, useValue: dataServiceMock },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
supportsEpg: false,
|
||||
isElectron: false,
|
||||
supportsRemoteControl: false,
|
||||
},
|
||||
},
|
||||
{ provide: PlaylistsService, useValue: playlistsServiceMock },
|
||||
{ provide: EpgService, useValue: epgServiceMock },
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: { resolvedPlaylistId: playlistId },
|
||||
},
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled: () => false },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
player,
|
||||
showCaptions,
|
||||
stripCountryPrefix,
|
||||
m3uVodDetails: signal(true),
|
||||
resolvedEpgViewMode: epgViewMode,
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
epgUrl: epgUrlSetting,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: {
|
||||
get: jest.fn(() =>
|
||||
of({ player: player() } as Partial<Settings>)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession: externalSession },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(VideoPlayerComponent, {
|
||||
set: {
|
||||
imports: [],
|
||||
template:
|
||||
'<ng-template #fullscreenChannelPanel></ng-template>',
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
gate = TestBed.inject(PlaybackHistoryGate);
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
storeMock.selectSignal.mockImplementation(harnessSelectSignal);
|
||||
});
|
||||
|
||||
function select(channel = sampleChannel): void {
|
||||
syncStoreState(channel);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('records an inline channel only once the player confirms it plays', () => {
|
||||
select();
|
||||
|
||||
// Selected, but the stream has not played yet (or failed at once).
|
||||
expect(
|
||||
playlistsServiceMock.addM3uRecentlyViewed
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
gate.confirm({ sessionKey: 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({
|
||||
sessionKey: component.playbackSessionKey(),
|
||||
streamUrls: ['http://localhost/second.m3u8'],
|
||||
});
|
||||
|
||||
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
expect.objectContaining({ title: 'Second TV' })
|
||||
);
|
||||
});
|
||||
|
||||
it('records a second row of the same URL that plays after the first failed', () => {
|
||||
// One stream listed twice in a playlist, under two channel ids.
|
||||
select();
|
||||
select({
|
||||
...sampleChannel,
|
||||
id: 'channel-1-copy',
|
||||
name: 'Sample TV HD',
|
||||
});
|
||||
|
||||
gate.confirm({
|
||||
sessionKey: component.playbackSessionKey(),
|
||||
streamUrls: [sampleChannel.url],
|
||||
});
|
||||
|
||||
expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
expect.objectContaining({ title: 'Sample TV HD' })
|
||||
);
|
||||
});
|
||||
|
||||
it('records a radio station once the audio player confirms it', () => {
|
||||
const radio = { ...sampleChannel, radio: 'true' };
|
||||
select(radio);
|
||||
|
||||
expect(
|
||||
playlistsServiceMock.addM3uRecentlyViewed
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
// The same URL opened elsewhere (MPV/VLC, another playlist) does not.
|
||||
gate.confirm({ streamUrls: [radio.url] });
|
||||
expect(
|
||||
playlistsServiceMock.addM3uRecentlyViewed
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
gate.confirm({
|
||||
sessionKey: component.playbackSessionKey(),
|
||||
streamUrls: [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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -106,10 +106,13 @@
|
||||
<app-audio-player
|
||||
[url]="activeChannel.url"
|
||||
[icon]="
|
||||
$safeNavigationMigration(activeChannel?.tvg?.logo)!
|
||||
$safeNavigationMigration(
|
||||
activeChannel?.tvg?.logo
|
||||
)!
|
||||
"
|
||||
[channelName]="displayChannelName()"
|
||||
[volume]="volume()"
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
(volumeChange)="onInlineVolumeChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
@@ -121,6 +121,7 @@ import {
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
Channel,
|
||||
createDevLogger,
|
||||
@@ -238,6 +239,7 @@ export class VideoPlayerComponent
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly historyGate = inject(PlaybackHistoryGate);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
@@ -487,7 +489,17 @@ export class VideoPlayerComponent
|
||||
epgParams: '',
|
||||
} as Channel;
|
||||
});
|
||||
readonly embeddedPlayback = computed<ResolvedPortalPlayback | null>(() => {
|
||||
/**
|
||||
* Compared by value: it also reads the playlist meta, which changes while
|
||||
* a channel plays (the recently viewed write, a favourite toggle). A new
|
||||
* but identical object would hand the player a new source and restart
|
||||
* the stream.
|
||||
*/
|
||||
readonly embeddedPlayback = computed<ResolvedPortalPlayback | null>(
|
||||
() => this.resolveEmbeddedPlayback(),
|
||||
{ equal: (a, b) => JSON.stringify(a) === JSON.stringify(b) }
|
||||
);
|
||||
private resolveEmbeddedPlayback(): ResolvedPortalPlayback | null {
|
||||
const activeChannel = this.activeChannel();
|
||||
const playbackTarget = this.playbackChannel();
|
||||
|
||||
@@ -530,7 +542,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
|
||||
@@ -838,13 +850,17 @@ export class VideoPlayerComponent
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKey = `${playlistId}::${activeChannel.url}`;
|
||||
// Channel identity too: two rows of one URL are separate
|
||||
// attempts, confirmed under their own session keys.
|
||||
const nextKey = `${playlistId}::${activeChannel.id}::${activeChannel.url}`;
|
||||
if (this.lastRecordedRecentKey === nextKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastRecordedRecentKey = nextKey;
|
||||
void this.persistRecentlyViewedChannel(playlistId, activeChannel);
|
||||
untracked(() =>
|
||||
this.recordRecentlyViewedChannel(playlistId, activeChannel)
|
||||
);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -1200,6 +1216,32 @@ 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(
|
||||
{
|
||||
sessionKey: this.playbackSessionKey(),
|
||||
streamUrls: [channel.url],
|
||||
},
|
||||
record
|
||||
);
|
||||
}
|
||||
|
||||
private async persistRecentlyViewedChannel(
|
||||
playlistId: string,
|
||||
channel: Channel
|
||||
|
||||
@@ -97,6 +97,7 @@ export class StubAudioPlayerComponent {
|
||||
readonly channelName = input('');
|
||||
readonly channelLogo = input('');
|
||||
readonly volume = input<number | null>(null);
|
||||
readonly playbackSessionKey = input<string | null>(null);
|
||||
readonly volumeChange = output<number>();
|
||||
}
|
||||
|
||||
|
||||
+44
-8
@@ -8,8 +8,10 @@ import {
|
||||
PORTAL_PLAYER,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
|
||||
import { UnifiedLiveTimeshift } from './unified-live-catchup';
|
||||
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
|
||||
import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation';
|
||||
|
||||
export interface UnifiedLiveSelection {
|
||||
@@ -71,6 +73,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 +122,27 @@ export function createUnifiedLiveSelection(options: {
|
||||
});
|
||||
};
|
||||
|
||||
/** Set by `dispose`: the host is gone and must not be notified. */
|
||||
let disposed = false;
|
||||
|
||||
/**
|
||||
* Runs once the row has really played. That is seconds after selection,
|
||||
* so the user may have moved on — the write still happened, and an open
|
||||
* Recently Viewed list must still move the row to the top.
|
||||
*/
|
||||
const recordLivePlayback = async (
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const updatedItem = await recentData.recordLivePlayback(item);
|
||||
if (!disposed) {
|
||||
options.onItemPlayed(updatedItem);
|
||||
}
|
||||
} catch {
|
||||
// Keep playback/EPG visible even if history persistence fails.
|
||||
}
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
options.generation.next();
|
||||
pendingActivation = null;
|
||||
@@ -223,14 +247,25 @@ 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. Played
|
||||
// inline, the tab's playlist-scoped session key — the one its
|
||||
// players confirm with — survives a switch to catch-up and does
|
||||
// not match the same URL in another playlist. MPV/VLC can only
|
||||
// confirm the launched URL, so an external row defers by URL.
|
||||
const playsInline = !options.shouldOpenExternalPlayback(
|
||||
detail,
|
||||
true
|
||||
);
|
||||
historyGate.defer(
|
||||
{
|
||||
sessionKey: playsInline
|
||||
? createUnifiedLivePlaybackSessionKey(item)
|
||||
: null,
|
||||
streamUrls: [detail.playback.streamUrl],
|
||||
},
|
||||
() => void recordLivePlayback(item)
|
||||
);
|
||||
|
||||
if (
|
||||
generation === options.generation.current() &&
|
||||
@@ -264,6 +299,7 @@ export function createUnifiedLiveSelection(options: {
|
||||
activate,
|
||||
close,
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
// Invalidate a playback continuation still awaiting its header
|
||||
// IPC and drop any radio credentials owned by this tab.
|
||||
options.generation.next();
|
||||
|
||||
+1
@@ -58,6 +58,7 @@
|
||||
activeDetail()?.playback?.title ||
|
||||
''
|
||||
"
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
/>
|
||||
} @else {
|
||||
<app-web-player-view
|
||||
|
||||
+26
@@ -27,6 +27,7 @@ import {
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { EpgItem, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
@@ -157,6 +158,15 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
/** What the mounted player reports once the stream has really played. */
|
||||
async function playStream(streamUrl: string): Promise<void> {
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
sessionKey: component.playbackSessionKey(),
|
||||
streamUrls: [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,
|
||||
@@ -659,6 +671,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();
|
||||
|
||||
@@ -712,6 +726,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(
|
||||
@@ -782,6 +798,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(
|
||||
@@ -841,6 +859,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)
|
||||
@@ -874,6 +894,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)
|
||||
@@ -897,6 +919,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({
|
||||
|
||||
+101
@@ -10,6 +10,7 @@ import {
|
||||
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
|
||||
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
|
||||
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
|
||||
import {
|
||||
StubAudioPlayerComponent,
|
||||
StubEpgTimelineComponent,
|
||||
@@ -173,6 +175,105 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('correlates an inline row with its session key, not its stream URL', async () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
const item = buildM3uLiveItem();
|
||||
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
||||
epgMode: 'm3u',
|
||||
playback: { streamUrl: item.streamUrl, title: item.name },
|
||||
epgPrograms: [],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue(item);
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
const gate = TestBed.inject(PlaybackHistoryGate);
|
||||
|
||||
// The same URL played from another playlist does not confirm it.
|
||||
gate.confirm({
|
||||
sessionKey: 'live:another-playlist:m3u-channel',
|
||||
streamUrls: [item.streamUrl],
|
||||
});
|
||||
await fixture.whenStable();
|
||||
expect(recentData.recordLivePlayback).not.toHaveBeenCalled();
|
||||
|
||||
// The row's own player does, even after switching to catch-up.
|
||||
gate.confirm({
|
||||
sessionKey: component.playbackSessionKey(),
|
||||
streamUrls: ['https://example.com/archive.m3u8'],
|
||||
});
|
||||
await fixture.whenStable();
|
||||
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
||||
});
|
||||
|
||||
it('records a row played in MPV/VLC from the URL its launch confirms', async () => {
|
||||
const item = buildM3uLiveItem();
|
||||
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
||||
epgMode: 'm3u',
|
||||
playback: { streamUrl: item.streamUrl, title: item.name },
|
||||
epgPrograms: [],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue(item);
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalled();
|
||||
|
||||
// The external session update carries only the launched URL.
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: [item.streamUrl],
|
||||
});
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
||||
});
|
||||
|
||||
it('moves a row that played to the top of Recently Viewed after another row was selected', async () => {
|
||||
const first = buildM3uLiveItem();
|
||||
const second: UnifiedCollectionItem = {
|
||||
...first,
|
||||
uid: 'm3u::pl-1::m3u-channel-2',
|
||||
name: 'M3U Live 2',
|
||||
channelId: 'm3u-channel-2',
|
||||
tvgId: 'm3u-channel-2',
|
||||
streamUrl: 'https://example.com/m3u-2.m3u8',
|
||||
};
|
||||
streamResolver.resolveM3uPlaybackDetail.mockImplementation(
|
||||
async (item: UnifiedCollectionItem) => ({
|
||||
epgMode: 'm3u',
|
||||
playback: { streamUrl: item.streamUrl, title: item.name },
|
||||
epgPrograms: [],
|
||||
})
|
||||
);
|
||||
recentData.recordLivePlayback.mockImplementation(
|
||||
async (item: UnifiedCollectionItem) => item
|
||||
);
|
||||
const played = jest.fn();
|
||||
component.itemPlayed.subscribe(played);
|
||||
fixture.componentRef.setInput('items', [first, second]);
|
||||
fixture.componentRef.setInput('mode', 'recent');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const row = (name: string) =>
|
||||
component
|
||||
.channelsForList()
|
||||
.find((channel) => channel.name === name)!;
|
||||
|
||||
await component.onChannelSelected(row('M3U Live'));
|
||||
await component.onChannelSelected(row('M3U Live 2'));
|
||||
// The first row's playback is confirmed only now, after the switch.
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
sessionKey: createUnifiedLivePlaybackSessionKey(first),
|
||||
streamUrls: [first.streamUrl],
|
||||
});
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(first);
|
||||
expect(played).toHaveBeenCalledWith(first);
|
||||
});
|
||||
|
||||
it('keeps the current detail (and its fullscreen player) mounted while the next selection resolves', async () => {
|
||||
const first = buildM3uLiveItem();
|
||||
const second: UnifiedCollectionItem = {
|
||||
|
||||
+1
@@ -101,6 +101,7 @@ export class StubAudioPlayerComponent {
|
||||
readonly icon = input('');
|
||||
readonly url = input.required<string>();
|
||||
readonly channelName = input('');
|
||||
readonly playbackSessionKey = input<string | null>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
+50
-1
@@ -5,6 +5,7 @@ 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 { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
@@ -13,6 +14,13 @@ 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({
|
||||
streamUrls: [playback.streamUrl],
|
||||
});
|
||||
}
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
...jest.requireActual('@iptvnator/portal/shared/util'),
|
||||
createLogger: () => ({
|
||||
@@ -160,6 +168,7 @@ describe('withStalkerPlayer', () => {
|
||||
'Movie Title',
|
||||
'thumb.jpg'
|
||||
);
|
||||
confirmPlayback(playback);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -225,6 +234,7 @@ describe('withStalkerPlayer', () => {
|
||||
1,
|
||||
3000001
|
||||
);
|
||||
confirmPlayback(playback);
|
||||
|
||||
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
|
||||
PLAYLIST._id,
|
||||
@@ -266,6 +276,7 @@ describe('withStalkerPlayer', () => {
|
||||
logo: 'jazz.png',
|
||||
category_id: '4001',
|
||||
});
|
||||
confirmPlayback(playback);
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
|
||||
@@ -372,6 +383,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 +567,9 @@ describe('withStalkerPlayer', () => {
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce(response);
|
||||
}
|
||||
|
||||
await store.resolveItvPlayback({ ...CHANNEL, ...flags });
|
||||
confirmPlayback(
|
||||
await store.resolveItvPlayback({ ...CHANNEL, ...flags })
|
||||
);
|
||||
|
||||
expect(
|
||||
playlistService.addPortalRecentlyViewed
|
||||
|
||||
+27
-12
@@ -5,6 +5,7 @@ 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 { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -60,7 +61,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 +124,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 +149,12 @@ export function withStalkerPlayer() {
|
||||
cover,
|
||||
title
|
||||
);
|
||||
|
||||
if (typeof storeState.addToRecentlyViewed === 'function') {
|
||||
storeState.addToRecentlyViewed(recentItem);
|
||||
return;
|
||||
}
|
||||
|
||||
persistRecentlyViewed(playlistId, recentItem);
|
||||
historyGate.defer({ streamUrls: [streamUrl] }, () =>
|
||||
persistRecentlyViewed(playlistId, {
|
||||
...recentItem,
|
||||
added_at: Date.now(),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const resolveVodPlaybackInternal = async (
|
||||
@@ -157,9 +166,7 @@ export function withStalkerPlayer() {
|
||||
startTime?: number
|
||||
): Promise<ResolvedPortalPlayback> => {
|
||||
const item = storeState.selectedItem() as
|
||||
| StalkerPlayableItem
|
||||
| null
|
||||
| undefined;
|
||||
StalkerPlayableItem | null | undefined;
|
||||
let cmdToUse = cmd ?? item?.cmd;
|
||||
|
||||
if (!cmdToUse) {
|
||||
@@ -195,7 +202,13 @@ export function withStalkerPlayer() {
|
||||
}
|
||||
);
|
||||
|
||||
recordRecentlyViewed(item, cmd, thumbnail, title);
|
||||
recordRecentlyViewed(
|
||||
streamUrl,
|
||||
item,
|
||||
cmd,
|
||||
thumbnail,
|
||||
title
|
||||
);
|
||||
|
||||
const isEpisode =
|
||||
episodeNum !== undefined || episodeId !== undefined;
|
||||
@@ -285,6 +298,7 @@ export function withStalkerPlayer() {
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
|
||||
recordRecentlyViewed(
|
||||
streamUrl,
|
||||
item,
|
||||
item.cmd,
|
||||
item.logo ?? item.cover,
|
||||
@@ -350,6 +364,7 @@ export function withStalkerPlayer() {
|
||||
);
|
||||
|
||||
recordRecentlyViewed(
|
||||
streamUrl,
|
||||
item,
|
||||
item.cmd,
|
||||
item.logo ?? item.cover,
|
||||
|
||||
@@ -59,10 +59,6 @@ export interface StalkerSeriesFeatureStoreContract
|
||||
vodSeriesSeasonsResource: ResourceState<StalkerVodSeriesSeason[]>;
|
||||
}
|
||||
|
||||
export interface StalkerRecentCallbackStoreContract {
|
||||
addToRecentlyViewed?: (item: StalkerRecentlyViewedItem) => void;
|
||||
}
|
||||
|
||||
export type StalkerRecentlyViewedItem = StalkerPortalItem & {
|
||||
id: string | number;
|
||||
title: string;
|
||||
@@ -74,8 +70,7 @@ export interface StalkerPlayerFeatureStoreContract
|
||||
Pick<
|
||||
StalkerSelectionStoreContract,
|
||||
'selectedContentType' | 'selectedItem'
|
||||
>,
|
||||
StalkerRecentCallbackStoreContract {}
|
||||
> {}
|
||||
|
||||
export interface StalkerEpgFeatureStoreContract
|
||||
extends
|
||||
|
||||
+7
-11
@@ -36,6 +36,7 @@ import {
|
||||
resolveSeriesPlaybackEpisodeState,
|
||||
type SeriesPlaybackEpisodeState,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { injectXtreamRecentHistory } from '../xtream-recent-history';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
|
||||
import {
|
||||
@@ -66,6 +67,7 @@ export class SerialDetailsPlaybackService {
|
||||
);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly recordRecentItem = injectXtreamRecentHistory();
|
||||
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
|
||||
private readonly seasonWatch = inject(SerialDetailsSeasonWatchService);
|
||||
|
||||
@@ -209,9 +211,12 @@ export class SerialDetailsPlaybackService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addToRecentlyViewed(this.route.snapshot.params['serialId']);
|
||||
|
||||
const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode);
|
||||
this.recordRecentItem(streamUrl, {
|
||||
xtreamId: this.route.snapshot.params['serialId'],
|
||||
contentType: 'series',
|
||||
backdropUrl: selectedItem.info?.backdrop_path?.[0],
|
||||
});
|
||||
const contentInfo: PlayerContentInfo = {
|
||||
playlistId: playlist.id,
|
||||
contentXtreamId: Number(episode.id),
|
||||
@@ -404,15 +409,6 @@ export class SerialDetailsPlaybackService {
|
||||
return this.bindings()?.selectedItem() ?? null;
|
||||
}
|
||||
|
||||
private addToRecentlyViewed(xtreamId: number): void {
|
||||
this.xtreamStore.addRecentItem({
|
||||
xtreamId,
|
||||
contentType: 'series',
|
||||
playlist: this.xtreamStore.currentPlaylist,
|
||||
backdropUrl: this.selectedItem()?.info?.backdrop_path?.[0],
|
||||
});
|
||||
}
|
||||
|
||||
private startPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
@@ -564,12 +565,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({
|
||||
streamUrls: ['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 +932,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 +970,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 +1077,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();
|
||||
|
||||
|
||||
+110
-2
@@ -5,8 +5,15 @@ import {
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
PlaybackPositionRuntimeBridgeService,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
@@ -28,6 +35,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
|
||||
/** The bridge callback the service registers at construction. */
|
||||
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
|
||||
const addRecentItem = jest.fn();
|
||||
const xtreamDataSource = {
|
||||
getContentByXtreamId: jest.fn(),
|
||||
addRecentItem: jest.fn(),
|
||||
};
|
||||
const activeSession = signal<unknown>(null);
|
||||
const closeSession = jest.fn().mockResolvedValue(undefined);
|
||||
const openResolvedPlayback = jest.fn();
|
||||
@@ -81,6 +92,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
|
||||
routeVodId.set(ROUTE_VOD_ID);
|
||||
positionListener = undefined;
|
||||
addRecentItem.mockClear();
|
||||
xtreamDataSource.getContentByXtreamId
|
||||
.mockReset()
|
||||
.mockResolvedValue({ id: 77 });
|
||||
xtreamDataSource.addRecentItem.mockReset().mockResolvedValue(undefined);
|
||||
closeSession.mockReset().mockResolvedValue(undefined);
|
||||
openResolvedPlayback
|
||||
.mockReset()
|
||||
@@ -105,6 +120,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
|
||||
.mockReturnValue('https://example.com/route.mkv'),
|
||||
},
|
||||
},
|
||||
{ provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource },
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession, closeSession },
|
||||
@@ -147,6 +163,95 @@ 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',
|
||||
});
|
||||
expect(addRecentItem).not.toHaveBeenCalled();
|
||||
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: ['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('saves a confirmation that arrives after a playlist switch without touching the store', async () => {
|
||||
// Only a slow MPV/VLC launch can confirm after the page is gone; the
|
||||
// store's recent list belongs to the other playlist by then.
|
||||
await service.startResolvedPlayback({
|
||||
streamUrl: 'https://example.com/route.mkv',
|
||||
title: 'Slow external launch',
|
||||
});
|
||||
currentPlaylist.set({ id: 'playlist-switched-meanwhile' });
|
||||
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: ['https://example.com/route.mkv'],
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(addRecentItem).not.toHaveBeenCalled();
|
||||
expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith(
|
||||
ROUTE_VOD_ID,
|
||||
ROUTE_PLAYLIST,
|
||||
'movie'
|
||||
);
|
||||
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
|
||||
77,
|
||||
ROUTE_PLAYLIST,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the API-only data source', false, 1],
|
||||
['the SQLite data source', true, 0],
|
||||
])(
|
||||
'keys a late, uncached write by Xtream id only for %s',
|
||||
async (_label, sqlite: boolean, saves: number) => {
|
||||
// A partial Electron bridge still selects the API-only source.
|
||||
jest.spyOn(
|
||||
TestBed.inject(RuntimeCapabilitiesService),
|
||||
'supportsXtreamSqliteDataSource',
|
||||
'get'
|
||||
).mockReturnValue(sqlite);
|
||||
xtreamDataSource.getContentByXtreamId.mockResolvedValue(null);
|
||||
await service.startResolvedPlayback({
|
||||
streamUrl: 'https://example.com/route.mkv',
|
||||
title: 'Slow external launch',
|
||||
});
|
||||
currentPlaylist.set({ id: 'playlist-switched-meanwhile' });
|
||||
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: ['https://example.com/route.mkv'],
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledTimes(saves);
|
||||
if (saves) {
|
||||
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
|
||||
ROUTE_VOD_ID,
|
||||
ROUTE_PLAYLIST,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('owns a session launched for the route’s own stream', () => {
|
||||
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
|
||||
|
||||
@@ -341,6 +446,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
|
||||
contentType: 'vod',
|
||||
},
|
||||
});
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: ['https://example.com/alt.mkv'],
|
||||
});
|
||||
|
||||
expect(addRecentItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
createExternalLaunchOwner,
|
||||
startRouteOwnedPlayback,
|
||||
} from './vod-details-external-launch-owner';
|
||||
import { injectXtreamRecentHistory } from '../xtream-recent-history';
|
||||
import { settleOwnedExternalLaunch } from './vod-details-external-launch';
|
||||
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
|
||||
import { isResumablePosition } from './vod-primary-action-position';
|
||||
@@ -68,6 +69,7 @@ export class VodDetailsPlaybackService {
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly recordRecentItem = injectXtreamRecentHistory();
|
||||
private readonly playbackPositionBridge = inject(
|
||||
PlaybackPositionRuntimeBridgeService
|
||||
);
|
||||
@@ -377,15 +379,6 @@ export class VodDetailsPlaybackService {
|
||||
this.positionLoaded.set(true);
|
||||
}
|
||||
|
||||
private addToRecentlyViewed(): void {
|
||||
this.xtreamStore.addRecentItem({
|
||||
xtreamId: this.bindings()?.vodId() ?? NaN,
|
||||
contentType: 'movie',
|
||||
playlist: this.xtreamStore.currentPlaylist,
|
||||
backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The single inline-vs-external fork. Public so multi-source can switch
|
||||
* the playing source through exactly the same path a normal Play takes —
|
||||
@@ -469,7 +462,11 @@ export class VodDetailsPlaybackService {
|
||||
}
|
||||
|
||||
// Same movie, different source: still a view.
|
||||
this.addToRecentlyViewed();
|
||||
this.recordRecentItem(playback.streamUrl, {
|
||||
xtreamId: this.bindings()?.vodId() ?? NaN,
|
||||
contentType: 'movie',
|
||||
backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0],
|
||||
});
|
||||
return await this.applyPlayback(playback, isCurrent);
|
||||
} finally {
|
||||
this.pendingStart.settle(startId);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { inject, Injector, signal } from '@angular/core';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
type IXtreamDataSource,
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
|
||||
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.
|
||||
*
|
||||
* A confirmation that arrives after the user switched to another playlist
|
||||
* (only a slow MPV/VLC launch can: the inline player goes with the page) is
|
||||
* saved to the captured playlist without touching the store, whose recent
|
||||
* list belongs to the other playlist by then.
|
||||
*
|
||||
* Must run in an injection context.
|
||||
*/
|
||||
export function injectXtreamRecentHistory(): (
|
||||
streamUrl: string,
|
||||
request: XtreamRecentItemRequest
|
||||
) => void {
|
||||
const gate = inject(PlaybackHistoryGate);
|
||||
const store = inject(XtreamStore);
|
||||
const injector = inject(Injector);
|
||||
|
||||
return (streamUrl, request) => {
|
||||
const playlist = signal(store.currentPlaylist()).asReadonly();
|
||||
gate.defer({ streamUrls: [streamUrl] }, () => {
|
||||
const playlistId = playlist()?.id;
|
||||
if (store.currentPlaylist()?.id === playlistId) {
|
||||
store.addRecentItem({ ...request, playlist });
|
||||
} else if (playlistId) {
|
||||
void saveWithoutListRefresh(
|
||||
injector.get(XTREAM_DATA_SOURCE),
|
||||
// The data source factory picks SQLite by this contract,
|
||||
// not by a generic Electron bridge.
|
||||
!injector.get(RuntimeCapabilitiesService)
|
||||
.supportsXtreamSqliteDataSource,
|
||||
playlistId,
|
||||
request
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The save half of `withRecentItems.addRecentItem` (same content lookup;
|
||||
* the API-only data source keys cold content by its Xtream id), without
|
||||
* reloading the store's recent list. Kept here rather than shared with the store: the
|
||||
* store and its library barrel ship in the initial bundle, this path is
|
||||
* only reached from lazy detail pages.
|
||||
*/
|
||||
async function saveWithoutListRefresh(
|
||||
dataSource: IXtreamDataSource,
|
||||
keysByXtreamId: boolean,
|
||||
playlistId: string,
|
||||
{ xtreamId, contentType, backdropUrl }: XtreamRecentItemRequest
|
||||
): Promise<void> {
|
||||
const id = Number(xtreamId);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return;
|
||||
}
|
||||
const content = await dataSource.getContentByXtreamId(
|
||||
id,
|
||||
playlistId,
|
||||
contentType
|
||||
);
|
||||
const contentId = content?.id ?? (keysByXtreamId ? id : null);
|
||||
if (contentId != null) {
|
||||
await dataSource.addRecentItem(contentId, playlistId, backdropUrl);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,9 @@ describe('ArtPlayerVideoSession', () => {
|
||||
player.currentTime = 45;
|
||||
player.duration = 120;
|
||||
player.emit('video:timeupdate');
|
||||
expect(times).toEqual([{ currentTime: 45, duration: 120 }]);
|
||||
expect(times).toEqual([
|
||||
{ currentTime: 45, duration: 120, playing: false },
|
||||
]);
|
||||
|
||||
Object.defineProperty(player.video, 'error', {
|
||||
configurable: true,
|
||||
@@ -70,7 +72,7 @@ describe('ArtPlayerVideoSession', () => {
|
||||
|
||||
it('uses the source-resolved duration for time updates', () => {
|
||||
const player = createPlayer();
|
||||
const times: Array<{ currentTime: number; duration: number }> = [];
|
||||
const times: unknown[] = [];
|
||||
player.currentTime = 45;
|
||||
player.duration = Number.POSITIVE_INFINITY;
|
||||
const session = new ArtPlayerVideoSession({
|
||||
@@ -87,7 +89,9 @@ describe('ArtPlayerVideoSession', () => {
|
||||
|
||||
player.emit('video:timeupdate');
|
||||
|
||||
expect(times).toEqual([{ currentTime: 45, duration: 135 }]);
|
||||
expect(times).toEqual([
|
||||
{ currentTime: 45, duration: 135, playing: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes native and ArtPlayer listeners exactly on destroy', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
classifyNativePlaybackIssue,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '@iptvnator/playback/util';
|
||||
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
||||
|
||||
export interface ArtPlayerVideoSessionConfig {
|
||||
player: Artplayer;
|
||||
@@ -13,7 +14,7 @@ export interface ArtPlayerVideoSessionConfig {
|
||||
getDuration: () => number;
|
||||
persistSharedVolume: boolean;
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
|
||||
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
|
||||
emitTimeUpdate: (value: PlayerTimeUpdate) => void;
|
||||
emitPlaybackEnded: () => void;
|
||||
emitPlaybackStarted?: () => void;
|
||||
}
|
||||
@@ -71,6 +72,7 @@ export class ArtPlayerVideoSession {
|
||||
this.config.emitTimeUpdate({
|
||||
currentTime: player.currentTime,
|
||||
duration: this.config.getDuration(),
|
||||
playing: !player.video.paused && !player.video.seeking,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from './art-player-setup';
|
||||
import { ArtPlayerSourceSession } from './art-player-source-session';
|
||||
import { ArtPlayerVideoSession } from './art-player-video-session';
|
||||
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
||||
|
||||
const debugArtPlayer = createDevLogger('ArtPlayer');
|
||||
|
||||
@@ -64,10 +65,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly playbackStarted = output<void>();
|
||||
|
||||
@@ -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/playback/data-access';
|
||||
import { AudioPlayerComponent } from './audio-player.component';
|
||||
|
||||
describe('AudioPlayerComponent', () => {
|
||||
@@ -176,6 +177,47 @@ 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(
|
||||
{ streamUrls: ['https://example.com/station.mp3'] },
|
||||
commit
|
||||
);
|
||||
Object.defineProperty(audio, 'paused', {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
const playTo = (position: number) => {
|
||||
Object.defineProperty(audio, 'currentTime', {
|
||||
configurable: true,
|
||||
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(
|
||||
{ streamUrls: ['https://example.com/dead.mp3'] },
|
||||
commit
|
||||
);
|
||||
|
||||
audio.dispatchEvent(new Event('timeupdate'));
|
||||
audio.dispatchEvent(new Event('timeupdate'));
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pauses the stream when the component is destroyed', () => {
|
||||
createComponent();
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audio-player',
|
||||
@@ -156,7 +158,12 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio preload="metadata" autoplay #audio></audio>
|
||||
<audio
|
||||
preload="metadata"
|
||||
autoplay
|
||||
#audio
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
></audio>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./audio-player.component.scss'],
|
||||
@@ -176,6 +183,8 @@ export class AudioPlayerComponent {
|
||||
readonly channelName = input<string>('');
|
||||
readonly externalVolume = input<number | null>(null, { alias: 'volume' });
|
||||
readonly dispatchAdjacentChannelAction = input(true);
|
||||
/** The host's playback session key, for the history confirmation. */
|
||||
readonly playbackSessionKey = input<string | null>(null);
|
||||
readonly channelSwitchRequested = output<'next' | 'previous'>();
|
||||
readonly volumeChange = output<number>();
|
||||
|
||||
@@ -198,6 +207,14 @@ 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),
|
||||
target: () => ({
|
||||
sessionKey: this.playbackSessionKey(),
|
||||
streamUrls: [this.url()],
|
||||
}),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const saved = parseFloat(localStorage.getItem('volume') ?? '1');
|
||||
@@ -268,6 +285,16 @@ export class AudioPlayerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
onTimeUpdate(): void {
|
||||
const audio = this.audioRef()?.nativeElement;
|
||||
if (audio) {
|
||||
this.historyConfirmation.record(
|
||||
audio.currentTime,
|
||||
!audio.paused && !audio.seeking
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
play() {
|
||||
const audio = this.audioRef()?.nativeElement;
|
||||
if (!audio) return;
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
subtitleTrackLabel,
|
||||
volumeIcon,
|
||||
} from './embedded-mpv-format.utils';
|
||||
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
||||
|
||||
const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000;
|
||||
|
||||
@@ -89,10 +90,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
@@ -567,6 +565,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: session.positionSeconds,
|
||||
duration: session.durationSeconds ?? 0,
|
||||
playing: session.status === 'playing',
|
||||
});
|
||||
this.legacyInteractions.scheduleControlsHide();
|
||||
});
|
||||
|
||||
@@ -4,8 +4,11 @@ describe('HtmlVideoElementSession', () => {
|
||||
it('owns native video events and detaches them idempotently', () => {
|
||||
const video = document.createElement('video');
|
||||
const playbackIssues: unknown[] = [];
|
||||
const timeUpdates: Array<{ currentTime: number; duration: number }> =
|
||||
[];
|
||||
const timeUpdates: Array<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playing?: boolean;
|
||||
}> = [];
|
||||
const playbackEnded = jest.fn();
|
||||
const removeEventListener = jest.spyOn(video, 'removeEventListener');
|
||||
const session = new HtmlVideoElementSession({
|
||||
@@ -40,7 +43,20 @@ describe('HtmlVideoElementSession', () => {
|
||||
video.dispatchEvent(new Event('ended'));
|
||||
|
||||
expect(video.currentTime).toBe(18);
|
||||
expect(timeUpdates).toEqual([{ currentTime: 18, duration: 90 }]);
|
||||
// A paused element's position moves too (a seek): not playing.
|
||||
expect(timeUpdates).toEqual([
|
||||
{ currentTime: 18, duration: 90, playing: false },
|
||||
]);
|
||||
Object.defineProperty(video, 'paused', {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
video.dispatchEvent(new Event('timeupdate'));
|
||||
expect(timeUpdates.at(-1)).toEqual({
|
||||
currentTime: 18,
|
||||
duration: 90,
|
||||
playing: true,
|
||||
});
|
||||
expect(playbackIssues[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'unsupported-container',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
classifyNativePlaybackIssue,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '@iptvnator/playback/util';
|
||||
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
||||
|
||||
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
|
||||
|
||||
@@ -13,7 +14,7 @@ export interface HtmlVideoElementSessionConfig {
|
||||
getChannelUrl: () => string | undefined;
|
||||
getStartTime: () => number;
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
|
||||
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
|
||||
emitTimeUpdate: (value: PlayerTimeUpdate) => void;
|
||||
emitPlaybackEnded: () => void;
|
||||
emitPlaybackStarted?: () => void;
|
||||
}
|
||||
@@ -62,6 +63,7 @@ export class HtmlVideoElementSession {
|
||||
this.config.emitTimeUpdate({
|
||||
currentTime: video.currentTime,
|
||||
duration: video.duration,
|
||||
playing: !video.paused && !video.seeking,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
HtmlVideoPlayerControlsBridge,
|
||||
type HtmlVideoControlsSource,
|
||||
} from './html-video-player-controls.bridge';
|
||||
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
||||
|
||||
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
|
||||
|
||||
@@ -85,10 +86,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
@Output() timeUpdate = new EventEmitter<PlayerTimeUpdate>();
|
||||
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
|
||||
@Output() playbackEnded = new EventEmitter<void>();
|
||||
@Output() playbackStarted = new EventEmitter<void>();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PlaybackHistoryTarget } from '@iptvnator/playback/data-access';
|
||||
import { PlaybackHistoryConfirmation } from './playback-history-confirmation';
|
||||
|
||||
describe('PlaybackHistoryConfirmation', () => {
|
||||
const streamA: PlaybackHistoryTarget = {
|
||||
sessionKey: 'live:p1:a',
|
||||
streamUrls: ['http://stream/a'],
|
||||
};
|
||||
const streamB: PlaybackHistoryTarget = {
|
||||
sessionKey: 'live:p1:b',
|
||||
streamUrls: ['http://stream/b'],
|
||||
};
|
||||
let target: PlaybackHistoryTarget;
|
||||
let revision: symbol;
|
||||
let confirm: jest.Mock;
|
||||
let confirmation: PlaybackHistoryConfirmation;
|
||||
|
||||
const play = (...positions: number[]) =>
|
||||
positions.forEach((position) => confirmation.record(position));
|
||||
|
||||
beforeEach(() => {
|
||||
target = streamA;
|
||||
revision = Symbol('first');
|
||||
confirm = jest.fn();
|
||||
confirmation = new PlaybackHistoryConfirmation({
|
||||
gate: { confirm },
|
||||
target: () => target,
|
||||
sourceRevision: () => revision,
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms what played', () => {
|
||||
play(0, 1, 2);
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(streamA);
|
||||
});
|
||||
|
||||
it('does not credit a new stream with the previous stream progress', () => {
|
||||
play(0, 1.5);
|
||||
target = streamB;
|
||||
play(0, 0.5);
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
|
||||
play(1, 2);
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(confirm).toHaveBeenCalledWith(streamB);
|
||||
});
|
||||
|
||||
it('confirms a returning stream again after another one played', () => {
|
||||
play(0, 1, 2);
|
||||
target = streamB;
|
||||
play(0, 1, 2);
|
||||
target = streamA;
|
||||
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);
|
||||
});
|
||||
|
||||
it('ignores position changes reported while not playing', () => {
|
||||
confirmation.record(0, false);
|
||||
confirmation.record(1, false);
|
||||
confirmation.record(2, false);
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
PlaybackHistoryGate,
|
||||
PlaybackHistoryTarget,
|
||||
} from '@iptvnator/playback/data-access';
|
||||
import { PlaybackProgressConfirmation } from './playback-progress-confirmation';
|
||||
|
||||
export interface PlaybackHistoryConfirmationOptions {
|
||||
readonly gate: Pick<PlaybackHistoryGate, 'confirm'>;
|
||||
/** What is playing now: its session key and/or stream URLs. */
|
||||
readonly target: () => PlaybackHistoryTarget;
|
||||
/**
|
||||
* Engine/source generation within the same keys (engine switch, live
|
||||
* format fallback, reload). A change restarts the position clock but
|
||||
* 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.target())
|
||||
);
|
||||
|
||||
constructor(private readonly options: PlaybackHistoryConfirmationOptions) {}
|
||||
|
||||
/** @param playing see {@link PlaybackProgressConfirmation.record}. */
|
||||
record(position: number, playing?: boolean): void {
|
||||
const identity = JSON.stringify(this.options.target());
|
||||
const sourceRevision = this.options.sourceRevision?.() ?? null;
|
||||
if (identity !== this.identity) {
|
||||
this.identity = identity;
|
||||
this.sourceRevision = sourceRevision;
|
||||
this.progress.reset();
|
||||
} else if (sourceRevision !== this.sourceRevision) {
|
||||
this.sourceRevision = sourceRevision;
|
||||
this.progress.rebase();
|
||||
}
|
||||
|
||||
this.progress.record(position, playing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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 short seeks of paused media', () => {
|
||||
confirmation.record(0, false);
|
||||
confirmation.record(1, false);
|
||||
confirmation.record(2, false);
|
||||
confirmation.record(2.5, false);
|
||||
|
||||
expect(onConfirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts only the steps reported while playing', () => {
|
||||
confirmation.record(0, true);
|
||||
confirmation.record(1.5, true);
|
||||
// Paused, then seeked a second ahead: not watched.
|
||||
confirmation.record(2.5, false);
|
||||
confirmation.record(2.9, true);
|
||||
|
||||
expect(onConfirmed).not.toHaveBeenCalled();
|
||||
|
||||
confirmation.record(3.5, true);
|
||||
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not count backwards jumps', () => {
|
||||
play(5, 4, 3, 2, 1);
|
||||
|
||||
expect(onConfirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-finite positions', () => {
|
||||
play(0, Number.NaN, Number.POSITIVE_INFINITY, 1);
|
||||
|
||||
expect(onConfirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts over after reset', () => {
|
||||
play(0, 1.5);
|
||||
confirmation.reset();
|
||||
play(100, 101);
|
||||
|
||||
expect(onConfirmed).not.toHaveBeenCalled();
|
||||
|
||||
play(102);
|
||||
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('confirms again for a new stream after reset', () => {
|
||||
play(0, 1, 2);
|
||||
confirmation.reset();
|
||||
play(0, 1, 2);
|
||||
|
||||
expect(onConfirmed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the progress across a rebase but not the position', () => {
|
||||
play(0, 1.5);
|
||||
confirmation.rebase();
|
||||
// The new engine's clock starts elsewhere; that jump is not counted.
|
||||
play(50, 50.5);
|
||||
|
||||
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/** 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 — including
|
||||
* short seeks of paused media, which engines report as `playing: false`.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param playing whether the media was actually playing (not paused, not
|
||||
* seeking) at this report; engines that cannot tell leave it undefined.
|
||||
*/
|
||||
record(position: number, playing?: boolean): void {
|
||||
if (this.confirmed || !Number.isFinite(position)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = this.lastPosition;
|
||||
this.lastPosition = position;
|
||||
if (previous === null || playing === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = position - previous;
|
||||
if (step <= 0 || step > MAX_PLAYBACK_STEP_SECONDS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.progressedSeconds += step;
|
||||
if (this.progressedSeconds >= PLAYBACK_CONFIRMATION_SECONDS) {
|
||||
this.confirmed = true;
|
||||
this.onConfirmed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Position report an engine emits on every media time update. */
|
||||
export interface PlayerTimeUpdate {
|
||||
readonly currentTime: number;
|
||||
readonly duration: number;
|
||||
/**
|
||||
* Whether the media was actually playing — not paused and not seeking.
|
||||
* Seeks of paused media also move the position; only playing reports
|
||||
* count towards the "really played" history confirmation. Left undefined
|
||||
* by an engine that cannot tell.
|
||||
*/
|
||||
readonly playing?: boolean;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
getVideoJsTechVideo,
|
||||
} from './vjs-player.types';
|
||||
import { VjsVideoElementSession } from './vjs-video-element-session';
|
||||
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
||||
|
||||
const debugVjsPlayer = createDevLogger('VjsPlayer');
|
||||
|
||||
@@ -82,10 +83,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly playbackStarted = output<void>();
|
||||
@@ -282,6 +280,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.player.currentTime() ?? 0,
|
||||
duration: this.player.duration() ?? 0,
|
||||
playing: !this.player.paused() && !this.player.seeking(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+32
@@ -83,6 +83,38 @@ describe('ExternalPlaybackRecoveryCoordinator', () => {
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an opened launch', session({ status: 'opened' }), 1],
|
||||
['a launch that failed', session({ status: 'error' }), 0],
|
||||
['a launch of another player', session({ player: 'vlc' }), 0],
|
||||
])(
|
||||
'reports %s to the view',
|
||||
async (_label, launched: ExternalPlayerSession, calls: number) => {
|
||||
const activeSession = signal<ExternalPlayerSession | null>(null);
|
||||
const onLaunched = jest.fn();
|
||||
const coordinator = new ExternalPlaybackRecoveryCoordinator(
|
||||
{
|
||||
activeSession,
|
||||
visibleSession: activeSession,
|
||||
closeSession: jest.fn(),
|
||||
dismissActiveSession: jest.fn(),
|
||||
},
|
||||
onLaunched
|
||||
);
|
||||
coordinator.syncSession('content-a');
|
||||
|
||||
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
|
||||
trackLaunch(Promise.resolve(launched));
|
||||
return true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onLaunched).toHaveBeenCalledTimes(calls);
|
||||
coordinator.destroy();
|
||||
}
|
||||
);
|
||||
|
||||
it('closes a closable error before retrying another external target', async () => {
|
||||
const previous = session({
|
||||
id: 'uncertain-process',
|
||||
|
||||
@@ -21,8 +21,16 @@ export class ExternalPlaybackRecoveryCoordinator {
|
||||
readonly states = this.recovery.states;
|
||||
readonly pending = this.recovery.pending;
|
||||
|
||||
/**
|
||||
* @param onLaunched runs once a launch this view requested has opened in
|
||||
* MPV/VLC — still owned by the current intent — e.g. to record it as
|
||||
* recently viewed under the view's own playback session.
|
||||
*/
|
||||
constructor(
|
||||
private readonly externalPlayback: PortalExternalPlayback | null
|
||||
private readonly externalPlayback: PortalExternalPlayback | null,
|
||||
private readonly onLaunched: (
|
||||
session: ExternalPlayerSession
|
||||
) => void = () => undefined
|
||||
) {}
|
||||
|
||||
observe(session: ExternalPlayerSession | null): void {
|
||||
@@ -93,7 +101,13 @@ export class ExternalPlaybackRecoveryCoordinator {
|
||||
void launch.then(
|
||||
(session) => {
|
||||
if (session) {
|
||||
this.recovery.confirm(intent, session);
|
||||
if (
|
||||
this.recovery.confirm(intent, session) &&
|
||||
(session.status === 'opened' ||
|
||||
session.status === 'playing')
|
||||
) {
|
||||
this.onLaunched(session);
|
||||
}
|
||||
} else {
|
||||
this.recovery.fail(intent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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 { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
type ExternalPlayerSession,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
} from './web-player-view.spec-stubs';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({ default: jest.fn() }));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
|
||||
describe('WebPlayerViewComponent playback history', () => {
|
||||
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
|
||||
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
|
||||
let gate: PlaybackHistoryGate;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ WebPlayerViewComponent } =
|
||||
await import('./web-player-view.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const externalSession = signal<ExternalPlayerSession | null>(null);
|
||||
await TestBed.configureTestingModule({
|
||||
deferBlockBehavior: DeferBlockBehavior.Playthrough,
|
||||
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: {
|
||||
get: (key: string) =>
|
||||
of(
|
||||
key === STORE_KEY.Settings
|
||||
? { player: VideoPlayer.VideoJs }
|
||||
: undefined
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsManagedExternalPlayers: true },
|
||||
},
|
||||
{
|
||||
provide: ElectronStreamHeadersService,
|
||||
useValue: { apply: () => null, clear: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
showCaptions: () => false,
|
||||
webPlayerSharedControls: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: {
|
||||
activeSession: externalSession,
|
||||
visibleSession: externalSession,
|
||||
dismissActiveSession: jest.fn(),
|
||||
closeSession: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(WebPlayerViewComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubFullscreenChannelPanelComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
],
|
||||
// The diagnostic panel itself is not under test here.
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
gate = TestBed.inject(PlaybackHistoryGate);
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
fixture.componentRef.setInput('streamUrl', 'https://example.com/a');
|
||||
fixture.componentRef.setInput('title', 'Channel A');
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'live:p1:a');
|
||||
await render();
|
||||
});
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('records a channel once its stream has played for two seconds', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer({ sessionKey: '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({ streamUrls: ['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({ sessionKey: '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 count seeks of paused media as watched time', () => {
|
||||
const commit = jest.fn();
|
||||
gate.defer({ sessionKey: 'live:p1:a' }, commit);
|
||||
|
||||
[0, 1, 2, 3].forEach((currentTime) =>
|
||||
vjs().timeUpdate.emit({ currentTime, duration: 0, playing: false })
|
||||
);
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not credit the next channel with the previous one', async () => {
|
||||
const next = jest.fn();
|
||||
playTo(0, 1.5);
|
||||
|
||||
fixture.componentRef.setInput('streamUrl', 'https://example.com/b');
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'live:p1:b');
|
||||
gate.defer({ sessionKey: 'live:p1:b' }, next);
|
||||
await render();
|
||||
playTo(0, 0.5);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
playTo(1, 2);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
async function render(): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function playTo(...positions: number[]): void {
|
||||
positions.forEach((currentTime) =>
|
||||
vjs().timeUpdate.emit({ currentTime, duration: 0 })
|
||||
);
|
||||
}
|
||||
|
||||
function vjs(): StubVjsPlayerComponent {
|
||||
return fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
|
||||
.componentInstance as StubVjsPlayerComponent;
|
||||
}
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@iptvnator/playback/util';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type Channel,
|
||||
@@ -36,6 +37,8 @@ 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 { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
||||
import {
|
||||
type PlayerMediaTitle,
|
||||
PlayerUpNextItem,
|
||||
@@ -117,8 +120,16 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
optional: true,
|
||||
});
|
||||
private readonly recoverySession = new PlaybackRecoverySession();
|
||||
private readonly historyGate = inject(PlaybackHistoryGate);
|
||||
private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator(
|
||||
this.externalPlayback
|
||||
this.externalPlayback,
|
||||
// "Open in MPV/VLC" after an inline failure is still this session's
|
||||
// view; the app-wide session confirmation carries only the URL.
|
||||
(session) =>
|
||||
this.historyGate.confirm({
|
||||
sessionKey: this.playbackSessionKey(),
|
||||
streamUrls: [session.streamUrl],
|
||||
})
|
||||
);
|
||||
private readonly applicationHandoff =
|
||||
new WebPlayerApplicationHandoffCoordinator(
|
||||
@@ -216,6 +227,15 @@ 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: this.historyGate,
|
||||
target: () => ({
|
||||
sessionKey: this.playbackSessionKey(),
|
||||
streamUrls: [this.streamUrl(), this.playback()?.streamUrl],
|
||||
}),
|
||||
sourceRevision: () => this.playbackSourceRevisionToken(),
|
||||
});
|
||||
readonly playbackExternallyTransferable = computed(() =>
|
||||
isPlaybackExternallyTransferable(this.resolvedPlayback())
|
||||
);
|
||||
@@ -358,7 +378,7 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
handleTimeUpdate(
|
||||
event: { currentTime: number; duration: number },
|
||||
event: PlayerTimeUpdate,
|
||||
ownership: PlaybackApplicationOwnership
|
||||
): void {
|
||||
if (
|
||||
@@ -380,6 +400,7 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
this.recoverySession.recordTimeUpdate(event, ownership.isLive);
|
||||
this.historyConfirmation.record(event.currentTime, event.playing);
|
||||
this.timeUpdate.emit(event);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
RecordingStartMetadata,
|
||||
RecordingStoppedEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { PlayerTimeUpdate } from '../playback-history/player-time-update';
|
||||
|
||||
/**
|
||||
* Player stand-ins for WebPlayerViewComponent specs. They mirror the real
|
||||
@@ -26,7 +27,7 @@ export class StubVjsPlayerComponent {
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
@@ -49,7 +50,7 @@ export class StubHtmlVideoPlayerComponent {
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
@@ -72,7 +73,7 @@ export class StubArtPlayerComponent {
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
@@ -102,7 +103,7 @@ export class StubEmbeddedMpvPlayerComponent {
|
||||
readonly recordingFolder = input('');
|
||||
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly timeUpdate = output<PlayerTimeUpdate>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
@@ -89,6 +89,13 @@
|
||||
"validationCommand": "pnpm nx test epg-data-access",
|
||||
"e2eTags": ["@epg"]
|
||||
},
|
||||
{
|
||||
"name": "playback-data-access",
|
||||
"root": "libs/playback/data-access",
|
||||
"sourceRoot": "libs/playback/data-access/src",
|
||||
"validationCommand": "pnpm nx test playback-data-access",
|
||||
"e2eTags": ["@persistence"]
|
||||
},
|
||||
{
|
||||
"name": "m3u-state",
|
||||
"root": "libs/m3u-state",
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
"libs/portal/shared/util/src/tokens.ts"
|
||||
],
|
||||
"@iptvnator/epg/data-access": ["libs/epg/data-access/src/index.ts"],
|
||||
"@iptvnator/playback/data-access": [
|
||||
"libs/playback/data-access/src/index.ts"
|
||||
],
|
||||
"@iptvnator/playback/util": ["libs/playback/util/src/index.ts"],
|
||||
"@iptvnator/playlist/shared/ui": [
|
||||
"libs/playlist/shared/ui/src/index.ts"
|
||||
|
||||
Reference in new issue
Block a user