Merge origin/master into agent/stalker-smart-discovery

This commit is contained in:
4gray committed 2026-08-09 13:39:37 +02:00
commit 854eb2c2e9
146 files changed
+11216 -1199

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: playback
---
MPV and VLC recovery actions now stay available, show precise opening, started, playing, and failure feedback, and prevent overlapping launches. The dock keeps Stop available when teardown is still needed and lets terminal errors be dismissed.
+11
View File
@@ -0,0 +1,11 @@
---
type: feature
area: ui
issues: [1128]
---
Live channel rows now stack their action buttons vertically — favorite on
top, programme info below — so the extra button costs no horizontal space.
The programme-info button is now also available in the Xtream and Stalker
channel sidebars, and its slot stays reserved while EPG data loads, so
buttons never jump.
+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: xtream
issues: [1128]
---
Live channels with provider catch-up now show a small archive badge — in the
channel sidebar next to the channel name, on the "all channels" grid cards,
and in the favorites and recently-viewed lists (including global favorites).
Hovering it shows the catch-up window in days.
@@ -0,0 +1,10 @@
---
type: feature
area: xtream
---
Xtream movie, series, and live catalogs now load continuously as you scroll
instead of flipping through pages — more items appear automatically as you near
the bottom, and taller windows fill themselves on open. Opening a title and
going back returns you to the exact spot in the list. In-category search and
the in-portal search results follow the same continuous style.
+84 -1
View File
@@ -288,7 +288,90 @@ Key files:
exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP
DRM suppresses external targets because its payload is not transferable. Raw
engine messages, arbitrary data, and credentials never enter recommendation
evidence or ownership state.
evidence or ownership state. MPV/VLC actions remain mounted after an attempt
and expose credential-free per-target launching/started/playing/error state;
only an exact Electron `playing` update is labelled Playing. One handshake is
allowed at a time. The renderer claims the credential-free content identity
before awaiting Electron, so primary Play is disabled and a launching or
closable-error alternative remains owned before the controller commits it.
Every route action that can start the same external playback, including
Restart and the provider-source shortcut, observes that local pre-IPC guard.
The Xtream VOD diagnostic-fallback handler records the same route-scoped
destination and pending generation before invoking MPV/VLC, so route reuse
cannot orphan that process outside the next route's close-before-play path.
Its fieldless intent is bound to the exact session returned
by the source owner's launch promise, so a late timed-out attempt cannot take
over a retry; later global updates must match that ID. A replacement waits for
confirmed teardown of the tracked external process, applies the old exact
close before launch, and cancels an unlaunched handoff if diagnostic ownership
changes. Process teardown has bounded graceful and forced confirmation
windows, and reusable MPV bounds the IPC command that precedes them; if any
stage cannot reach a confirmed exit, the exact session stays live and the
replacement fails closed instead of overlapping it. A process-wide teardown
gate starts before any potentially slow teardown preparation, including VLC
position flush and a reused player's protocol quit, and rejects every
MPV/VLC spawn until that exact child reports exit. If bounded
teardown fails while a fresh launch is still pending, that launch IPC rejects
and the exact session remains a closable error instead of hanging forever.
If a pre-content reuse failure has no still-live displaced session to restore,
the replacement error keeps its attached closer so Stop can retry the orphaned
child teardown. A terminal error without a closer is never restorable.
A failed close is single-flight only while its promise is pending: Stop can
retry the same exact child after a bounded confirmation failure. Reuse maps
the child to its current content session, so a stale older closer becomes a
no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.
A duplicate close for an already closed session returns its terminal snapshot
without re-entering the saved closer, and a late process error cannot revive
that terminal session. Reused MPV commands are bound to the socket captured
for that exact child, so a later process cannot inherit a stale protocol quit.
Stop observed before a pending MPV content command or VLC enqueue command
prevents that command from dispatching. A source handoff fails closed while
a live session has no closer (`canClose: false`); renderer Dismiss is not
teardown confirmation. That denied handoff advances neither the multi-source
switch token nor the playback generation, so it cannot cancel the sole launch
already in flight.
VLC rechecks the gate at each concrete spawn after port allocation or reuse
work; if a post-start fallback is blocked there, the opened session becomes
an error rather than retaining a false started status. A failed RC-port
allocation never claims reuse ownership, so the fallback VLC child retains
its exact one-shot closer.
Reuse failures before a content command restore the globally displaced
renderer session, not the reusable process's prior owner, and only while the
exact displaced-session ID is still active; after
`loadfile`/VLC `clear` is dispatched,
the replacement owns the process and remains a closable error instead of
restoring stale content metadata. Stop during an in-flight MPV or VLC reuse
command, including during failed-command teardown or the subsequent VLC
fallback port-allocation wait, settles that exact close without falling
through to a fresh spawn;
a stopped VLC spawn error that reports only `close` also settles its original
launch IPC with the exact closed session;
a fresh fallback retires the old child's exit under its prior session so it
cannot close the replacement. Source handoffs recheck ownership after launch
and accept only `opened`/`playing`; a stale returned session is closed exactly
and a Stop-returned `closed` session is never committed. If that exact stale
close fails, its credential-free destination owner is retained for the next
close attempt. Retained destination ownership is scoped to the initiating
playlist/VOD route key, so route reuse cannot expose Stop for the previous
movie's external session. Play/Resume capture that route key before awaiting
close and cancel if navigation changes it; a late diagnostic fallback closes
its exact returned session instead of adopting it on the new route. They
supersede an older source resolution before awaiting the shared
close-before-replacement path, and accepting a diagnostic fallback retires
the same older resolution before opening MPV/VLC. They publish the route-source
badge, caption evidence, and position only after start succeeds.
Closable errors still participate in every replacement close and keep Stop as
the global dock's only teardown affordance; Dismiss is reserved for terminal
errors that have no closer. The shared `isLiveExternalPlayerSession` predicate
keeps M3U and series ownership while
such an error can still be stopped; consumers must not treat every `error`
status as terminal.
If the local handshake times out after an exact Electron session is known,
that ID remains
correlated so a later exact update can recover the UI. The global dock mirrors
those statuses, keeps closable errors visible until Stop confirms teardown and
terminal errors visible until dismissal, and intentionally has no retry because
it does not own the original launch headers or credentials.
- The built-in HTML5/hls.js player is the second guarded consumer.
`HtmlVideoPlayerComponent` provides a component-scoped
`WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
+97 -2
View File
@@ -377,7 +377,7 @@ libs/portal/xtream/
│ │ ├── features/
│ │ │ ├── with-portal.feature.ts # Playlist & portal status
│ │ │ ├── with-content.feature.ts # Categories & streams
│ │ │ ├── with-selection.feature.ts # UI selection & pagination
│ │ │ ├── with-selection.feature.ts # UI selection & infinite-scroll window
│ │ │ ├── with-search.feature.ts # Search functionality
│ │ │ ├── with-epg.feature.ts # EPG data
│ │ │ ├── with-player.feature.ts # Stream URLs & player
@@ -415,6 +415,18 @@ Key patterns:
`ElectronXtreamDataSource` only when
`RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise it
selects `PwaXtreamDataSource`
- **Catalog lazy loading**: catalog grids scroll infinitely instead of paging.
`withSelection` keeps a `visibleCount` render window over the in-memory
catalog plus bounded per-selection scroll snapshots for detail/tab
round-trips; the shared `InfiniteScrollDirective`
(`libs/portal/shared/ui`) measures container overflow to auto-fill tall
viewports (terminating on lack of container growth, not on a load count)
and fires `loadMore` near the bottom. The search layout routes its results
container through the same directive (`nearEnd*` inputs). Transitional:
`PortalCatalogFacade.supportsInfiniteScroll` gates the shared
`CategoryContentViewComponent` — Xtream scrolls, Stalker still pages until
its server-paged append lands, after which the paged facade members and
the flag are deleted
Xtream data strategies by runtime capability:
@@ -812,7 +824,90 @@ app as a real argument, so it is not an option.
exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP
DRM suppresses external targets because its payload is not transferable. Raw
engine messages, arbitrary data, and credentials never enter recommendation
evidence or ownership state.
evidence or ownership state. MPV/VLC actions remain mounted after an attempt
and expose credential-free per-target launching/started/playing/error state;
only an exact Electron `playing` update is labelled Playing. One handshake is
allowed at a time. The renderer claims the credential-free content identity
before awaiting Electron, so primary Play is disabled and a launching or
closable-error alternative remains owned before the controller commits it.
Every route action that can start the same external playback, including
Restart and the provider-source shortcut, observes that local pre-IPC guard.
The Xtream VOD diagnostic-fallback handler records the same route-scoped
destination and pending generation before invoking MPV/VLC, so route reuse
cannot orphan that process outside the next route's close-before-play path.
Its fieldless intent is bound to the exact session returned
by the source owner's launch promise, so a late timed-out attempt cannot take
over a retry; later global updates must match that ID. A replacement waits for
confirmed teardown of the tracked external process, applies the old exact
close before launch, and cancels an unlaunched handoff if diagnostic ownership
changes. Process teardown has bounded graceful and forced confirmation
windows, and reusable MPV bounds the IPC command that precedes them; if any
stage cannot reach a confirmed exit, the exact session stays live and the
replacement fails closed instead of overlapping it. A process-wide teardown
gate starts before any potentially slow teardown preparation, including VLC
position flush and a reused player's protocol quit, and rejects every
MPV/VLC spawn until that exact child reports exit. If bounded
teardown fails while a fresh launch is still pending, that launch IPC rejects
and the exact session remains a closable error instead of hanging forever.
If a pre-content reuse failure has no still-live displaced session to restore,
the replacement error keeps its attached closer so Stop can retry the orphaned
child teardown. A terminal error without a closer is never restorable.
A failed close is single-flight only while its promise is pending: Stop can
retry the same exact child after a bounded confirmation failure. Reuse maps
the child to its current content session, so a stale older closer becomes a
no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.
A duplicate close for an already closed session returns its terminal snapshot
without re-entering the saved closer, and a late process error cannot revive
that terminal session. Reused MPV commands are bound to the socket captured
for that exact child, so a later process cannot inherit a stale protocol quit.
Stop observed before a pending MPV content command or VLC enqueue command
prevents that command from dispatching. A source handoff fails closed while
a live session has no closer (`canClose: false`); renderer Dismiss is not
teardown confirmation. That denied handoff advances neither the multi-source
switch token nor the playback generation, so it cannot cancel the sole launch
already in flight.
VLC rechecks the gate at each concrete spawn after port allocation or reuse
work; if a post-start fallback is blocked there, the opened session becomes
an error rather than retaining a false started status. A failed RC-port
allocation never claims reuse ownership, so the fallback VLC child retains
its exact one-shot closer.
Reuse failures before a content command restore the globally displaced
renderer session, not the reusable process's prior owner, and only while the
exact displaced-session ID is still active; after
`loadfile`/VLC `clear` is dispatched,
the replacement owns the process and remains a closable error instead of
restoring stale content metadata. Stop during an in-flight MPV or VLC reuse
command, including during failed-command teardown or the subsequent VLC
fallback port-allocation wait, settles that exact close without falling
through to a fresh spawn;
a stopped VLC spawn error that reports only `close` also settles its original
launch IPC with the exact closed session;
a fresh fallback retires the old child's exit under its prior session so it
cannot close the replacement. Source handoffs recheck ownership after launch
and accept only `opened`/`playing`; a stale returned session is closed exactly
and a Stop-returned `closed` session is never committed. If that exact stale
close fails, its credential-free destination owner is retained for the next
close attempt. Retained destination ownership is scoped to the initiating
playlist/VOD route key, so route reuse cannot expose Stop for the previous
movie's external session. Play/Resume capture that route key before awaiting
close and cancel if navigation changes it; a late diagnostic fallback closes
its exact returned session instead of adopting it on the new route. They
supersede an older source resolution before awaiting the shared
close-before-replacement path, and accepting a diagnostic fallback retires
the same older resolution before opening MPV/VLC. They publish the route-source
badge, caption evidence, and position only after start succeeds.
Closable errors still participate in every replacement close and keep Stop as
the global dock's only teardown affordance; Dismiss is reserved for terminal
errors that have no closer. The shared `isLiveExternalPlayerSession` predicate
keeps M3U and series ownership while
such an error can still be stopped; consumers must not treat every `error`
status as terminal.
If the local handshake times out after an exact Electron session is known,
that ID remains
correlated so a later exact update can recover the UI. The global dock mirrors
those statuses, keeps closable errors visible until Stop confirms teardown and
terminal errors visible until dismissal, and intentionally has no retry because
it does not own the original launch headers or credentials.
- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
@@ -222,23 +222,28 @@ test.describe('Electron Catalog Sorting', () => {
}
});
test('keeps Xtream catalog page after opening VOD and series details, and resets grid scroll on page changes', async ({
test('loads more Xtream catalog items on scroll and restores the spot after opening VOD and series details', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
// The 'large' scenario has 200 items per category — more than the
// initial render window, so scrolling genuinely has to load more.
const vodFixture = await fetchXtreamVodFixture(
request,
xtreamCredentials
largeXtreamCredentials
);
const seriesFixture = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
largeXtreamCredentials
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
username: largeXtreamCredentials.username,
password: largeXtreamCredentials.password,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Movies');
@@ -247,22 +252,25 @@ test.describe('Electron Catalog Sorting', () => {
vodFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
await expectNoCatalogPaginator(app.mainWindow);
const vodSearchTitle = await firstVisibleGridTitle(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await expectCatalogGrowsOnScroll(app.mainWindow);
// In-category search filters the whole list and resets the scroll.
await expectCatalogSearchResetsToFirstPage(
app.mainWindow,
vodSearchTitle
);
await expect
.poll(() => getCatalogGridScrollTop(app.mainWindow))
.toBeLessThan(2);
await clearCatalogSearch(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await clickFirstGridListCard(app.mainWindow);
await expectPathname(
await expectDetailRoundTripRestoresScroll(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
await goBackFromDetail(app.mainWindow);
await expectCatalogPageQuery(app.mainWindow, '2');
await expectCatalogGridReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(
@@ -270,22 +278,12 @@ test.describe('Electron Catalog Sorting', () => {
seriesFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await expectCatalogSearchResetsToFirstPage(
app.mainWindow,
seriesSearchTitle
);
await clearCatalogSearch(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await clickFirstGridListCard(app.mainWindow);
await expectPathname(
await expectNoCatalogPaginator(app.mainWindow);
await expectCatalogGrowsOnScroll(app.mainWindow);
await expectDetailRoundTripRestoresScroll(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
);
await goBackFromDetail(app.mainWindow);
await expectCatalogPageQuery(app.mainWindow, '2');
await expectCatalogGridReady(app.mainWindow);
} finally {
await closeElectronApp(app);
}
@@ -363,6 +361,12 @@ const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
// Mock-server scenario with 200 items per category (see xtream-mock-server
// scenarios.ts) — large enough that the infinite-scroll window must grow.
const largeXtreamCredentials = {
username: 'large',
password: 'large',
};
async function setLiveSortMode(
page: Page,
@@ -458,6 +462,63 @@ async function expectCatalogSearchResetsToFirstPage(
});
}
async function expectNoCatalogPaginator(page: Page): Promise<void> {
await expect(
page.locator('.category-content-header mat-paginator')
).toHaveCount(0);
}
function catalogCardCount(page: Page): Promise<number> {
return page.locator('.category-content-layout mat-card').count();
}
/**
* Scrolls the catalog grid to its bottom and expects the infinite-scroll
* window to append more cards. Returns the grown card count.
*/
async function expectCatalogGrowsOnScroll(page: Page): Promise<number> {
const grid = catalogGrid(page);
await expect(grid).toBeVisible({ timeout: 20000 });
const countBefore = await catalogCardCount(page);
await grid.evaluate((element: HTMLElement) => {
element.scrollTo({ top: element.scrollHeight });
});
await expect
.poll(() => catalogCardCount(page), { timeout: 20000 })
.toBeGreaterThan(countBefore);
return catalogCardCount(page);
}
/**
* From a grown, scrolled-down grid: opens a visible (bottom) card's detail,
* navigates back, and expects both the render window and the scroll offset to
* be restored instead of landing back at the top of page one.
*/
async function expectDetailRoundTripRestoresScroll(
page: Page,
detailPathname: RegExp
): Promise<void> {
const grownCount = await expectCatalogGrowsOnScroll(page);
await expect.poll(() => getCatalogGridScrollTop(page)).toBeGreaterThan(100);
// The last card is already in view at the bottom — clicking it does not
// make Playwright scroll the grid back to the top first.
await page.locator('.category-content-layout mat-card').last().click();
await expectPathname(page, detailPathname);
await goBackFromDetail(page);
await expectCatalogGridReady(page);
await expectCatalogPageQuery(page, null);
await expect
.poll(() => catalogCardCount(page), { timeout: 20000 })
.toBeGreaterThanOrEqual(grownCount);
await expect
.poll(() => getCatalogGridScrollTop(page), { timeout: 20000 })
.toBeGreaterThan(100);
}
async function expectStalkerCatalogSearchResetsToFirstPage(
page: Page,
options: { categoryId: string; title: string; type: 'series' | 'vod' }
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs';
import { basename, join } from 'path';
import type {
ExternalPlayerName,
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import {
@@ -189,6 +190,7 @@ type CapturedExternalPlayerLaunch = {
};
type PlaybackRecommendationCaptureSnapshot = {
closed: string[];
completed: number;
launches: CapturedExternalPlayerLaunch[];
released: boolean;
@@ -197,6 +199,7 @@ type PlaybackRecommendationCaptureSnapshot = {
type PlaybackRecommendationCaptureState =
PlaybackRecommendationCaptureSnapshot & {
releaseFirstResponse: (() => void) | undefined;
sessions: Record<string, ExternalPlayerSession>;
waitForFirstResponse: Promise<void>;
};
@@ -209,10 +212,12 @@ async function installPlaybackRecommendationLaunchCapture(
releaseFirstResponse = resolve;
});
const state: PlaybackRecommendationCaptureState = {
closed: [],
completed: 0,
launches: [],
released: false,
releaseFirstResponse,
sessions: {},
waitForFirstResponse,
};
const globalRef = globalThis as typeof globalThis & {
@@ -223,7 +228,7 @@ async function installPlaybackRecommendationLaunchCapture(
const captureLaunch =
(player: ExternalPlayerName) =>
async (
_event: unknown,
rawEvent: unknown,
url: string,
title: string,
thumbnail?: string,
@@ -248,30 +253,81 @@ async function installPlaybackRecommendationLaunchCapture(
],
player,
});
const now = new Date().toISOString();
const id = `e2e-recommended-${player}-${invocation}`;
const event = rawEvent as {
sender: {
send: (
channel: string,
session: ExternalPlayerSession
) => void;
};
};
const launching: ExternalPlayerSession = {
canClose: true,
id,
player,
startedAt: now,
status: 'launching',
streamUrl: url,
thumbnail: thumbnail ?? null,
title,
updatedAt: now,
};
state.sessions[id] = launching;
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', launching);
if (invocation === 1 && !state.released) {
await state.waitForFirstResponse;
}
state.completed += 1;
const now = new Date().toISOString();
return {
canClose: false,
id: `e2e-recommended-${player}-${invocation}`,
const completed: ExternalPlayerSession = {
...launching,
canClose: player === 'mpv',
error:
player === 'vlc'
? 'E2E player launch failed'
: undefined,
player,
startedAt: now,
status: 'opened',
streamUrl: url,
thumbnail: thumbnail ?? null,
title,
updatedAt: now,
status: player === 'mpv' ? 'opened' : 'error',
updatedAt: new Date().toISOString(),
};
state.sessions[id] = completed;
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', completed);
return completed;
};
ipcMain.removeHandler('OPEN_MPV_PLAYER');
ipcMain.removeHandler('OPEN_VLC_PLAYER');
ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv'));
ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc'));
ipcMain.removeHandler('CLOSE_EXTERNAL_PLAYER_SESSION');
ipcMain.handle(
'CLOSE_EXTERNAL_PLAYER_SESSION',
(rawEvent: unknown, sessionId: string) => {
const session = state.sessions[sessionId];
if (!session) return null;
const closed: ExternalPlayerSession = {
...session,
canClose: false,
status: 'closed',
updatedAt: new Date().toISOString(),
};
state.closed.push(sessionId);
state.sessions[sessionId] = closed;
const event = rawEvent as {
sender: {
send: (
channel: string,
update: ExternalPlayerSession
) => void;
};
};
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', closed);
return closed;
}
);
});
}
@@ -289,6 +345,7 @@ async function getPlaybackRecommendationCapture(
);
}
return {
closed: capture.closed,
completed: capture.completed,
launches: capture.launches,
released: capture.released,
@@ -313,7 +370,7 @@ async function releasePlaybackRecommendationCapture(
});
}
test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens in MPV', async ({
test('@electron @dash ClearKey DASH filters DRM fallback and reports external launch states', async ({
dataDir,
}) => {
const fixtureServer = await startDashFixtureServer();
@@ -439,6 +496,7 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens
},
] satisfies CapturedExternalPlayerLaunch[];
expect(await getPlaybackRecommendationCapture(app)).toEqual({
closed: [],
completed: 0,
launches: [],
released: false,
@@ -450,18 +508,33 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens
timeout: 10_000,
})
.toEqual({
closed: [],
completed: 0,
launches: expectedLaunches,
released: false,
});
expect(
(await getPlaybackRecommendationCapture(app)).launches.filter(
({ player }) => player === 'vlc'
)
).toHaveLength(0);
await expect(mpvFallback).toHaveCount(0);
await expect(vlcFallback).toHaveClass(
/web-player-diagnostic__player-card--primary/
await expect(mpvFallback).toBeVisible();
await expect(vlcFallback).toBeVisible();
await expect(mpvFallback).toContainText('Opening MPV');
await expect(mpvFallback).toContainText('Opening player');
await expect(mpvFallback).toHaveAttribute('aria-busy', 'true');
await expect(vlcFallback).toHaveAttribute('aria-disabled', 'true');
await vlcFallback.evaluate((button: HTMLButtonElement) =>
button.click()
);
await expect
.poll(() => getPlaybackRecommendationCapture(app))
.toEqual({
closed: [],
completed: 0,
launches: expectedLaunches,
released: false,
});
const dock = app.mainWindow.locator('app-external-playback-dock');
await expect(dock).toContainText('Opening player');
await expect(dock.locator('.external-playback-dock')).toHaveAttribute(
'aria-busy',
'true'
);
await releasePlaybackRecommendationCapture(app);
@@ -470,6 +543,7 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens
timeout: 10_000,
})
.toEqual({
closed: [],
completed: 1,
launches: expectedLaunches,
released: true,
@@ -483,14 +557,77 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens
})
);
expect(await getPlaybackRecommendationCapture(app)).toEqual({
closed: [],
completed: 1,
launches: expectedLaunches,
released: true,
});
await expect(mpvFallback).toHaveCount(0);
await expect(mpvFallback).toBeVisible();
await expect(mpvFallback).toContainText('Open MPV again');
await expect(mpvFallback).toContainText('Player started');
await expect(vlcFallback).toHaveClass(
/web-player-diagnostic__player-card--primary/
);
await expect(dock).toContainText('Player started');
const dockThemeStyles = await dock
.locator('.external-playback-dock')
.evaluate((element) => {
const body = element.ownerDocument.body;
const wasDark = body.classList.contains('dark-theme');
const read = () => {
const style = getComputedStyle(element);
return {
borderColor: style.borderColor,
color: style.color,
onSurface: style
.getPropertyValue('--app-heading-color')
.trim(),
surface: style
.getPropertyValue('--app-widget-bg')
.trim(),
};
};
body.classList.remove('dark-theme');
const light = read();
body.classList.add('dark-theme');
const dark = read();
body.classList.toggle('dark-theme', wasDark);
return { dark, light };
});
expect(dockThemeStyles.light.surface).not.toBe('');
expect(dockThemeStyles.dark.surface).not.toBe('');
expect(dockThemeStyles.light.onSurface).not.toBe('');
expect(dockThemeStyles.dark.onSurface).not.toBe('');
expect(dockThemeStyles.light).not.toEqual(dockThemeStyles.dark);
await vlcFallback.click();
const expectedBothLaunches = [
...expectedLaunches,
{ ...expectedLaunches[0], player: 'vlc' as const },
];
await expect
.poll(() => getPlaybackRecommendationCapture(app), {
timeout: 10_000,
})
.toEqual({
closed: ['e2e-recommended-mpv-1'],
completed: 2,
launches: expectedBothLaunches,
released: true,
});
await expect(mpvFallback).toBeVisible();
await expect(vlcFallback).toBeVisible();
await expect(vlcFallback).toContainText('Try VLC again');
await expect(vlcFallback).toContainText('External player error');
await expect(mpvFallback).toHaveClass(
/web-player-diagnostic__player-card--primary/
);
await expect(dock).toContainText('E2E player launch failed');
await expect(
dock.getByRole('button', { name: 'Dismiss' })
).toBeVisible();
await dock.getByRole('button', { name: 'Dismiss' }).click();
await expect(dock).toBeHidden();
} finally {
await releasePlaybackRecommendationCapture(app).catch(() => undefined);
await closeElectronApp(app);
@@ -64,6 +64,12 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
).first();
await expect(channelRow).toBeVisible({ timeout: 20000 });
// Provider-declared catch-up (tv_archive=1 in the fixture) is
// surfaced as a badge on the sidebar row (#1128).
await expect(
channelRow.getByTestId('catchup-badge')
).toBeVisible();
// Sidebar channel list shows the per-channel "now" programme line.
await expect
.poll(async () =>
@@ -0,0 +1,150 @@
import type { ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import {
ExternalPlayerProcessTeardownGate,
terminateExternalPlayerProcess,
waitForExternalPlayerProcessExit,
} from './external-player-process';
function createMockChildProcess(): ChildProcess {
return Object.assign(new EventEmitter(), {
exitCode: null,
killed: false,
kill: jest.fn(() => true),
signalCode: null,
stderr: null,
stdout: null,
unref: jest.fn(),
}) as unknown as ChildProcess;
}
describe('external player process teardown', () => {
it('accepts close as confirmed termination after a spawn error', async () => {
const child = createMockChildProcess();
child.on('error', () => undefined);
let settled = false;
const exit = waitForExternalPlayerProcessExit(child).then(() => {
settled = true;
});
child.emit(
'error',
Object.assign(new Error('spawn failed'), { code: 'ENOENT' })
);
await Promise.resolve();
expect(settled).toBe(false);
child.emit('close', -2, null);
await expect(exit).resolves.toBeUndefined();
});
it('does not treat a process error as confirmed exit', async () => {
const child = createMockChildProcess();
child.on('error', () => undefined);
let settled = false;
const exit = waitForExternalPlayerProcessExit(child).then(() => {
settled = true;
});
child.emit('error', new Error('kill failed'));
await Promise.resolve();
expect(settled).toBe(false);
Object.defineProperty(child, 'exitCode', { value: 0 });
child.emit('exit', 0);
await expect(exit).resolves.toBeUndefined();
});
it('escalates teardown and resolves only after confirmed exit', async () => {
jest.useFakeTimers();
try {
const child = createMockChildProcess();
const teardown = terminateExternalPlayerProcess(child);
expect(child.kill).toHaveBeenNthCalledWith(1);
await jest.advanceTimersByTimeAsync(3_000);
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
child.emit('close', null, 'SIGKILL');
await expect(teardown).resolves.toBeUndefined();
} finally {
jest.useRealTimers();
}
});
it('allows a protocol quit grace period before forced termination', async () => {
jest.useFakeTimers();
try {
const child = createMockChildProcess();
const teardown = terminateExternalPlayerProcess(child, {
sendTerminationSignal: false,
});
expect(child.kill).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(3_000);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
child.emit('exit', null, 'SIGKILL');
await expect(teardown).resolves.toBeUndefined();
} finally {
jest.useRealTimers();
}
});
it('rejects after a bounded forced-teardown wait', async () => {
jest.useFakeTimers();
try {
const child = createMockChildProcess();
const teardown = terminateExternalPlayerProcess(child);
const result = expect(teardown).rejects.toThrow(
'External player process did not exit'
);
await jest.advanceTimersByTimeAsync(5_000);
await result;
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
} finally {
jest.useRealTimers();
}
});
it('blocks replacement launches until an unconfirmed child actually exits', async () => {
jest.useFakeTimers();
try {
const child = createMockChildProcess();
const gate = new ExternalPlayerProcessTeardownGate();
const teardown = gate.terminate(child);
const rejection = expect(teardown).rejects.toThrow(
'External player process did not exit'
);
expect(() => gate.assertLaunchAllowed()).toThrow(
'previous external player is still shutting down'
);
await jest.advanceTimersByTimeAsync(5_000);
await rejection;
expect(() => gate.assertLaunchAllowed()).toThrow(
'previous external player is still shutting down'
);
Object.defineProperty(child, 'exitCode', { value: 0 });
child.emit('exit', 0);
expect(() => gate.assertLaunchAllowed()).not.toThrow();
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,189 @@
import type { ChildProcess } from 'child_process';
const TERMINATION_GRACE_PERIOD_MS = 3_000;
const FORCED_TERMINATION_WAIT_MS = 2_000;
interface TerminateExternalPlayerProcessOptions {
sendTerminationSignal?: boolean;
}
const EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR =
'Cannot launch player because the previous external player is still shutting down';
/**
* Serializes replacement launches against exact children whose exit has not
* been confirmed. `ChildProcess.killed` only means a signal was sent, so it is
* deliberately not used as a release condition.
*/
export class ExternalPlayerProcessTeardownGate {
private readonly pending = new Map<ChildProcess, () => void>();
assertLaunchAllowed(): void {
for (const child of this.pending.keys()) {
if (hasExited(child)) {
this.release(child);
}
}
if (this.pending.size > 0) {
throw new Error(EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR);
}
}
/**
* Guard replacement launches before a potentially slow protocol-level
* quit command is dispatched. `terminate()` keeps using the same exact
* child registration once that command completes or fails.
*/
beginTeardown(child: ChildProcess): void {
this.track(child);
}
async terminate(
child: ChildProcess,
options: TerminateExternalPlayerProcessOptions = {}
): Promise<void> {
this.track(child);
try {
await terminateExternalPlayerProcess(child, options);
} finally {
if (hasExited(child)) {
this.release(child);
}
}
}
terminateInBackground(child: ChildProcess): void {
void this.terminate(child).catch(() => {
// The child remains registered until an exact exit/close event.
// A later launch therefore still fails closed.
});
}
private track(child: ChildProcess): void {
if (hasExited(child) || this.pending.has(child)) {
return;
}
const release = () => this.release(child);
this.pending.set(child, release);
child.once('exit', release);
child.once('close', release);
}
private release(child: ChildProcess): void {
const release = this.pending.get(child);
if (!release) {
return;
}
child.off('exit', release);
child.off('close', release);
this.pending.delete(child);
}
}
export const externalPlayerProcessTeardownGate =
new ExternalPlayerProcessTeardownGate();
/**
* Resolves only after the exact child has stopped. A sent termination signal
* is not itself proof that a replacement can be started safely.
*/
export function waitForExternalPlayerProcessExit(
child: ChildProcess
): Promise<void> {
if (hasExited(child)) {
return Promise.resolve();
}
return new Promise((resolve) => {
const complete = () => {
child.off('exit', complete);
child.off('close', complete);
resolve();
};
child.once('exit', complete);
child.once('close', complete);
});
}
export async function terminateExternalPlayerProcess(
child: ChildProcess,
options: TerminateExternalPlayerProcessOptions = {}
): Promise<void> {
if (hasExited(child)) {
return;
}
if (
options.sendTerminationSignal !== false &&
!hasExited(child) &&
!child.killed
) {
try {
child.kill();
} catch {
// The forced termination attempt below still gets a chance to
// confirm that the exact child stopped.
}
}
if (
await waitForExternalPlayerProcessExitWithin(
child,
TERMINATION_GRACE_PERIOD_MS
)
) {
return;
}
try {
child.kill('SIGKILL');
} catch {
// Keep waiting for the bounded confirmation window. If the child does
// not report exit, reject so callers cannot launch a replacement.
}
if (
await waitForExternalPlayerProcessExitWithin(
child,
FORCED_TERMINATION_WAIT_MS
)
) {
return;
}
throw new Error('External player process did not exit');
}
function waitForExternalPlayerProcessExitWithin(
child: ChildProcess,
timeoutMs: number
): Promise<boolean> {
if (hasExited(child)) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const complete = (exited: boolean) => {
child.off('exit', onExit);
child.off('close', onExit);
if (timer) {
clearTimeout(timer);
}
resolve(exited);
};
const onExit = () => complete(true);
child.once('exit', onExit);
child.once('close', onExit);
timer = setTimeout(() => complete(hasExited(child)), timeoutMs);
timer.unref();
});
}
function hasExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
@@ -47,7 +47,27 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(registry.getActiveSessionId()).toBeNull();
});
it('marks the session closed even when the runtime close fails', async () => {
it('does not invoke a stale closer for an already closed session', async () => {
const close = jest.fn();
const session = registry.beginSession({
player: 'mpv',
title: 'Closed',
streamUrl: 'https://example.com/closed.m3u8',
});
registry.attachCloser(session.id, close);
registry.markClosed(session.id);
const closed = await registry.closeSession(session.id);
expect(close).not.toHaveBeenCalled();
expect(closed).toMatchObject({
id: session.id,
status: 'closed',
canClose: false,
});
});
it('keeps the session live when runtime close cannot be confirmed', async () => {
const close = jest.fn().mockRejectedValue(new Error('close failed'));
const session = registry.beginSession({
player: 'vlc',
@@ -57,12 +77,14 @@ describe('ExternalPlayerSessionRegistry', () => {
registry.attachCloser(session.id, close);
const closed = await registry.closeSession(session.id);
await expect(registry.closeSession(session.id)).rejects.toThrow(
'close failed'
);
expect(close).toHaveBeenCalled();
expect(closed?.status).toBe('closed');
expect(closed?.canClose).toBe(false);
expect(registry.getActiveSessionId()).toBeNull();
expect(registry.getSession(session.id)?.status).toBe('launching');
expect(registry.getSession(session.id)?.canClose).toBe(true);
expect(registry.getActiveSessionId()).toBe(session.id);
});
it('marks runtime failures as errors without clearing the active id', () => {
@@ -78,4 +100,121 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(errored?.error).toBe('Failed to launch');
expect(registry.getActiveSessionId()).toBe(session.id);
});
it('does not overwrite terminal status with late lifecycle updates', () => {
const failed = registry.beginSession({
player: 'mpv',
title: 'Failed',
streamUrl: 'https://example.com/failed.m3u8',
});
registry.markError(failed.id, 'Failed to launch');
expect(registry.markOpened(failed.id)?.status).toBe('error');
const closed = registry.beginSession({
player: 'vlc',
title: 'Closed',
streamUrl: 'https://example.com/closed.m3u8',
});
registry.markClosed(closed.id);
expect(registry.markPlaying(closed.id)?.status).toBe('closed');
expect(
registry.markError(closed.id, 'Late process exit')?.status
).toBe('closed');
expect(updates.at(-1)).toMatchObject({
id: closed.id,
status: 'closed',
});
});
it('keeps confirmed playback ahead of a late opened acknowledgement', () => {
const session = registry.beginSession({
player: 'mpv',
title: 'Fast playback',
streamUrl: 'https://example.com/fast.m3u8',
});
expect(registry.markPlaying(session.id)?.status).toBe('playing');
expect(registry.markOpened(session.id)?.status).toBe('playing');
});
it('restores a previous live session after a replacement fails', () => {
const previous = registry.beginSession({
player: 'mpv',
title: 'Previous',
streamUrl: 'https://example.com/previous.m3u8',
});
registry.attachCloser(previous.id, jest.fn());
registry.markOpened(previous.id);
const replacement = registry.beginSession({
player: 'mpv',
title: 'Replacement',
streamUrl: 'https://example.com/replacement.m3u8',
});
registry.markError(replacement.id, 'teardown unconfirmed');
const restored = registry.restoreActiveSession(
previous.id,
replacement.id
);
expect(restored).toMatchObject({
id: previous.id,
status: 'opened',
canClose: true,
});
expect(registry.getActiveSessionId()).toBe(previous.id);
expect(updates.at(-1)).toMatchObject({
id: previous.id,
restoredFromSessionId: replacement.id,
});
});
it('does not restore over a newer active session', () => {
const previous = registry.beginSession({
player: 'mpv',
title: 'Previous',
streamUrl: 'https://example.com/previous.m3u8',
});
registry.markOpened(previous.id);
const failedReplacement = registry.beginSession({
player: 'mpv',
title: 'Failed replacement',
streamUrl: 'https://example.com/failed.m3u8',
});
const newer = registry.beginSession({
player: 'vlc',
title: 'Newer',
streamUrl: 'https://example.com/newer.m3u8',
});
const updateCount = updates.length;
expect(
registry.restoreActiveSession(previous.id, failedReplacement.id)
).toBeNull();
expect(registry.getActiveSessionId()).toBe(newer.id);
expect(updates).toHaveLength(updateCount);
});
it('does not restore an unclosable terminal error session', () => {
const terminal = registry.beginSession({
player: 'mpv',
title: 'Terminal failure',
streamUrl: 'https://example.com/terminal.m3u8',
});
registry.markError(terminal.id, 'No player process remains');
const replacement = registry.beginSession({
player: 'vlc',
title: 'Replacement',
streamUrl: 'https://example.com/replacement.m3u8',
});
const updateCount = updates.length;
expect(
registry.restoreActiveSession(terminal.id, replacement.id)
).toBeNull();
expect(registry.getActiveSessionId()).toBe(replacement.id);
expect(updates).toHaveLength(updateCount);
});
});
@@ -23,6 +23,19 @@ interface ExternalPlayerSessionRuntime {
close?: () => Promise<void> | void;
}
interface MarkExternalPlayerSessionErrorOptions {
canClose?: boolean;
}
function isRestorableSession(session: ExternalPlayerSession): boolean {
return (
session.status === 'launching' ||
session.status === 'opened' ||
session.status === 'playing' ||
(session.status === 'error' && session.canClose)
);
}
export class ExternalPlayerSessionRegistry {
private readonly sessions = new Map<string, ExternalPlayerSessionRuntime>();
private activeSessionId: string | null = null;
@@ -62,6 +75,32 @@ export class ExternalPlayerSessionRegistry {
return this.sessions.get(id)?.snapshot ?? null;
}
/**
* Re-publish the exact still-live session that a failed replacement had
* temporarily displaced. Its existing closer remains attached.
*/
restoreActiveSession(
id: string,
displacedSessionId: string
): ExternalPlayerSession | null {
const runtime = this.sessions.get(id);
if (
!runtime ||
!isRestorableSession(runtime.snapshot) ||
this.activeSessionId !== displacedSessionId
) {
return null;
}
this.activeSessionId = id;
this.onUpdate({
...runtime.snapshot,
updatedAt: new Date().toISOString(),
restoredFromSessionId: displacedSessionId,
});
return runtime.snapshot;
}
attachCloser(
id: string,
close: () => Promise<void> | void
@@ -95,12 +134,21 @@ export class ExternalPlayerSessionRegistry {
}
markOpened(id: string): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status !== 'launching') {
return current;
}
return this.updateSession(id, { status: 'opened' });
}
markPlaying(id: string): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status === 'playing') {
if (
!current ||
current.status === 'playing' ||
current.status === 'error' ||
current.status === 'closed'
) {
return current;
}
@@ -108,17 +156,30 @@ export class ExternalPlayerSessionRegistry {
}
markClosed(id: string): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status === 'closed') {
return current;
}
if (this.activeSessionId === id) {
this.activeSessionId = null;
}
return this.updateSession(id, { status: 'closed', canClose: false });
}
markError(id: string, error: string): ExternalPlayerSession | null {
markError(
id: string,
error: string,
options: MarkExternalPlayerSessionErrorOptions = {}
): ExternalPlayerSession | null {
const current = this.getSession(id);
if (!current || current.status === 'closed') {
return current;
}
return this.updateSession(id, {
status: 'error',
error,
canClose: false,
canClose: options.canClose ?? false,
});
}
@@ -128,13 +189,19 @@ export class ExternalPlayerSessionRegistry {
return null;
}
try {
await runtime.close?.();
} catch {
// Close failures must not keep the session in a live state; the
// registry still reports it as closed below.
// A renderer can deliver a delayed duplicate Stop after the exact
// child has already exited and a newer external player owns the
// process slot. Never re-enter the terminal session's saved closer:
// its protocol endpoint may since have been reused by another child.
if (runtime.snapshot.status === 'closed') {
return runtime.snapshot;
}
// A failed closer cannot prove that the underlying process stopped.
// Preserve the live session and propagate the failure so callers do
// not start a replacement process alongside it.
await runtime.close?.();
return this.markClosed(id);
}
}
@@ -0,0 +1,325 @@
import { ChildProcess } from 'child_process';
import { createConnection } from 'net';
import {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { joinMpvHeaderFields } from '../util/mpv-string-list.util';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
externalPlayerSessions,
traceExternalPlayer,
} from './external-player-runtime';
const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000;
export interface MpvReuseAttemptState {
contentMutated: boolean;
teardownUnconfirmed: boolean;
}
interface MpvReuseOptions {
session: ExternalPlayerSession;
previousProcessSessionId: string | null;
url: string;
title: string;
effectiveUserAgent?: string;
effectiveReferer?: string;
headerFields: string[];
contentInfo?: PlayerContentInfo;
startTime?: number;
state: MpvReuseAttemptState;
startPositionPolling: (
socketPath: string,
contentInfo: PlayerContentInfo,
sessionId: string
) => void;
stopPositionPolling: () => void;
}
function sendMpvCommand(
socketPath: string,
command: string,
args: Array<string | number>,
shouldDispatch?: () => boolean
): Promise<boolean> {
return new Promise((resolve, reject) => {
const client = createConnection(socketPath);
const request = JSON.stringify({ command: [command, ...args] }) + '\n';
let settled = false;
let timeoutHandle: NodeJS.Timeout | null = null;
const complete = (error?: Error, dispatched = true) => {
if (settled) return;
settled = true;
if (timeoutHandle) clearTimeout(timeoutHandle);
if (!dispatched && !client.destroyed) client.destroy();
if (error) {
reject(error);
} else {
resolve(dispatched);
}
};
client.on('connect', () => {
if (shouldDispatch && !shouldDispatch()) {
complete(undefined, false);
return;
}
traceExternalPlayer('mpv ipc command', {
command,
argsCount: args.length,
});
try {
client.write(request);
client.end();
complete();
} catch (error) {
complete(
error instanceof Error ? error : new Error(String(error))
);
}
});
client.on('error', (error) => complete(error));
timeoutHandle = setTimeout(() => {
complete(new Error('MPV IPC command timed out'));
client.destroy();
}, MPV_IPC_COMMAND_TIMEOUT_MS);
timeoutHandle.unref();
});
}
/** Owns the one MPV child/socket retained when instance reuse is enabled. */
export class MpvReusableProcess {
private process: ChildProcess | null = null;
private socketPath: string | null = null;
private processSessionId: string | null = null;
private readonly processSessionIds = new WeakMap<ChildProcess, string>();
currentSessionId(): string | null {
return this.processSessionId;
}
sessionIdFor(process: ChildProcess, fallback: string): string {
return this.processSessionIds.get(process) ?? fallback;
}
owns(process: ChildProcess, sessionId: string): boolean {
return this.processSessionIds.get(process) === sessionId;
}
track(process: ChildProcess, socketPath: string, sessionId: string): void {
this.process = process;
this.socketPath = socketPath;
this.processSessionId = sessionId;
this.processSessionIds.set(process, sessionId);
}
clear(process: ChildProcess): boolean {
if (this.process !== process) return false;
this.process = null;
this.socketPath = null;
this.processSessionId = null;
return true;
}
stopStored(
reason: string,
stopPositionPolling: () => void,
guardFutureLaunches = false
): void {
const process = this.process;
if (!process || process.killed) return;
traceExternalPlayer(reason);
if (guardFutureLaunches) {
externalPlayerProcessTeardownGate.terminateInBackground(process);
} else {
process.kill();
}
this.clear(process);
stopPositionPolling();
}
async tryReuse(
options: MpvReuseOptions
): Promise<ExternalPlayerSession | null> {
const reusedProcess = this.process;
const reusedSocketPath = this.socketPath;
if (!reusedProcess || reusedProcess.killed || !reusedSocketPath) {
return null;
}
traceExternalPlayer('reuse existing mpv instance');
const { session, state } = options;
const reusedProcessSessionId =
this.processSessionIds.get(reusedProcess) ??
options.previousProcessSessionId;
let closeRequested = false;
let retryableClose: Promise<void> | null = null;
let launchClose: Promise<void> | null = null;
const ownsReusedProcess = () =>
this.processSessionIds.get(reusedProcess) === session.id ||
externalPlayerSessions.getActiveSessionId() === session.id;
const closeReusedProcess = async () => {
externalPlayerProcessTeardownGate.beginTeardown(reusedProcess);
try {
await sendMpvCommand(reusedSocketPath, 'quit', []);
} catch {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
return;
}
await externalPlayerProcessTeardownGate.terminate(reusedProcess, {
sendTerminationSignal: false,
});
};
const finishRequestedClose = async () => {
if (launchClose) {
try {
await launchClose;
} catch (error) {
state.teardownUnconfirmed = true;
throw error;
}
}
return externalPlayerSessions.markClosed(session.id) ?? session;
};
externalPlayerSessions.attachCloser(session.id, () => {
closeRequested = true;
if (!ownsReusedProcess()) return;
if (!retryableClose) {
const closeAttempt = closeReusedProcess();
retryableClose = closeAttempt;
launchClose ??= closeAttempt;
void closeAttempt.catch((error) => {
if (retryableClose === closeAttempt) retryableClose = null;
state.teardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{ canClose: true }
);
});
}
return retryableClose;
});
try {
await this.applyReuseCommands(
options,
reusedSocketPath,
() => !closeRequested
);
if (closeRequested) return await finishRequestedClose();
state.contentMutated = true;
this.processSessionId = session.id;
this.processSessionIds.set(reusedProcess, session.id);
options.stopPositionPolling();
if (options.startTime) {
await sendMpvCommand(
reusedSocketPath,
'seek',
[String(options.startTime), 'absolute'],
() => !closeRequested
);
}
if (closeRequested) return await finishRequestedClose();
if (options.contentInfo) {
options.startPositionPolling(
reusedSocketPath,
options.contentInfo,
session.id
);
} else {
options.stopPositionPolling();
}
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (error) {
const current = externalPlayerSessions.getSession(session.id);
if (current?.status === 'closed') return current;
if (closeRequested) return await finishRequestedClose();
console.error('Failed to send command to existing MPV:', error);
if (state.contentMutated) {
if (reusedProcessSessionId) {
this.processSessionIds.set(
reusedProcess,
reusedProcessSessionId
);
} else {
this.processSessionIds.delete(reusedProcess);
}
}
try {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
} catch (teardownError) {
if (state.contentMutated) {
this.processSessionIds.set(reusedProcess, session.id);
}
state.teardownUnconfirmed = true;
throw teardownError;
}
this.clear(reusedProcess);
options.stopPositionPolling();
if (closeRequested) return await finishRequestedClose();
return null;
}
}
private async applyReuseCommands(
options: MpvReuseOptions,
socketPath: string,
shouldDispatch: () => boolean
): Promise<void> {
if (options.effectiveUserAgent) {
const dispatched = await sendMpvCommand(
socketPath,
'set_property',
['user-agent', options.effectiveUserAgent],
shouldDispatch
);
if (!dispatched) return;
}
if (options.effectiveReferer) {
const dispatched = await sendMpvCommand(
socketPath,
'set_property',
['referrer', options.effectiveReferer],
shouldDispatch
);
if (!dispatched) return;
}
if (options.headerFields.length > 0) {
const dispatched = await sendMpvCommand(
socketPath,
'set_property',
[
'http-header-fields',
joinMpvHeaderFields(options.headerFields),
],
shouldDispatch
);
if (!dispatched) return;
}
if (!shouldDispatch()) return;
const loadFileArgs: Array<string | number> = [options.url, 'replace'];
if (options.title) {
loadFileArgs.push(-1, `force-media-title=${options.title}`);
}
const dispatched = await sendMpvCommand(
socketPath,
'loadfile',
loadFileArgs,
shouldDispatch
);
if (!dispatched) return;
traceExternalPlayer('loaded new url in existing mpv instance');
}
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
import { ChildProcess, spawn } from 'child_process';
import { spawn } from 'child_process';
import { createConnection } from 'net';
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import {
@@ -29,6 +29,11 @@ import {
sendPlayerErrorNotification,
traceExternalPlayer,
} from './external-player-runtime';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
MpvReusableProcess,
MpvReuseAttemptState,
} from './mpv-reusable-process';
export interface OpenExternalPlayerRequest {
url: string;
@@ -42,8 +47,7 @@ export interface OpenExternalPlayerRequest {
headers?: Record<string, string>;
}
let mpvProcess: ChildProcess | null = null;
let mpvSocketPath: string | null = null;
const reusableMpvProcess = new MpvReusableProcess();
let positionPollingInterval: NodeJS.Timeout | null = null;
function getMpvPath(options: PlayerPathOptions = {}): string {
@@ -152,53 +156,16 @@ function startPositionPolling(
}, 2000);
}
function sendMpvCommand(
command: string,
args: Array<string | number>
): Promise<void> {
return new Promise((resolve, reject) => {
if (!mpvSocketPath) {
reject(new Error('No MPV socket path available'));
return;
}
const client = createConnection(mpvSocketPath);
const request = JSON.stringify({ command: [command, ...args] }) + '\n';
client.on('connect', () => {
traceExternalPlayer('mpv ipc command', {
command,
argsCount: args.length,
});
client.write(request);
client.end();
resolve();
});
client.on('error', (err) => {
console.error('MPV socket error:', err);
reject(err);
});
});
}
function killStoredMpvProcess(reason: string): void {
if (!mpvProcess || mpvProcess.killed) {
return;
}
traceExternalPlayer(reason);
mpvProcess.kill();
mpvProcess = null;
mpvSocketPath = null;
stopPositionPolling();
}
export function setMpvReuseInstance(reuseInstance: boolean): void {
traceExternalPlayer('set mpv reuse instance', { reuseInstance });
store.set(MPV_REUSE_INSTANCE, reuseInstance);
if (!reuseInstance) {
killStoredMpvProcess('clean up mpv process after disabling reuse');
reusableMpvProcess.stopStored(
'clean up mpv process after disabling reuse',
stopPositionPolling,
true
);
}
}
@@ -208,7 +175,10 @@ export function setMpvReuseInstance(reuseInstance: boolean): void {
* app and keeps playing after quit.
*/
export function shutdownMpvSession(): void {
killStoredMpvProcess('kill reused mpv process on app shutdown');
reusableMpvProcess.stopStored(
'kill reused mpv process on app shutdown',
stopPositionPolling
);
}
export async function openMpvPlayer({
@@ -222,6 +192,9 @@ export async function openMpvPlayer({
startTime,
headers,
}: OpenExternalPlayerRequest) {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
const previousProcessSessionId = reusableMpvProcess.currentSessionId();
const session = externalPlayerSessions.beginSession({
player: 'mpv',
title,
@@ -229,6 +202,11 @@ export async function openMpvPlayer({
streamUrl: url,
contentInfo,
});
const reuseState: MpvReuseAttemptState = {
teardownUnconfirmed: false,
contentMutated: false,
};
let freshTeardownUnconfirmed = false;
try {
const isFlatpak = isRunningInFlatpak();
@@ -273,80 +251,22 @@ export async function openMpvPlayer({
parseExternalPlayerArguments(customMpvArguments).length,
});
if (
reuseInstance &&
mpvProcess &&
!mpvProcess.killed &&
mpvSocketPath
) {
traceExternalPlayer('reuse existing mpv instance');
try {
if (effectiveUserAgent) {
await sendMpvCommand('set_property', [
'user-agent',
effectiveUserAgent,
]);
}
if (effectiveReferer) {
await sendMpvCommand('set_property', [
'referrer',
effectiveReferer,
]);
}
if (headerFields.length > 0) {
await sendMpvCommand('set_property', [
'http-header-fields',
joinMpvHeaderFields(headerFields),
]);
}
const loadFileArgs: Array<string | number> = [url, 'replace'];
const loadFileOptions: string[] = [];
if (title) {
loadFileOptions.push(`force-media-title=${title}`);
}
if (loadFileOptions.length > 0) {
loadFileArgs.push(-1, loadFileOptions.join(','));
}
await sendMpvCommand('loadfile', loadFileArgs);
traceExternalPlayer('loaded new url in existing mpv instance');
externalPlayerSessions.attachCloser(session.id, async () => {
try {
await sendMpvCommand('quit', []);
} catch {
if (mpvProcess && !mpvProcess.killed) {
mpvProcess.kill();
}
}
});
if (startTime) {
await sendMpvCommand('seek', [
String(startTime),
'absolute',
]);
}
if (contentInfo) {
startPositionPolling(
mpvSocketPath,
contentInfo,
session.id
);
} else {
stopPositionPolling();
}
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (err) {
console.error('Failed to send command to existing MPV:', err);
mpvProcess = null;
mpvSocketPath = null;
stopPositionPolling();
}
if (reuseInstance) {
const reused = await reusableMpvProcess.tryReuse({
session,
previousProcessSessionId,
url,
title,
effectiveUserAgent,
effectiveReferer,
headerFields,
contentInfo,
startTime,
state: reuseState,
startPositionPolling,
stopPositionPolling,
});
if (reused) return reused;
}
traceExternalPlayer('create new mpv instance');
@@ -373,7 +293,9 @@ export async function openMpvPlayer({
}
if (headerFields.length > 0) {
args.push(`--http-header-fields=${joinMpvHeaderFields(headerFields)}`);
args.push(
`--http-header-fields=${joinMpvHeaderFields(headerFields)}`
);
}
if (title) {
@@ -387,10 +309,36 @@ export async function openMpvPlayer({
args.push(url);
await new Promise<void>((resolve, reject) => {
let settled = false;
let startConfirmationTimer: NodeJS.Timeout | null = null;
const resolveLaunch = () => {
if (settled) {
return;
}
settled = true;
if (startConfirmationTimer) {
clearTimeout(startConfirmationTimer);
}
resolve();
};
const rejectLaunch = (error: Error) => {
if (settled) {
return;
}
settled = true;
if (startConfirmationTimer) {
clearTimeout(startConfirmationTimer);
}
reject(error);
};
const spawnSpec = buildExternalPlayerSpawnSpec(
mpvLaunchContext,
buildPlayerArgsWithCustomArguments(customMpvArguments, args)
);
// Reuse teardown yields while waiting for the old child. Another
// external process may enter teardown during that window, so the
// process-wide invariant must be checked at the actual spawn too.
externalPlayerProcessTeardownGate.assertLaunchAllowed();
const proc = spawn(spawnSpec.command, spawnSpec.args, {
shell: false,
detached: !reuseInstance,
@@ -455,20 +403,28 @@ export async function openMpvPlayer({
proc.on('error', (err) => {
console.error('Failed to start MPV player:', err);
mpvProcess = null;
mpvSocketPath = null;
const processSessionId = reusableMpvProcess.sessionIdFor(
proc,
session.id
);
reusableMpvProcess.clear(proc);
stopPositionPolling();
externalPlayerSessions.markError(
session.id,
processSessionId,
`Failed to start MPV player: ${err.message}`
);
reject(buildPlayerStartError('MPV', err, mpvLaunchContext));
rejectLaunch(
buildPlayerStartError('MPV', err, mpvLaunchContext)
);
});
proc.on('exit', (code) => {
traceExternalPlayer('mpv exited', { code });
mpvProcess = null;
mpvSocketPath = null;
const processSessionId = reusableMpvProcess.sessionIdFor(
proc,
session.id
);
reusableMpvProcess.clear(proc);
stopPositionPolling();
if (code !== 0 && code !== null) {
@@ -480,18 +436,19 @@ export async function openMpvPlayer({
`MPV player closed unexpectedly (exit code: ${code})`
);
externalPlayerSessions.markError(
session.id,
processSessionId,
`MPV player closed unexpectedly (exit code: ${code})`
);
resolveLaunch();
return;
}
externalPlayerSessions.markClosed(session.id);
externalPlayerSessions.markClosed(processSessionId);
resolveLaunch();
});
if (reuseInstance && socketPath) {
mpvProcess = proc;
mpvSocketPath = socketPath;
reusableMpvProcess.track(proc, socketPath, session.id);
traceExternalPlayer('stored mpv process for reuse', {
socketPath,
});
@@ -500,8 +457,27 @@ export async function openMpvPlayer({
}
externalPlayerSessions.attachCloser(session.id, async () => {
if (!proc.killed) {
proc.kill();
if (
reuseInstance &&
!reusableMpvProcess.owns(proc, session.id)
) {
return;
}
try {
await externalPlayerProcessTeardownGate.terminate(proc);
} catch (error) {
const teardownError =
error instanceof Error
? error
: new Error(String(error));
freshTeardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
teardownError.message,
{ canClose: true }
);
rejectLaunch(teardownError);
throw teardownError;
}
});
@@ -509,22 +485,38 @@ export async function openMpvPlayer({
startPositionPolling(socketPath, contentInfo, session.id);
}
setTimeout(() => {
startConfirmationTimer = setTimeout(() => {
if (!proc.killed) {
resolve();
resolveLaunch();
}
}, 100);
startConfirmationTimer.unref();
});
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (error) {
console.error('Error opening MPV player:', error);
mpvProcess = null;
mpvSocketPath = null;
stopPositionPolling();
if (!reuseState.teardownUnconfirmed && !freshTeardownUnconfirmed) {
stopPositionPolling();
}
const restoredSession =
reuseState.teardownUnconfirmed &&
!reuseState.contentMutated &&
displacedSessionId
? externalPlayerSessions.restoreActiveSession(
displacedSessionId,
session.id
)
: null;
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error)
error instanceof Error ? error.message : String(error),
{
canClose:
freshTeardownUnconfirmed ||
(reuseState.teardownUnconfirmed &&
(reuseState.contentMutated || !restoredSession)),
}
);
throw error;
}
@@ -0,0 +1,167 @@
import { createConnection } from 'net';
import { ExternalPlaybackSnapshot } from './external-player-runtime';
export function buildVlcEnqueueCommands(options: {
url: string;
title?: string;
userAgent?: string;
referer?: string;
origin?: string;
headers?: Record<string, string>;
startTime?: number;
}): string[] {
const inputOptions: string[] = [];
if (options.userAgent) {
inputOptions.push(`:http-user-agent=${options.userAgent}`);
}
if (options.referer) {
inputOptions.push(`:http-referrer=${options.referer}`);
} else if (options.origin) {
inputOptions.push(`:http-referrer=${options.origin}`);
}
Object.entries(options.headers ?? {}).forEach(([name, value]) => {
if (!name || value === undefined || value === null) return;
const trimmedValue = String(value).trim();
if (!trimmedValue) return;
inputOptions.push(`:http-header=${name}: ${trimmedValue}`);
});
if (options.title) {
inputOptions.push(`:meta-title=${options.title}`);
}
const inputLine =
inputOptions.length > 0
? `${options.url} ${inputOptions.join(' ')}`
: options.url;
const commands = ['clear', `add ${inputLine}`];
if (options.startTime && Number.isFinite(options.startTime)) {
commands.push(`seek ${Math.floor(options.startTime)}`);
}
return commands;
}
export function sendVlcRcCommand(
port: number,
command: string,
onDispatched?: () => void,
shouldDispatch?: () => boolean
): Promise<boolean> {
return new Promise((resolve, reject) => {
const client = createConnection({ port, host: '127.0.0.1' });
let settled = false;
const finish = (error?: Error, dispatched = true) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
if (error) reject(error);
else resolve(dispatched);
};
const timeoutHandle = setTimeout(
() => finish(new Error('VLC RC command timed out')),
2_000
);
client.on('connect', () => {
if (shouldDispatch && !shouldDispatch()) {
finish(undefined, false);
return;
}
try {
client.write(`${command}\n`);
onDispatched?.();
} catch (error) {
finish(
error instanceof Error ? error : new Error(String(error))
);
}
});
client.on('data', (chunk) => {
if (chunk.toString().includes('>')) finish();
});
client.on('error', (error) => finish(error));
});
}
export async function sendVlcRcCommands(
port: number,
commands: string[],
onCommandSent?: (command: string, index: number) => void,
shouldDispatch?: () => boolean
): Promise<void> {
for (const [index, command] of commands.entries()) {
if (shouldDispatch && !shouldDispatch()) return;
const dispatched = await sendVlcRcCommand(
port,
command,
() => onCommandSent?.(command, index),
shouldDispatch
);
if (!dispatched) return;
}
}
export function parseVlcRcNumericResponse(data: string): string {
return data.match(/>\s*(-?\d+(?:\.\d+)?)/)?.[1] ?? '';
}
export function parseVlcRcPlaybackState(data: string): string | null {
return (
data
.match(/\(\s*state\s+([^)]+)\s*\)/i)?.[1]
?.trim()
.toLowerCase() ?? null
);
}
function getVlcCommandResponse(port: number, command: string): Promise<string> {
return new Promise((resolve) => {
const client = createConnection({ port, host: '127.0.0.1' });
let data = '';
let settled = false;
const finish = (result: string) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
resolve(result);
};
const timeoutHandle = setTimeout(() => finish(''), 2_000);
client.on('connect', () => client.write(`${command}\n`));
client.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('>')) finish(data);
});
client.on('error', () => finish(''));
});
}
export async function getVlcPlaybackState(
port: number
): Promise<string | null> {
return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status'));
}
export async function getVlcPlaybackSnapshot(
port: number
): Promise<ExternalPlaybackSnapshot | null> {
const time = parseInt(
parseVlcRcNumericResponse(
await getVlcCommandResponse(port, 'get_time')
),
10
);
const duration = parseInt(
parseVlcRcNumericResponse(
await getVlcCommandResponse(port, 'get_length')
),
10
);
if (Number.isNaN(time)) return null;
return {
positionSeconds: time,
durationSeconds: Number.isNaN(duration) ? null : duration,
};
}
@@ -0,0 +1,257 @@
import { ChildProcess } from 'child_process';
import {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
ExternalPlaybackSnapshot,
externalPlayerSessions,
sendPlaybackPositionUpdate,
traceExternalPlayer,
} from './external-player-runtime';
import {
buildVlcEnqueueCommands,
sendVlcRcCommand,
sendVlcRcCommands,
} from './vlc-rc';
export interface VlcReuseAttemptState {
contentMutated: boolean;
teardownUnconfirmed: boolean;
closeRequested: boolean;
requestedClose: Promise<void> | null;
}
interface VlcReuseOptions {
session: ExternalPlayerSession;
previousProcessSessionId: string | null;
url: string;
title: string;
effectiveUserAgent?: string;
effectiveReferer?: string;
effectiveOrigin?: string;
mergedHeaders: Record<string, string>;
contentInfo?: PlayerContentInfo;
startTime?: number;
state: VlcReuseAttemptState;
startPositionPolling: (
port: number,
contentInfo: PlayerContentInfo,
sessionId: string,
onSnapshot?: (snapshot: ExternalPlaybackSnapshot) => void,
onStopped?: () => void
) => void;
stopPositionPolling: () => void;
}
/** Owns the one VLC child/RC port retained when instance reuse is enabled. */
export class VlcReusableProcess {
private process: ChildProcess | null = null;
private rcPort: number | null = null;
private processSessionId: string | null = null;
private readonly processSessionIds = new WeakMap<ChildProcess, string>();
currentSessionId(): string | null {
return this.processSessionId;
}
sessionIdFor(process: ChildProcess, fallback: string): string {
return this.processSessionIds.get(process) ?? fallback;
}
owns(process: ChildProcess, sessionId: string): boolean {
return this.processSessionIds.get(process) === sessionId;
}
track(process: ChildProcess, rcPort: number, sessionId: string): void {
this.process = process;
this.rcPort = rcPort;
this.processSessionId = sessionId;
this.processSessionIds.set(process, sessionId);
}
clear(process: ChildProcess): boolean {
if (this.process !== process) return false;
this.process = null;
this.rcPort = null;
this.processSessionId = null;
return true;
}
stopStored(
reason: string,
stopPositionPolling: () => void,
guardFutureLaunches = false
): void {
const process = this.process;
if (!process || process.killed) return;
traceExternalPlayer(reason);
if (guardFutureLaunches) {
externalPlayerProcessTeardownGate.terminateInBackground(process);
} else {
process.kill();
}
this.clear(process);
stopPositionPolling();
}
async tryReuse(
options: VlcReuseOptions
): Promise<ExternalPlayerSession | null> {
const reusedProcess = this.process;
const reusedRcPort = this.rcPort;
if (!reusedProcess || reusedProcess.killed || !reusedRcPort)
return null;
traceExternalPlayer('reuse existing vlc instance', {
rcPort: reusedRcPort,
});
const { session, state } = options;
const reusedProcessSessionId =
this.processSessionIds.get(reusedProcess) ??
options.previousProcessSessionId;
let closeRequested = false;
let retryableClose: Promise<void> | null = null;
let launchClose: Promise<void> | null = null;
const ownsReusedProcess = () =>
this.processSessionIds.get(reusedProcess) === session.id ||
externalPlayerSessions.getActiveSessionId() === session.id;
const closeReusedProcess = async () => {
externalPlayerProcessTeardownGate.beginTeardown(reusedProcess);
try {
await sendVlcRcCommand(reusedRcPort, 'quit');
} catch {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
return;
}
await externalPlayerProcessTeardownGate.terminate(reusedProcess, {
sendTerminationSignal: false,
});
};
const finishRequestedClose = async () => {
if (launchClose) {
try {
await launchClose;
} catch (error) {
state.teardownUnconfirmed = true;
throw error;
}
}
return externalPlayerSessions.markClosed(session.id) ?? session;
};
externalPlayerSessions.attachCloser(session.id, () => {
closeRequested = true;
state.closeRequested = true;
if (!ownsReusedProcess()) return;
if (!retryableClose) {
const closeAttempt = closeReusedProcess();
retryableClose = closeAttempt;
launchClose ??= closeAttempt;
void closeAttempt.catch((error) => {
if (retryableClose === closeAttempt) retryableClose = null;
state.teardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{ canClose: true }
);
});
}
state.requestedClose = retryableClose;
return retryableClose;
});
try {
await sendVlcRcCommands(
reusedRcPort,
buildVlcEnqueueCommands({
url: options.url,
title: options.title,
userAgent: options.effectiveUserAgent,
referer: options.effectiveReferer,
origin: options.effectiveOrigin,
headers: options.mergedHeaders,
startTime: options.startTime,
}),
(_command, index) => {
if (index !== 0) return;
state.contentMutated = true;
this.processSessionId = session.id;
this.processSessionIds.set(reusedProcess, session.id);
options.stopPositionPolling();
},
() => !closeRequested
);
if (closeRequested) return await finishRequestedClose();
traceExternalPlayer('loaded new url in existing vlc instance');
let lastSnapshot: ExternalPlaybackSnapshot | null = null;
if (options.contentInfo) {
options.startPositionPolling(
reusedRcPort,
options.contentInfo,
session.id,
(snapshot) => {
lastSnapshot = snapshot;
},
() => {
if (
lastSnapshot &&
externalPlayerSessions.getSession(session.id)
?.status !== 'closed'
) {
sendPlaybackPositionUpdate(
session.id,
options.contentInfo as PlayerContentInfo,
lastSnapshot
);
}
externalPlayerSessions.markClosed(session.id);
}
);
} else {
options.stopPositionPolling();
}
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (error) {
const current = externalPlayerSessions.getSession(session.id);
if (current?.status === 'closed') return current;
if (closeRequested) return await finishRequestedClose();
console.error(
'Failed to reuse existing VLC, spawning fresh:',
error
);
if (state.contentMutated) {
if (reusedProcessSessionId) {
this.processSessionIds.set(
reusedProcess,
reusedProcessSessionId
);
} else {
this.processSessionIds.delete(reusedProcess);
}
}
try {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
} catch (teardownError) {
if (state.contentMutated) {
this.processSessionIds.set(reusedProcess, session.id);
}
state.teardownUnconfirmed = true;
throw teardownError;
}
this.clear(reusedProcess);
options.stopPositionPolling();
if (closeRequested) return await finishRequestedClose();
return null;
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
import { ChildProcess, spawn } from 'child_process';
import { AddressInfo, createConnection, createServer } from 'net';
import { spawn } from 'child_process';
import { AddressInfo, createServer } from 'net';
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import {
VLC_PLAYER_ARGUMENTS,
@@ -28,6 +28,17 @@ import {
sendPlayerErrorNotification,
traceExternalPlayer,
} from './external-player-runtime';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import { getVlcPlaybackSnapshot, getVlcPlaybackState } from './vlc-rc';
export {
buildVlcEnqueueCommands,
parseVlcRcNumericResponse,
parseVlcRcPlaybackState,
} from './vlc-rc';
import {
VlcReusableProcess,
VlcReuseAttemptState,
} from './vlc-reusable-process';
export interface OpenVlcPlayerRequest {
url: string;
@@ -41,8 +52,7 @@ export interface OpenVlcPlayerRequest {
headers?: Record<string, string>;
}
let vlcProcess: ChildProcess | null = null;
let vlcRcPort: number | null = null;
const reusableVlcProcess = new VlcReusableProcess();
let vlcPollingInterval: NodeJS.Timeout | null = null;
function getVlcPath(options: PlayerPathOptions = {}): string {
@@ -52,103 +62,6 @@ function getVlcPath(options: PlayerPathOptions = {}): string {
);
}
export function buildVlcEnqueueCommands(options: {
url: string;
title?: string;
userAgent?: string;
referer?: string;
origin?: string;
headers?: Record<string, string>;
startTime?: number;
}): string[] {
const inputOptions: string[] = [];
if (options.userAgent) {
inputOptions.push(`:http-user-agent=${options.userAgent}`);
}
if (options.referer) {
inputOptions.push(`:http-referrer=${options.referer}`);
} else if (options.origin) {
inputOptions.push(`:http-referrer=${options.origin}`);
}
Object.entries(options.headers ?? {}).forEach(([name, value]) => {
if (!name || value === undefined || value === null) return;
const trimmedValue = String(value).trim();
if (!trimmedValue) return;
inputOptions.push(`:http-header=${name}: ${trimmedValue}`);
});
if (options.title) {
inputOptions.push(`:meta-title=${options.title}`);
}
const inputLine =
inputOptions.length > 0
? `${options.url} ${inputOptions.join(' ')}`
: options.url;
const commands = ['clear', `add ${inputLine}`];
if (options.startTime && Number.isFinite(options.startTime)) {
commands.push(`seek ${Math.floor(options.startTime)}`);
}
return commands;
}
function sendVlcRcCommand(port: number, command: string): Promise<void> {
return new Promise((resolve, reject) => {
const client = createConnection({ port, host: '127.0.0.1' });
let settled = false;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
if (err) {
reject(err);
return;
}
resolve();
};
const timeoutHandle = setTimeout(
() => finish(new Error('VLC RC command timed out')),
2000
);
client.on('connect', () => {
client.write(`${command}\n`);
});
client.on('data', (chunk) => {
if (chunk.toString().includes('>')) {
finish();
}
});
client.on('error', (err) => finish(err));
});
}
async function sendVlcRcCommands(
port: number,
commands: string[]
): Promise<void> {
for (const command of commands) {
await sendVlcRcCommand(port, command);
}
}
export function parseVlcRcNumericResponse(data: string): string {
const match = data.match(/>\s*(-?\d+(?:\.\d+)?)/);
return match ? match[1] : '';
}
export function parseVlcRcPlaybackState(data: string): string | null {
const match = data.match(/\(\s*state\s+([^)]+)\s*\)/i);
return match ? match[1].trim().toLowerCase() : null;
}
function stopVlcPositionPolling(): void {
if (vlcPollingInterval) {
clearInterval(vlcPollingInterval);
@@ -156,69 +69,6 @@ function stopVlcPositionPolling(): void {
}
}
async function getVlcCommandResponse(
port: number,
command: string
): Promise<string> {
return new Promise((resolve) => {
const client = createConnection({ port, host: '127.0.0.1' });
let data = '';
let resolved = false;
const done = (result: string) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
resolve(result);
};
const timeoutHandle = setTimeout(() => done(''), 2000);
client.on('connect', () => {
client.write(command + '\n');
});
client.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('>')) {
done(data);
}
});
client.on('error', () => done(''));
});
}
async function getVlcProperty(port: number, command: string): Promise<string> {
return parseVlcRcNumericResponse(
await getVlcCommandResponse(port, command)
);
}
async function getVlcPlaybackState(port: number): Promise<string | null> {
return parseVlcRcPlaybackState(await getVlcCommandResponse(port, 'status'));
}
async function getVlcPlaybackSnapshot(
port: number
): Promise<ExternalPlaybackSnapshot | null> {
const timeStr = await getVlcProperty(port, 'get_time');
const lenStr = await getVlcProperty(port, 'get_length');
const position = parseInt(timeStr, 10);
const duration = parseInt(lenStr, 10);
if (isNaN(position)) {
return null;
}
return {
positionSeconds: position,
durationSeconds: !isNaN(duration) ? duration : null,
};
}
function startVlcPositionPolling(
port: number,
contentInfo: PlayerContentInfo,
@@ -270,23 +120,16 @@ function getFreePort(): Promise<number> {
});
}
function killStoredVlcProcess(reason: string): void {
if (!vlcProcess || vlcProcess.killed) {
return;
}
traceExternalPlayer(reason);
vlcProcess.kill();
vlcProcess = null;
vlcRcPort = null;
stopVlcPositionPolling();
}
export function setVlcReuseInstance(reuseInstance: boolean): void {
traceExternalPlayer('set vlc reuse instance', { reuseInstance });
store.set(VLC_REUSE_INSTANCE, reuseInstance);
if (!reuseInstance) {
killStoredVlcProcess('clean up vlc process after disabling reuse');
reusableVlcProcess.stopStored(
'clean up vlc process after disabling reuse',
stopVlcPositionPolling,
true
);
}
}
@@ -296,7 +139,10 @@ export function setVlcReuseInstance(reuseInstance: boolean): void {
* playing after quit.
*/
export function shutdownVlcSession(): void {
killStoredVlcProcess('kill reused vlc process on app shutdown');
reusableVlcProcess.stopStored(
'kill reused vlc process on app shutdown',
stopVlcPositionPolling
);
}
export async function openVlcPlayer({
@@ -310,6 +156,9 @@ export async function openVlcPlayer({
startTime,
headers,
}: OpenVlcPlayerRequest) {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
const previousProcessSessionId = reusableVlcProcess.currentSessionId();
const session = externalPlayerSessions.beginSession({
player: 'vlc',
title,
@@ -317,6 +166,13 @@ export async function openVlcPlayer({
streamUrl: url,
contentInfo,
});
const reuseState: VlcReuseAttemptState = {
teardownUnconfirmed: false,
contentMutated: false,
closeRequested: false,
requestedClose: null,
};
let freshTeardownUnconfirmed = false;
try {
const isFlatpak = isRunningInFlatpak();
@@ -358,79 +214,23 @@ export async function openVlcPlayer({
parseExternalPlayerArguments(customVlcArguments).length,
});
if (reuseInstance && vlcProcess && !vlcProcess.killed && vlcRcPort) {
traceExternalPlayer('reuse existing vlc instance', {
rcPort: vlcRcPort,
if (reuseInstance) {
const reused = await reusableVlcProcess.tryReuse({
session,
previousProcessSessionId,
url,
title,
effectiveUserAgent,
effectiveReferer,
effectiveOrigin,
mergedHeaders,
contentInfo,
startTime,
state: reuseState,
startPositionPolling: startVlcPositionPolling,
stopPositionPolling: stopVlcPositionPolling,
});
try {
const enqueueCommands = buildVlcEnqueueCommands({
url,
title,
userAgent: effectiveUserAgent,
referer: effectiveReferer,
origin: effectiveOrigin,
headers: mergedHeaders,
startTime,
});
await sendVlcRcCommands(vlcRcPort, enqueueCommands);
traceExternalPlayer('loaded new url in existing vlc instance');
const reusedRcPort = vlcRcPort;
let lastReusedSnapshot: ExternalPlaybackSnapshot | null = null;
externalPlayerSessions.attachCloser(session.id, async () => {
try {
await sendVlcRcCommand(reusedRcPort, 'stop');
} catch {
if (vlcProcess && !vlcProcess.killed) {
vlcProcess.kill();
}
}
});
if (contentInfo) {
startVlcPositionPolling(
reusedRcPort,
contentInfo,
session.id,
(snapshot) => {
lastReusedSnapshot = snapshot;
},
() => {
if (
lastReusedSnapshot &&
externalPlayerSessions.getSession(session.id)
?.status !== 'closed'
) {
sendPlaybackPositionUpdate(
session.id,
contentInfo,
lastReusedSnapshot
);
}
externalPlayerSessions.markClosed(session.id);
}
);
} else {
stopVlcPositionPolling();
}
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (err) {
console.error(
'Failed to reuse existing VLC, spawning fresh:',
err
);
if (vlcProcess && !vlcProcess.killed) {
try {
vlcProcess.kill();
} catch {
// Ignore cleanup failures.
}
}
vlcProcess = null;
vlcRcPort = null;
stopVlcPositionPolling();
}
if (reused) return reused;
}
let rcPort = 0;
@@ -488,6 +288,8 @@ export async function openVlcPlayer({
await new Promise<void>((resolve, reject) => {
let settled = false;
let closeRequested = false;
let requestedClose: Promise<void> | null = null;
const resolveSpawn = () => {
if (settled) {
@@ -508,6 +310,50 @@ export async function openVlcPlayer({
};
const spawnVlc = (playerArgs: string[], isRetry = false) => {
if (reuseState.closeRequested) {
closeRequested = true;
const pendingReuseClose =
reuseState.requestedClose ?? Promise.resolve();
void pendingReuseClose.then(() => {
externalPlayerSessions.markClosed(session.id);
resolveSpawn();
}, rejectSpawn);
return;
}
if (
externalPlayerSessions.getSession(session.id)?.status ===
'closed'
) {
closeRequested = true;
resolveSpawn();
return;
}
try {
// Port allocation and reuse fallback both yield. Another
// exact child can enter teardown during either wait, so
// the process-wide invariant must be checked at the
// actual spawn boundary as well as at request entry.
externalPlayerProcessTeardownGate.assertLaunchAllowed();
} catch (error) {
const launchError =
error instanceof Error
? error
: new Error(String(error));
if (settled) {
const current = externalPlayerSessions.getSession(
session.id
);
if (current?.status !== 'closed') {
externalPlayerSessions.markError(
session.id,
launchError.message
);
}
} else {
rejectSpawn(launchError);
}
return;
}
const spawnSpec = buildExternalPlayerSpawnSpec(
vlcLaunchContext,
buildPlayerArgsWithCustomArguments(
@@ -515,18 +361,24 @@ export async function openVlcPlayer({
playerArgs
)
);
const trackProcess = reuseInstance && !isRetry;
// Reuse ownership exists only when an RC port was allocated.
// Without it this child is a normal one-shot process whose
// exact session closer must still terminate it.
const trackProcess = reuseInstance && !isRetry && rcPort > 0;
const proc = spawn(spawnSpec.command, spawnSpec.args, {
shell: false,
detached: !trackProcess,
stdio: trackProcess ? ['ignore', 'pipe', 'pipe'] : 'ignore',
});
proc.once('spawn', resolveSpawn);
proc.once('spawn', () => {
if (!closeRequested) {
resolveSpawn();
}
});
if (trackProcess && rcPort > 0) {
vlcProcess = proc;
vlcRcPort = rcPort;
reusableVlcProcess.track(proc, rcPort, session.id);
traceExternalPlayer('tracking vlc process for reuse', {
rcPort,
});
@@ -571,11 +423,45 @@ export async function openVlcPlayer({
);
};
externalPlayerSessions.attachCloser(session.id, async () => {
await flushVlcPlaybackPosition();
if (!proc.killed) {
proc.kill();
externalPlayerSessions.attachCloser(session.id, () => {
closeRequested = true;
if (
trackProcess &&
!reusableVlcProcess.owns(proc, session.id)
) {
return;
}
if (!requestedClose) {
// Position flush uses two bounded RC requests. Guard
// the exact child before either request yields so no
// replacement can reuse or overlap it while Stop is
// still preparing the teardown.
externalPlayerProcessTeardownGate.beginTeardown(proc);
const closeAttempt = (async () => {
await flushVlcPlaybackPosition();
await externalPlayerProcessTeardownGate.terminate(
proc
);
})();
requestedClose = closeAttempt;
void closeAttempt.catch((error) => {
if (requestedClose === closeAttempt) {
requestedClose = null;
}
const teardownError =
error instanceof Error
? error
: new Error(String(error));
freshTeardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
teardownError.message,
{ canClose: true }
);
rejectSpawn(teardownError);
});
}
return requestedClose;
});
if (!isRetry && rcPort > 0 && contentInfo) {
@@ -612,9 +498,21 @@ export async function openVlcPlayer({
proc.on('error', (err) => {
console.error('Failed to start VLC player:', err);
if (vlcProcess === proc) {
vlcProcess = null;
vlcRcPort = null;
const processSessionId = reusableVlcProcess.sessionIdFor(
proc,
session.id
);
reusableVlcProcess.clear(proc);
if (closeRequested) {
void requestedClose?.then(() => {
// A spawn failure reports `close` without an
// `exit` event. Close the exact session before
// settling OPEN_VLC_PLAYER so the renderer
// cannot receive a stale `opened` result.
externalPlayerSessions.markClosed(session.id);
resolveSpawn();
}, rejectSpawn);
return;
}
if (!isRetry && rcPort > 0) {
traceExternalPlayer(
@@ -629,7 +527,7 @@ export async function openVlcPlayer({
spawnVlc(retryArgs, true);
} else {
externalPlayerSessions.markError(
session.id,
processSessionId,
`Failed to start VLC player: ${err.message}`
);
rejectSpawn(
@@ -640,13 +538,15 @@ export async function openVlcPlayer({
proc.on('exit', (code) => {
traceExternalPlayer('vlc exited', { code });
if (vlcProcess === proc) {
vlcProcess = null;
vlcRcPort = null;
}
const processSessionId = reusableVlcProcess.sessionIdFor(
proc,
session.id
);
reusableVlcProcess.clear(proc);
stopVlcPositionPolling();
if (
!closeRequested &&
lastVlcSnapshot &&
contentInfo &&
externalPlayerSessions.getSession(session.id)
@@ -659,7 +559,12 @@ export async function openVlcPlayer({
);
}
if (code === 1 && !isRetry && rcPort > 0) {
if (
code === 1 &&
!closeRequested &&
!isRetry &&
rcPort > 0
) {
traceExternalPlayer(
'retry vlc without rc interface after exit'
);
@@ -683,13 +588,15 @@ export async function openVlcPlayer({
`VLC player closed unexpectedly (exit code: ${code})`
);
externalPlayerSessions.markError(
session.id,
processSessionId,
`VLC player closed unexpectedly (exit code: ${code})`
);
resolveSpawn();
return;
}
externalPlayerSessions.markClosed(session.id);
externalPlayerSessions.markClosed(processSessionId);
resolveSpawn();
});
if (!trackProcess) {
@@ -703,9 +610,24 @@ export async function openVlcPlayer({
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (error) {
console.error('Error opening VLC player:', error);
const restoredSession =
reuseState.teardownUnconfirmed &&
!reuseState.contentMutated &&
displacedSessionId
? externalPlayerSessions.restoreActiveSession(
displacedSessionId,
session.id
)
: null;
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error)
error instanceof Error ? error.message : String(error),
{
canClose:
freshTeardownUnconfirmed ||
(reuseState.teardownUnconfirmed &&
(reuseState.contentMutated || !restoredSession)),
}
);
throw error;
}
@@ -2,9 +2,7 @@ import { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { ExternalPlaybackService } from './external-playback.service';
describe('ExternalPlaybackService', () => {
let listener:
| ((session: ExternalPlayerSession) => void)
| undefined;
let listener: ((session: ExternalPlayerSession) => void) | undefined;
let closeExternalPlayerSession: jest.Mock;
let service: ExternalPlaybackService;
@@ -94,7 +92,9 @@ describe('ExternalPlaybackService', () => {
contentType: 'vod',
})
).toBeNull();
expect(service.visibleSession()).toBeNull();
expect(service.visibleSession()).toEqual(
expect.objectContaining({ status: 'error' })
);
});
it('delegates close requests for closable sessions', async () => {
@@ -133,18 +133,48 @@ describe('ExternalPlaybackService', () => {
expect(closeExternalPlayerSession).toHaveBeenCalledWith('session-2');
});
it('hides terminal sessions from the dock', () => {
it('hides closed sessions but keeps errors visible until dismissed', () => {
listener?.(createSession({ status: 'closed', canClose: false }));
expect(service.visibleSession()).toBeNull();
listener?.(
createSession({
id: 'session-3',
status: 'error',
error: 'Launch failed',
canClose: false,
})
);
const failed = createSession({
id: 'session-3',
status: 'error',
error: 'Launch failed',
canClose: false,
});
listener?.(createSession({ id: failed.id }));
listener?.(failed);
expect(service.visibleSession()).toEqual(failed);
service.dismissActiveSession();
expect(service.visibleSession()).toBeNull();
});
it('adopts an exactly correlated restored session after replacement teardown fails', () => {
const previous = createSession({
id: 'session-previous',
status: 'opened',
title: 'Previous stream',
});
const replacement = createSession({
id: 'session-replacement',
title: 'Replacement stream',
});
listener?.(previous);
listener?.(replacement);
listener?.({
...previous,
restoredFromSessionId: replacement.id,
updatedAt: '2026-03-07T10:00:20.000Z',
});
expect(service.activeSession()).toMatchObject({
id: previous.id,
status: 'opened',
});
expect(service.visibleSession()?.id).toBe(previous.id);
});
});
@@ -1,5 +1,8 @@
import { computed, Injectable, signal } from '@angular/core';
import { ExternalPlayerSession, PlayerContentInfo } from '@iptvnator/shared/interfaces';
import {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
@Injectable({
providedIn: 'root',
@@ -10,11 +13,7 @@ export class ExternalPlaybackService {
readonly visibleSession = computed(() => {
const session = this.activeSession();
if (
!session ||
session.status === 'closed' ||
session.status === 'error'
) {
if (!session || session.status === 'closed') {
return null;
}
@@ -60,9 +59,8 @@ export class ExternalPlaybackService {
this.dismissedSessionId.set(session.id);
try {
const updatedSession = await window.electron.closeExternalPlayerSession(
session.id
);
const updatedSession =
await window.electron.closeExternalPlayerSession(session.id);
if (updatedSession) {
this.handleSessionUpdate(updatedSession);
return;
@@ -105,12 +103,19 @@ export class ExternalPlaybackService {
private handleSessionUpdate(session: ExternalPlayerSession): void {
const current = this.activeSession();
const restoresCurrentReplacement =
current?.id === session.restoredFromSessionId;
if (!current || current.id === session.id || session.status === 'launching') {
if (
!current ||
current.id === session.id ||
session.status === 'launching' ||
restoresCurrentReplacement
) {
this.activeSession.set(session);
}
if (session.status === 'launching') {
if (session.status === 'launching' || restoresCurrentReplacement) {
this.dismissedSessionId.set(null);
}
}
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "هذا الهدف متوافق مع بيانات التشفير المتاحة",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "لم يتم العثور على قنوات",
"NO_CHANNELS_FOUND": "لم يتم العثور على قنوات",
"NO_GROUPS_FOUND": "لم يتم العثور على مجموعات",
"CATCHUP_AVAILABLE": "Catchup متاح",
"CATCHUP_AVAILABLE_DAYS": "Catchup متاح — {{days}} يوم",
"TRY_DIFFERENT_SEARCH": "جرّب مصطلح بحث مختلف",
"DETAILS_DIALOG": {
"TITLE": "تفاصيل القناة",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "كل اللغات",
"SAME_AS_ABOVE": "كما في الأعلى",
"SHOWING_OF_TOTAL": "{{visible}} من {{total}}"
},
"GRID": {
"LOADING_MORE": "جارٍ تحميل المزيد…",
"LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر",
"RETRY": "إعادة المحاولة"
}
},
"INFORMATION": "معلومات",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "المفضلة",
"RAIL_DOWNLOADS": "التنزيلات",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "جميع القنوات",
"RAIL_GROUPS": "المجموعات",
"RAIL_CONTEXT_ACTIONS": "إجراءات القسم",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "هاد الهدف متوافق مع بيانات التشفير المتوفرة",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "ما تلقاتش حتى قناة",
"NO_CHANNELS_FOUND": "ما تلقاتش حتى قناة",
"NO_GROUPS_FOUND": "ما تلقاتش حتى مجموعة",
"CATCHUP_AVAILABLE": "Catchup متوفر",
"CATCHUP_AVAILABLE_DAYS": "Catchup متوفر — {{days}} يوم",
"TRY_DIFFERENT_SEARCH": "جرب كلمة بحث أخرى",
"DETAILS_DIALOG": {
"TITLE": "تفاصيل القناة",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "كل اللغات",
"SAME_AS_ABOVE": "بحال اللي الفوق",
"SHOWING_OF_TOTAL": "{{visible}} من {{total}}"
},
"GRID": {
"LOADING_MORE": "جارٍ تحميل المزيد…",
"LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر",
"RETRY": "عاود المحاولة"
}
},
"INFORMATION": "معلومات",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "المفضلة",
"RAIL_DOWNLOADS": "التحميلات",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "جميع القنوات",
"RAIL_GROUPS": "المجموعات",
"RAIL_CONTEXT_ACTIONS": "إجراءات القسم",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Гэтая мэта сумяшчальная з даступнымі данымі шыфравання",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Каналаў не знойдзена",
"NO_CHANNELS_FOUND": "Каналаў не знойдзена",
"NO_GROUPS_FOUND": "Груп не знойдзена",
"CATCHUP_AVAILABLE": "Catchup даступны",
"CATCHUP_AVAILABLE_DAYS": "Catchup даступны — {{days}} дзён",
"TRY_DIFFERENT_SEARCH": "Паспрабуйце іншы пошукавы запыт",
"DETAILS_DIALOG": {
"TITLE": "Інфармацыя аб канале",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Усе мовы",
"SAME_AS_ABOVE": "як радком вышэй",
"SHOWING_OF_TOTAL": "{{visible}} з {{total}}"
},
"GRID": {
"LOADING_MORE": "Загружаем яшчэ…",
"LOAD_MORE_FAILED": "Не атрымалася загрузіць больш элементаў",
"RETRY": "Паўтарыць"
}
},
"INFORMATION": "Інфармацыя",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Выбранае",
"RAIL_DOWNLOADS": "Спампаванні",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Усе каналы",
"RAIL_GROUPS": "Групы",
"RAIL_CONTEXT_ACTIONS": "Дзеянні раздзела",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Dieses Ziel ist mit den verfügbaren Verschlüsselungsdaten kompatibel",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "MPV wird geöffnet…",
"ACTION_OPENING_VLC": "VLC wird geöffnet…",
"ACTION_REOPEN_MPV": "MPV erneut öffnen",
"ACTION_REOPEN_VLC": "VLC erneut öffnen",
"ACTION_RETRY_MPV": "MPV erneut versuchen",
"ACTION_RETRY_VLC": "VLC erneut versuchen",
"EXTERNAL_OPENING": "Player wird geöffnet…",
"EXTERNAL_STARTED": "Player gestartet",
"EXTERNAL_PLAYING": "Wiedergabe läuft",
"EXTERNAL_FAILED": "Fehler im externen Player",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Keine Sender gefunden",
"NO_CHANNELS_FOUND": "Keine Sender gefunden",
"NO_GROUPS_FOUND": "Keine Gruppen gefunden",
"CATCHUP_AVAILABLE": "Catchup verfügbar",
"CATCHUP_AVAILABLE_DAYS": "Catchup verfügbar — {{days}} Tage",
"TRY_DIFFERENT_SEARCH": "Versuchen Sie einen anderen Suchbegriff",
"DETAILS_DIALOG": {
"TITLE": "Senderdetails",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Alle Sprachen",
"SAME_AS_ABOVE": "wie oben",
"SHOWING_OF_TOTAL": "{{visible}} von {{total}}"
},
"GRID": {
"LOADING_MORE": "Lade mehr…",
"LOAD_MORE_FAILED": "Weitere Einträge konnten nicht geladen werden",
"RETRY": "Erneut versuchen"
}
},
"INFORMATION": "Information",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Favoriten",
"RAIL_DOWNLOADS": "Downloads",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Ausblenden",
"EXTERNAL_PLAYBACK_OPENING": "Player wird geöffnet…",
"EXTERNAL_PLAYBACK_STARTED": "Player gestartet",
"EXTERNAL_PLAYBACK_PLAYING": "Wiedergabe läuft",
"EXTERNAL_PLAYBACK_FAILED": "Fehler im externen Player",
"EXTERNAL_PLAYBACK_CLOSED": "Geschlossen",
"RAIL_ALL_CHANNELS": "Alle Sender",
"RAIL_GROUPS": "Gruppen",
"RAIL_CONTEXT_ACTIONS": "Bereichsaktionen",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Αυτός ο στόχος είναι συμβατός με τα διαθέσιμα δεδομένα κρυπτογράφησης",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Δεν βρέθηκαν κανάλια",
"NO_CHANNELS_FOUND": "Δεν βρέθηκαν κανάλια",
"NO_GROUPS_FOUND": "Δεν βρέθηκαν ομάδες",
"CATCHUP_AVAILABLE": "Διαθέσιμο Catchup",
"CATCHUP_AVAILABLE_DAYS": "Διαθέσιμο Catchup — {{days}} ημέρες",
"TRY_DIFFERENT_SEARCH": "Δοκιμάστε διαφορετικό όρο αναζήτησης",
"DETAILS_DIALOG": {
"TITLE": "Λεπτομέρειες καναλιού",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Όλες οι γλώσσες",
"SAME_AS_ABOVE": "ίδιο με το παραπάνω",
"SHOWING_OF_TOTAL": "{{visible}} από {{total}}"
},
"GRID": {
"LOADING_MORE": "Φόρτωση περισσότερων…",
"LOAD_MORE_FAILED": "Δεν ήταν δυνατή η φόρτωση περισσότερων στοιχείων",
"RETRY": "Επανάληψη"
}
},
"INFORMATION": "Πληροφορίες",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Αγαπημένα",
"RAIL_DOWNLOADS": "Λήψεις",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Όλα τα κανάλια",
"RAIL_GROUPS": "Ομάδες",
"RAIL_CONTEXT_ACTIONS": "Ενέργειες ενότητας",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "This target is compatible with the available encryption data",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy stream URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "No channels found",
"NO_CHANNELS_FOUND": "No channels found",
"NO_GROUPS_FOUND": "No groups found",
"CATCHUP_AVAILABLE": "Catchup available",
"CATCHUP_AVAILABLE_DAYS": "Catchup available — {{days}} days",
"TRY_DIFFERENT_SEARCH": "Try a different search term",
"DETAILS_DIALOG": {
"TITLE": "Channel details",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "All languages",
"SAME_AS_ABOVE": "same as above",
"SHOWING_OF_TOTAL": "{{visible}} of {{total}}"
},
"GRID": {
"LOADING_MORE": "Loading more…",
"LOAD_MORE_FAILED": "Couldn't load more items",
"RETRY": "Retry"
}
},
"INFORMATION": "Information",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Favorites",
"RAIL_DOWNLOADS": "Downloads",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "All channels",
"RAIL_GROUPS": "Groups",
"RAIL_CONTEXT_ACTIONS": "Section actions",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Este destino es compatible con los datos de cifrado disponibles",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "No se encontraron canales",
"NO_CHANNELS_FOUND": "No se encontraron canales",
"NO_GROUPS_FOUND": "No se encontraron grupos",
"CATCHUP_AVAILABLE": "Catchup disponible",
"CATCHUP_AVAILABLE_DAYS": "Catchup disponible — {{days}} días",
"TRY_DIFFERENT_SEARCH": "Prueba con otro término de búsqueda",
"DETAILS_DIALOG": {
"TITLE": "Detalles del canal",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Todos los idiomas",
"SAME_AS_ABOVE": "igual que la anterior",
"SHOWING_OF_TOTAL": "{{visible}} de {{total}}"
},
"GRID": {
"LOADING_MORE": "Cargando más…",
"LOAD_MORE_FAILED": "No se pudieron cargar más elementos",
"RETRY": "Reintentar"
}
},
"INFORMATION": "Información",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Favoritos",
"RAIL_DOWNLOADS": "Descargas",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Todos los canales",
"RAIL_GROUPS": "Grupos",
"RAIL_CONTEXT_ACTIONS": "Acciones de la sección",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Cette cible est compatible avec les données de chiffrement disponibles",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Aucune chaîne trouvée",
"NO_CHANNELS_FOUND": "Aucune chaîne trouvée",
"NO_GROUPS_FOUND": "Aucun groupe trouvé",
"CATCHUP_AVAILABLE": "Catchup disponible",
"CATCHUP_AVAILABLE_DAYS": "Catchup disponible — {{days}} jours",
"TRY_DIFFERENT_SEARCH": "Essayez un autre terme de recherche",
"DETAILS_DIALOG": {
"TITLE": "Détails de la chaîne",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Toutes les langues",
"SAME_AS_ABOVE": "identique à la ligne au-dessus",
"SHOWING_OF_TOTAL": "{{visible}} sur {{total}}"
},
"GRID": {
"LOADING_MORE": "Chargement de la suite…",
"LOAD_MORE_FAILED": "Impossible de charger plus d'éléments",
"RETRY": "Réessayer"
}
},
"INFORMATION": "Information",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Favoris",
"RAIL_DOWNLOADS": "Téléchargements",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Toutes les chaînes",
"RAIL_GROUPS": "Groupes",
"RAIL_CONTEXT_ACTIONS": "Actions de section",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Ez a cél kompatibilis az elérhető titkosítási adatokkal",
"ACTION_OPEN_MPV": "Megnyitás MPV-ben",
"ACTION_OPEN_VLC": "Megnyitás VLC-ben",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "beállított lejátszó",
"ACTION_OPEN_VLC_HINT": "beállított lejátszó",
"ACTION_COPY_URL": "Adatfolyam URL-címének másolása",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Nem található csatorna",
"NO_CHANNELS_FOUND": "Nem található csatorna",
"NO_GROUPS_FOUND": "Nem található csoport",
"CATCHUP_AVAILABLE": "Visszanézés elérhető",
"CATCHUP_AVAILABLE_DAYS": "Visszanézés elérhető — {{days}} nap",
"TRY_DIFFERENT_SEARCH": "Próbálkozzon másik keresési kifejezéssel",
"DETAILS_DIALOG": {
"TITLE": "Csatorna részletei",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Minden nyelv",
"SAME_AS_ABOVE": "ugyanaz, mint fent",
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
},
"GRID": {
"LOADING_MORE": "További elemek betöltése…",
"LOAD_MORE_FAILED": "Nem sikerült több elemet betölteni",
"RETRY": "Újra"
}
},
"INFORMATION": "Információk",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Kedvencek",
"RAIL_DOWNLOADS": "Letöltések",
"EXTERNAL_PLAYBACK_CLOSE": "Lejátszó bezárása",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Összes csatorna",
"RAIL_GROUPS": "Csoportok",
"RAIL_CONTEXT_ACTIONS": "A szakasz műveletei",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Questa destinazione è compatibile con i dati di crittografia disponibili",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Nessun canale trovato",
"NO_CHANNELS_FOUND": "Nessun canale trovato",
"NO_GROUPS_FOUND": "Nessun gruppo trovato",
"CATCHUP_AVAILABLE": "Catchup disponibile",
"CATCHUP_AVAILABLE_DAYS": "Catchup disponibile — {{days}} giorni",
"TRY_DIFFERENT_SEARCH": "Prova con un altro termine di ricerca",
"DETAILS_DIALOG": {
"TITLE": "Dettagli canale",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Tutte le lingue",
"SAME_AS_ABOVE": "uguale alla riga sopra",
"SHOWING_OF_TOTAL": "{{visible}} di {{total}}"
},
"GRID": {
"LOADING_MORE": "Caricamento di altri elementi…",
"LOAD_MORE_FAILED": "Impossibile caricare altri elementi",
"RETRY": "Riprova"
}
},
"INFORMATION": "Informazioni",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Preferiti",
"RAIL_DOWNLOADS": "Download",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Tutti i canali",
"RAIL_GROUPS": "Gruppi",
"RAIL_CONTEXT_ACTIONS": "Azioni di sezione",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "この対象は利用可能な暗号化データに対応しています",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "チャンネルが見つかりません",
"NO_CHANNELS_FOUND": "チャンネルが見つかりません",
"NO_GROUPS_FOUND": "グループが見つかりません",
"CATCHUP_AVAILABLE": "Catchup利用可能",
"CATCHUP_AVAILABLE_DAYS": "Catchup利用可能 — {{days}}日",
"TRY_DIFFERENT_SEARCH": "別の検索キーワードをお試しください",
"DETAILS_DIALOG": {
"TITLE": "チャンネルの詳細",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "すべての言語",
"SAME_AS_ABOVE": "上と同じ",
"SHOWING_OF_TOTAL": "{{total}}件中{{visible}}件"
},
"GRID": {
"LOADING_MORE": "さらに読み込み中…",
"LOAD_MORE_FAILED": "追加の項目を読み込めませんでした",
"RETRY": "再試行"
}
},
"INFORMATION": "情報",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "お気に入り",
"RAIL_DOWNLOADS": "ダウンロード",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "すべてのチャンネル",
"RAIL_GROUPS": "グループ",
"RAIL_CONTEXT_ACTIONS": "セクションのアクション",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "이 대상은 사용 가능한 암호화 데이터와 호환됩니다",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "채널을 찾을 수 없습니다",
"NO_CHANNELS_FOUND": "채널을 찾을 수 없습니다",
"NO_GROUPS_FOUND": "그룹을 찾을 수 없습니다",
"CATCHUP_AVAILABLE": "Catchup 사용 가능",
"CATCHUP_AVAILABLE_DAYS": "Catchup 사용 가능 — {{days}}일",
"TRY_DIFFERENT_SEARCH": "다른 검색어를 시도해 보세요",
"DETAILS_DIALOG": {
"TITLE": "채널 세부정보",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "모든 언어",
"SAME_AS_ABOVE": "위와 동일",
"SHOWING_OF_TOTAL": "{{total}}개 중 {{visible}}개"
},
"GRID": {
"LOADING_MORE": "더 불러오는 중…",
"LOAD_MORE_FAILED": "추가 항목을 불러오지 못했습니다",
"RETRY": "다시 시도"
}
},
"INFORMATION": "정보",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "즐겨찾기",
"RAIL_DOWNLOADS": "다운로드",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "모든 채널",
"RAIL_GROUPS": "그룹",
"RAIL_CONTEXT_ACTIONS": "섹션 작업",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Dit doel is compatibel met de beschikbare versleutelingsgegevens",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Geen kanalen gevonden",
"NO_CHANNELS_FOUND": "Geen kanalen gevonden",
"NO_GROUPS_FOUND": "Geen groepen gevonden",
"CATCHUP_AVAILABLE": "Catchup beschikbaar",
"CATCHUP_AVAILABLE_DAYS": "Catchup beschikbaar — {{days}} dagen",
"TRY_DIFFERENT_SEARCH": "Probeer een andere zoekterm",
"DETAILS_DIALOG": {
"TITLE": "Kanaaldetails",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Alle talen",
"SAME_AS_ABOVE": "zelfde als hierboven",
"SHOWING_OF_TOTAL": "{{visible}} van {{total}}"
},
"GRID": {
"LOADING_MORE": "Meer laden…",
"LOAD_MORE_FAILED": "Kan geen extra items laden",
"RETRY": "Opnieuw proberen"
}
},
"INFORMATION": "Informatie",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Favorieten",
"RAIL_DOWNLOADS": "Downloads",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Alle kanalen",
"RAIL_GROUPS": "Groepen",
"RAIL_CONTEXT_ACTIONS": "Sectieacties",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Ten cel jest zgodny z dostępnymi danymi szyfrowania",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Nie znaleziono kanałów",
"NO_CHANNELS_FOUND": "Nie znaleziono kanałów",
"NO_GROUPS_FOUND": "Nie znaleziono grup",
"CATCHUP_AVAILABLE": "Catchup dostępny",
"CATCHUP_AVAILABLE_DAYS": "Catchup dostępny — {{days}} dni",
"TRY_DIFFERENT_SEARCH": "Spróbuj innego zapytania",
"DETAILS_DIALOG": {
"TITLE": "Szczegóły kanału",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Wszystkie języki",
"SAME_AS_ABOVE": "tak samo jak wyżej",
"SHOWING_OF_TOTAL": "{{visible}} z {{total}}"
},
"GRID": {
"LOADING_MORE": "Wczytywanie kolejnych…",
"LOAD_MORE_FAILED": "Nie udało się wczytać kolejnych pozycji",
"RETRY": "Ponów"
}
},
"INFORMATION": "Informacje",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Ulubione",
"RAIL_DOWNLOADS": "Pobrane",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Wszystkie kanały",
"RAIL_GROUPS": "Grupy",
"RAIL_CONTEXT_ACTIONS": "Akcje sekcji",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Este destino é compatível com os dados de criptografia disponíveis",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Nenhum canal encontrado",
"NO_CHANNELS_FOUND": "Nenhum canal encontrado",
"NO_GROUPS_FOUND": "Nenhum grupo encontrado",
"CATCHUP_AVAILABLE": "Catchup disponível",
"CATCHUP_AVAILABLE_DAYS": "Catchup disponível — {{days}} dias",
"TRY_DIFFERENT_SEARCH": "Tente um termo de pesquisa diferente",
"DETAILS_DIALOG": {
"TITLE": "Detalhes do canal",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Todos os idiomas",
"SAME_AS_ABOVE": "igual à linha acima",
"SHOWING_OF_TOTAL": "{{visible}} de {{total}}"
},
"GRID": {
"LOADING_MORE": "A carregar mais…",
"LOAD_MORE_FAILED": "Não foi possível carregar mais itens",
"RETRY": "Tentar novamente"
}
},
"INFORMATION": "Informação",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Favoritos",
"RAIL_DOWNLOADS": "Downloads",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Todos os canais",
"RAIL_GROUPS": "Grupos",
"RAIL_CONTEXT_ACTIONS": "Ações da seção",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Эта цель совместима с доступными данными шифрования",
"ACTION_OPEN_MPV": "Открыть в MPV",
"ACTION_OPEN_VLC": "Открыть в VLC",
"ACTION_OPENING_MPV": "MPV открывается…",
"ACTION_OPENING_VLC": "VLC открывается…",
"ACTION_REOPEN_MPV": "Открыть MPV снова",
"ACTION_REOPEN_VLC": "Открыть VLC снова",
"ACTION_RETRY_MPV": "Повторить запуск MPV",
"ACTION_RETRY_VLC": "Повторить запуск VLC",
"EXTERNAL_OPENING": "Плеер открывается…",
"EXTERNAL_STARTED": "Плеер запущен",
"EXTERNAL_PLAYING": "Воспроизведение",
"EXTERNAL_FAILED": "Ошибка внешнего плеера",
"ACTION_OPEN_MPV_HINT": "настроенный плеер",
"ACTION_OPEN_VLC_HINT": "настроенный плеер",
"ACTION_COPY_URL": "Скопировать URL потока",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Каналы не найдены",
"NO_CHANNELS_FOUND": "Каналы не найдены",
"NO_GROUPS_FOUND": "Группы не найдены",
"CATCHUP_AVAILABLE": "Catchup доступен",
"CATCHUP_AVAILABLE_DAYS": "Catchup доступен — {{days}} дн.",
"TRY_DIFFERENT_SEARCH": "Попробуйте другой поисковый запрос",
"DETAILS_DIALOG": {
"TITLE": "Сведения о канале",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Все языки",
"SAME_AS_ABOVE": "как строкой выше",
"SHOWING_OF_TOTAL": "{{visible}} из {{total}}"
},
"GRID": {
"LOADING_MORE": "Загружаем ещё…",
"LOAD_MORE_FAILED": "Не удалось загрузить больше элементов",
"RETRY": "Повторить"
}
},
"INFORMATION": "Инфо",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Избранное",
"RAIL_DOWNLOADS": "Загрузки",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Скрыть",
"EXTERNAL_PLAYBACK_OPENING": "Плеер открывается…",
"EXTERNAL_PLAYBACK_STARTED": "Плеер запущен",
"EXTERNAL_PLAYBACK_PLAYING": "Воспроизведение",
"EXTERNAL_PLAYBACK_FAILED": "Ошибка внешнего плеера",
"EXTERNAL_PLAYBACK_CLOSED": "Закрыт",
"RAIL_ALL_CHANNELS": "Все каналы",
"RAIL_GROUPS": "Группы",
"RAIL_CONTEXT_ACTIONS": "Действия раздела",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Bu hedef kullanılabilir şifreleme verileriyle uyumludur",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "Kanal bulunamadı",
"NO_CHANNELS_FOUND": "Kanal bulunamadı",
"NO_GROUPS_FOUND": "Grup bulunamadı",
"CATCHUP_AVAILABLE": "Catchup mevcut",
"CATCHUP_AVAILABLE_DAYS": "Catchup mevcut — {{days}} gün",
"TRY_DIFFERENT_SEARCH": "Farklı bir arama terimi deneyin",
"DETAILS_DIALOG": {
"TITLE": "Kanal ayrıntıları",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "Tüm diller",
"SAME_AS_ABOVE": "üsttekiyle aynı",
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
},
"GRID": {
"LOADING_MORE": "Daha fazla yükleniyor…",
"LOAD_MORE_FAILED": "Daha fazla öğe yüklenemedi",
"RETRY": "Yeniden dene"
}
},
"INFORMATION": "Bilgi",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "Favoriler",
"RAIL_DOWNLOADS": "İndirilenler",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Tüm kanallar",
"RAIL_GROUPS": "Gruplar",
"RAIL_CONTEXT_ACTIONS": "Bölüm işlemleri",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "此目标与可用的加密数据兼容",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "未找到频道",
"NO_CHANNELS_FOUND": "未找到频道",
"NO_GROUPS_FOUND": "未找到分组",
"CATCHUP_AVAILABLE": "Catchup 可用",
"CATCHUP_AVAILABLE_DAYS": "Catchup 可用 — {{days}} 天",
"TRY_DIFFERENT_SEARCH": "尝试其他搜索词",
"DETAILS_DIALOG": {
"TITLE": "频道详情",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "所有语言",
"SAME_AS_ABOVE": "与上行相同",
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
},
"GRID": {
"LOADING_MORE": "正在加载更多…",
"LOAD_MORE_FAILED": "无法加载更多内容",
"RETRY": "重试"
}
},
"INFORMATION": "信息",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "收藏",
"RAIL_DOWNLOADS": "下载",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "所有频道",
"RAIL_GROUPS": "分组",
"RAIL_CONTEXT_ACTIONS": "章节操作",
+23
View File
@@ -585,6 +585,16 @@
"REASON_COMPATIBLE_DRM_PATH": "此目標與可用的加密資料相容",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -637,6 +647,8 @@
"NO_SEARCH_RESULTS": "找不到頻道",
"NO_CHANNELS_FOUND": "找不到頻道",
"NO_GROUPS_FOUND": "找不到群組",
"CATCHUP_AVAILABLE": "Catchup 可用",
"CATCHUP_AVAILABLE_DAYS": "Catchup 可用 — {{days}} 天",
"TRY_DIFFERENT_SEARCH": "請嘗試其他搜尋詞",
"DETAILS_DIALOG": {
"TITLE": "頻道詳細資訊",
@@ -1084,6 +1096,11 @@
"ALL_LANGUAGES": "所有語言",
"SAME_AS_ABOVE": "與上行相同",
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
},
"GRID": {
"LOADING_MORE": "正在載入更多…",
"LOAD_MORE_FAILED": "無法載入更多內容",
"RETRY": "重試"
}
},
"INFORMATION": "資訊",
@@ -1293,6 +1310,12 @@
"RAIL_FAVORITES": "我的最愛",
"RAIL_DOWNLOADS": "下載",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "External player error",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "所有頻道",
"RAIL_GROUPS": "群組",
"RAIL_CONTEXT_ACTIONS": "區段操作",
+103 -13
View File
@@ -582,12 +582,13 @@ offer a same-family built-in alternative. Unknown source or engine-family facts
also suppress built-in recommendations.
The pure policy builds the following order, filters the current, unavailable,
incompatible, and already attempted targets, and then returns at most three
actions. It also projects attempted inline target IDs through the validated
incompatible, and already attempted inline targets, and then returns at most
three actions. It projects attempted inline target IDs through the validated
canonical source/target capability matrix and filters every engine family that
has already been attempted. HTML5 and ArtPlayer therefore cannot be offered as
separate hls.js recovery attempts. The first surviving action is primary and
later actions are secondary.
separate hls.js recovery attempts. External MPV/VLC attempts remain eligible;
the view reranks them by per-target attempt count instead of removing them. The
first surviving action is primary and later actions are secondary.
| Sanitized evidence | Candidate order |
| ----------------------------------------- | ------------------------------------------------------------ |
@@ -654,8 +655,93 @@ override and saved player setting. The new engine receives the latest finite
VOD position as a best-effort resume point; live playback starts at the live
edge. Retry reloads the active target without clearing attempts. Selecting MPV
or VLC records the external target before emitting the existing fallback
request. The system does not infer whether the external process ultimately
played the stream.
request. Both external actions remain mounted while their per-target state
moves through `launching`, `started`, `playing`, or `error`; an attempted idle
target becomes an explicit reopen action and a failed target becomes Try again.
Only an exact correlated Electron `playing` session update earns the Playing
label. A single external launch handshake owns the session: duplicate actions
are ignored, other external actions wait, and an existing live external session
must close before a different player can start. Play, Restart, and secondary
provider launch actions all observe the same local guard before Electron has
returned a session. Xtream VOD also records the diagnostic fallback's
route-scoped destination and pending generation before invoking MPV/VLC, so a
subsequent route cannot start a second detached player while the first launch
is being correlated. The source-owning host binds
its returned launch promise to the fieldless current intent, so only that exact
result supplies the initial session ID; a late result from a timed-out attempt
cannot take over a retry. Later global updates must match the exact ID. A
replacement does not launch until teardown of the exact spawned process is
confirmed, and the old target is settled synchronously before the new launch so
coalesced signal effects cannot preserve stale feedback. Teardown waits through
bounded graceful and forced-exit windows, and reusable MPV also bounds its IPC
quit command before entering those windows. If any stage cannot confirm exit,
close rejects and keeps the exact session live so a replacement cannot overlap
it. A process-wide teardown gate starts before any potentially slow teardown
preparation, including VLC position flush and a reused player's MPV IPC or VLC
RC quit command, and rejects both player launches until that exact child
reports exit; `ChildProcess.killed` is never treated as proof. If
bounded teardown fails while a fresh launch IPC is pending, the IPC rejects and
the exact session becomes a closable error instead of remaining in Opening.
If a pre-content reuse failure has no still-live displaced session to restore,
the replacement error keeps its attached closer so Stop can retry teardown of
the orphaned reusable child. A terminal error without a closer is never
restorable.
A rejected close promise is cached only while that attempt is pending, so the
dock's Stop action can retry teardown of the same exact child after an
unconfirmed bounded attempt. Reusable children are mapped to the current
content session; a stale older closer becomes a no-op after MPV `loadfile` or
the VLC enqueue handoff remaps the process. Closing an already terminal session
is also idempotent: it returns the closed snapshot without invoking the saved
closer, and a later process error cannot revive it as a visible failure. Reused
MPV commands use the socket captured for their exact child, so a delayed close
cannot send `quit` to a replacement process through a newer global socket.
If Stop is observed before a pending MPV content command or VLC enqueue command
is dispatched, that command is skipped. A source handoff also fails closed
while a live session has no closer (`canClose: false`); renderer Dismiss is not
accepted as process-teardown confirmation. That denial advances neither the
multi-source switch token nor the playback generation, so it cannot cancel the
sole launch already in flight.
VLC rechecks that gate immediately around every concrete spawn after
asynchronous port allocation or reuse fallback work. If a post-start VLC
fallback is blocked by the gate, the already-opened session transitions to
error instead of continuing to claim that the player started. If RC-port
allocation fails, reuse ownership is never claimed: the spawned VLC child
keeps its exact one-shot session closer so Stop still confirms its teardown.
During reusable-player handoff, a failure before the content command restores
the globally displaced renderer session—not the reusable process's prior
owner—through an exact `restoredFromSessionId` transition, but only while that
displaced replacement is still the active session.
After MPV `loadfile` or VLC `clear` has been dispatched, the attempted session
owns the possibly changed process and stays a closable error instead of
restoring stale content metadata. Such an error still participates in every
replacement close. Stop during an in-flight MPV or VLC reuse command, including
the teardown wait after a failed command and VLC's subsequent fallback
port-allocation wait, settles that exact close and cancels the fallback spawn.
If a partially applied reuse
command is instead recovered by a fresh spawn, the old child's exit is retired
under its previous session so it cannot close the replacement session. A
during-start Stop also settles VLC's launch IPC when a spawn error reports
`close` without `exit`. The dock keeps Stop as the only global teardown action
while that exact closer remains live; safe Dismiss is available only after an
error becomes terminal and has no closer. If diagnostic ownership changes during close, the
unlaunched intent is cancelled without a false launch error; an exact stale
launch whose close fails keeps its credential-free owner for the next close
attempt. Route Play/Resume cancels older source resolution immediately but
captures the initiating playlist/VOD route before awaiting teardown; navigation
cancels that start. Accepting a diagnostic fallback cancels the same older
source resolution before opening MPV/VLC, and a fallback resolving on the new
route closes its exact returned session. The route commits its source badge and playback
evidence only after start succeeds. If the local
handshake timeout fires after Electron has supplied an exact session ID, the ID
stays correlated so a later `opened`, `playing`, or `error` update can reconcile
the action with the global dock.
The global external-playback dock uses the same Electron session status. It
shows Opening player with progress during launch, Player started for `opened`,
and Playing only for `playing`. An error with a live closer remains visible with
Stop until teardown is confirmed; an unclosable terminal error remains visible
until dismissed. The dock deliberately has no retry because it does not own the
original headers or credentials required to reconstruct a safe launch request.
No recommendation mutates `Settings.player` or another persisted setting.
Recovery recommendations never auto-switch a player or source and do not
@@ -709,14 +795,18 @@ External MPV/VLC integration is split across focused main-process modules:
- `apps/electron-backend/src/app/events/external-player-runtime.ts` owns shared
session tracking, trace logging, renderer notifications, playback-position
forwarding, and user-facing start errors.
- `apps/electron-backend/src/app/events/mpv-session.service.ts` owns MPV process,
socket, reuse, cleanup, and progress polling lifecycle.
- `apps/electron-backend/src/app/events/vlc-session.service.ts` owns VLC process,
RC interface, reuse, cleanup, command parsing, and progress polling lifecycle.
- `apps/electron-backend/src/app/events/mpv-session.service.ts` owns fresh MPV
launches and progress polling; `mpv-reusable-process.ts` owns the tracked
child, captured socket, remapping, retryable close, and reuse handoff.
- `apps/electron-backend/src/app/events/vlc-session.service.ts` owns fresh VLC
launches and progress polling; `vlc-reusable-process.ts` owns the tracked
child, RC-port remapping, retryable close, and reuse handoff, while
`vlc-rc.ts` owns bounded RC commands, parsing, and playback snapshots.
Keep player-specific process state in the MPV/VLC session modules. Shared spawn,
request-header, session-registry, and notification helpers belong in the
`external-player-*` modules so IPC registration stays small and reviewable.
Keep player-specific reusable-process state in the focused MPV/VLC managers.
Shared spawn, request-header, session-registry, and notification helpers belong
in the `external-player-*` modules so IPC registration stays small and
reviewable.
## Flatpak External Players
@@ -420,6 +420,8 @@ Prefer removing a control over shrinking everything around it:
- Keyboard-only affordances — the `⌘K` badge, the shortcuts button.
- The `mat-paginator` page-size select, which is the widest part of the
control and the least useful one on a phone. The range and arrows stay.
(Only Stalker catalog routes still render a paginator — Xtream catalogs use
infinite scroll and have none.)
- Counts and subtitles that a neighbouring control already states.
Never drop the only way back to a hidden surface. A collapse toggle that is
+56 -7
View File
@@ -496,8 +496,9 @@ cut. The toast offers Undo, and adds a dub warning when
audio track as fact. Two guesses, or a guess against a fact, stay silent.
Web engines only (HTML5/hls.js, Video.js, ArtPlayer). Embedded MPV suppresses
shared diagnostics and owns its own error block; external MPV/VLC are
fire-and-forget with no error channel back.
shared diagnostics and owns its own error block. External MPV/VLC use managed
Electron sessions: recovery actions and the global dock report their exact
launch state without retaining playback headers or credentials.
## External players and an alternative source
@@ -507,7 +508,43 @@ that session carries the OTHER playlist's ids. `matchedExternalPlayback` would
disown it: the primary button never became Stop, stopping found no session, and
another click opened a second player. The page therefore claims a session that
matches either the route's own stream or the alternative multi-source says is
active (`VodDetailsPlaybackBindings.activeSource`).
active (`VodDetailsPlaybackBindings.activeSource`). It also claims the
credential-free destination identity before awaiting the Electron launch: the
primary action becomes pending immediately, and a launching or closable-error
session remains matched before the controller can truthfully mark it active.
Restart and the provider-source shortcut read the same local pending state, so
a second activation cannot enter the Electron launch before the first IPC
response exists; their DOM controls stay mounted and disabled to preserve focus.
The diagnostic-fallback handler makes that same route-scoped ownership claim
before invoking MPV/VLC, rather than relying on the later controller commit. It
also supersedes an older source resolution at that acceptance boundary, so the
late switch cannot close and replace the newer diagnostic fallback.
That retained destination is scoped to the initiating playlist/VOD route key,
so Angular route reuse for a different movie cannot turn its Play action into
Stop for the previous movie's still-running external session. That new route's
Play and Resume actions still pass through the shared close-before-replacement
path, and they recheck their captured route key after teardown, so starting it
cannot leave the prior detached player running or launch stale content after
navigation. A diagnostic fallback that resolves after route reuse closes its
exact returned session instead of adopting it on the new route.
Before an external alternative replaces another external session, teardown of
the exact old process must be confirmed. If close fails or times out, the
replacement is cancelled instead of allowing two external players to overlap.
If the old launch is still opening and has no exact closer yet, a source-row
replacement is denied before either the multi-source switch token or playback
generation advances; the only in-flight launch therefore remains owned and is
not closed as superseded.
Once that old session is closed, later duplicate Stop/Close delivery is a
terminal no-op and cannot invoke its saved closer against a newly started MPV
or VLC process.
The controller commits the destination row, previous-source pointer, and switch
notice only after the playback seam accepts that handoff and an external
Electron launch resolves, so a rejected close or launch leaves the old source
as the truthful selection. The host's exact switch-owner probe crosses that
seam too: after teardown but before applying playback, the route rechecks it so
a newer unresolvable selection cannot leave the older player launched but
disowned by the controller.
`ownsContent()` answers that question once, for both consumers: the session
matcher AND the playback-position bridge. They cannot be allowed to disagree —
@@ -609,10 +646,22 @@ honest state, and the same one the rest of this feature takes when it does not
know.
Switching sources through `startResolvedPlayback` closes the external session
it LAUNCHED first — tracked separately from the controller's active source,
which a switch has already moved to the destination by then. It REPLACES what is playing — with MPV or VLC and instance
reuse off, the backend would otherwise spawn a second detached player, leaving
both sources running and Stop owning only the newer one.
it LAUNCHED first — tracked separately so refreshes and overlapping handoffs
cannot disown it within the same route session. The retained identity is ignored
after the playlist/VOD route key changes. Only after that exact teardown, the
host ownership checks on both sides of the launch await, and an `opened` or
`playing` launch result does the controller commit the destination. A launch
that loses ownership while IPC is pending is closed by its exact returned
session; a Stop that wins the race returns `closed` and is never committed. A
partial reusable-player handoff that leaves a closable error retains its
destination identity so the next source switch closes that exact process before
trying again. A stale launch whose exact close fails retains the same
credential-free identity for another close attempt. Direct route Play/Resume
supersede an older source resolution before waiting for teardown, but update the
active-source badge, playback evidence, and controller position only after the
new start succeeds. With MPV or VLC and instance reuse off, applying playback
earlier would spawn a second detached player, leaving both sources running and
Stop owning only the newer one.
## Short titles and Unicode
@@ -0,0 +1,492 @@
# External Playback Launch Feedback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Keep MPV/VLC recovery actions visible while reporting exact launch/session state in the diagnostic panel and global dock.
**Architecture:** Add a component-local, credential-free external recovery state machine beside `PlaybackRecoverySession`. The existing host output still owns source-specific launch payloads and binds its returned launch promise to the fieldless current intent; only that exact result supplies the initial session ID. `PORTAL_EXTERNAL_PLAYBACK` then supplies exact-ID status observation and close-before-switch. Attempts rerank but never remove external targets, while stale promises, sessions, timers, and diagnostic owners are rejected.
**Tech Stack:** Angular signals and control flow, Angular Material icons/spinner, Jest, Nx, Playwright Electron E2E, ngx-translate.
---
### Task 1: External recovery state machine
**Files:**
- Create: `libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts`
- Create: `libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts`
- [ ] **Step 1: Write the failing state-machine tests**
Cover the exact public contract:
```typescript
const state = new ExternalPlaybackRecovery();
state.syncSession('content-a');
const intent = state.begin('mpv');
expect(intent).not.toBeNull();
expect(state.pending()).toBe(true);
expect(state.target('mpv')).toMatchObject({
status: 'launching',
attempts: 1,
sessionId: null,
});
expect(state.begin('vlc')).toBeNull();
expect(state.observe(session({ id: 'old-session', player: 'mpv' }))).toBe(
false
);
expect(state.observe(session({ id: 'new-session', player: 'vlc' }))).toBe(
false
);
expect(state.observe(session({ id: 'new-session', player: 'mpv' }))).toBe(
false
);
expect(
state.confirm(intent!, session({ id: 'new-session', player: 'mpv' }))
).toBe(true);
expect(state.target('mpv').status).toBe('started');
```
Add separate tests for `playing`, `error`, `closed → idle`, stale exact-ID
updates, timeout-to-error, stale timeout after session reset, retry attempt
count, and `destroy()` timer cleanup.
- [ ] **Step 2: Run RED**
Run:
```bash
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts --skip-nx-cache
```
Expected: FAIL because `external-playback-recovery.ts` does not exist.
- [ ] **Step 3: Implement the minimal signal state machine**
Use these exported shapes:
```typescript
export type ExternalRecoveryStatus =
'idle' | 'launching' | 'started' | 'playing' | 'error';
export interface ExternalRecoveryTargetState {
readonly status: ExternalRecoveryStatus;
readonly attempts: number;
readonly sessionId: string | null;
}
export interface ExternalRecoveryIntent {
readonly token: symbol;
readonly target: ExternalPlayerName;
}
export class ExternalPlaybackRecovery {
readonly states: Signal<
Readonly<Record<ExternalPlayerName, ExternalRecoveryTargetState>>
>;
readonly pending: Signal<boolean>;
syncSession(key: string): boolean;
begin(target: ExternalPlayerName): ExternalRecoveryIntent | null;
owns(intent: ExternalRecoveryIntent): boolean;
confirm(
intent: ExternalRecoveryIntent,
session: ExternalPlayerSession
): boolean;
cancel(intent: ExternalRecoveryIntent): boolean;
close(target: ExternalPlayerName, sessionId: string): boolean;
observe(session: ExternalPlayerSession | null): boolean;
fail(intent: ExternalRecoveryIntent): boolean;
target(target: ExternalPlayerName): ExternalRecoveryTargetState;
destroy(): void;
}
```
The internal launch timer is 10 seconds, and all ownership tokens are fieldless
`Symbol()` values. Store only target, attempt count, status, and the exact
session ID returned through the owned host launch promise.
- [ ] **Step 4: Run GREEN and refactor**
Run the Task 1 command again. Expected: all state-machine tests PASS.
- [ ] **Step 5: Commit**
```bash
git add libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts
git commit -m "feat(playback): track external recovery launches"
```
### Task 2: Preserve and rerank external recommendations
**Files:**
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts`
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts`
- Test: `libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts`
- [ ] **Step 1: Write failing ranking tests**
Add a focused spec for a helper with this contract:
```typescript
const result = createWebPlayerRecommendations({
...baseOptions,
attemptedTargets: new Set(['videojs', 'mpv']),
externalStates: {
mpv: { status: 'error', attempts: 1, sessionId: 'mpv-1' },
vlc: { status: 'idle', attempts: 0, sessionId: null },
},
});
expect(
result.map((item) => (item.action === 'player' ? item.target : item.action))
).toEqual(['vlc', 'mpv', 'alternative-source']);
expect(result.map((item) => item.priority)).toEqual([
'primary',
'secondary',
'secondary',
]);
```
Also prove that an attempted inline family remains excluded, ties preserve MPV
before VLC, input arrays/sets/states are not mutated, and output stays capped at
three.
- [ ] **Step 2: Run RED**
```bash
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts --skip-nx-cache
```
Expected: FAIL because external state is not accepted and attempted MPV is
removed.
- [ ] **Step 3: Implement filtered policy attempts and stable reranking**
Pass only inline attempted targets into `recommendPlaybackRecovery()`. Stable
sort adjacent external recommendations by `attempts`, using their original
policy index as tie-breaker, then regenerate primary/secondary priority without
mutating inputs.
- [ ] **Step 4: Update old integration expectations**
Change old assertions that expected MPV/VLC to disappear. They must now assert
that both targets remain mounted and the less-attempted sibling is promoted.
- [ ] **Step 5: Run GREEN and commit**
```bash
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts --skip-nx-cache
git add libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts
git commit -m "fix(playback): keep external recovery actions available"
```
### Task 3: Wire launch ownership and close-before-switch
**Files:**
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.html`
- Modify: `libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts`
- Modify: `libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts`
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts`
- [ ] **Step 1: Add failing integration tests**
Provide a fake `PORTAL_EXTERNAL_PLAYBACK` with `activeSession: signal(null)`,
`visibleSession`, and `closeSession`. Prove:
```typescript
mpvButton.click();
mpvButton.click();
expect(fallbackRequests).toHaveLength(1);
expect(component.externalRecoveryPending()).toBe(true);
activeSession.set(
externalSession({ id: 'mpv-1', player: 'mpv', status: 'opened' })
);
fixture.detectChanges();
expect(component.externalRecoveryState().mpv.status).toBe('started');
expect(playerActionIds()).toContain('playback-fallback-mpv');
```
Add independent tests for close-before-VLC, refusing an unclosable active
session, stale session after `playbackSessionKey` change, no duplicate output,
and no URL/header fields in serialized coordinator state.
- [ ] **Step 2: Run RED**
Run the recovery integration spec. Expected: new assertions FAIL because the
component does not observe external sessions.
- [ ] **Step 3: Wire the coordinator**
Inject `PORTAL_EXTERNAL_PLAYBACK` optionally, observe its active signal in an
`effect`, reset external state from `syncRecoverySession()`, and destroy it from
the existing component cleanup. For external targets:
1. validate the recommendation and current diagnostic;
2. begin one owned external intent;
3. if a nonterminal active external session exists, require `canClose`, await
`closeSession`, and confirm it is no longer live;
4. recheck intent and diagnostic ownership;
5. record the external attempt and emit the existing exact
`PlaybackFallbackRequest` once.
- [ ] **Step 4: Run GREEN and commit**
```bash
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts --skip-nx-cache
git add libs/ui/playback/src/lib/web-player-view
git commit -m "feat(playback): synchronize external launch feedback"
```
### Task 4: Render accessible per-target feedback
**Files:**
- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts`
- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html`
- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss`
- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts`
- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts`
- Modify: `libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts`
- [ ] **Step 1: Write failing view/component tests**
Prove state-aware keys and labels:
```typescript
expect(getRecommendationLabelKey(mpvRecommendation, launchingState)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_OPENING_MPV'
);
expect(getRecommendationLabelKey(mpvRecommendation, errorState)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY_MPV'
);
```
The component test must assert that the same MPV `HTMLButtonElement` remains in
the DOM through state changes, keeps focus, contains a 16px spinner during
launch, exposes `aria-busy="true"` and `aria-disabled="true"`, and does not emit
when aria-disabled. Assert a visible polite status for started/playing/error.
- [ ] **Step 2: Run RED**
Run both panel specs. Expected: FAIL because the panel has no external-state
input or spinner.
- [ ] **Step 3: Implement the view**
Import the existing Angular Material progress spinner. Keep external buttons
mounted and use `aria-disabled` plus handler guards instead of native
`disabled` for external handshake feedback, preserving keyboard focus. Keep
the existing native `disabled` behaviour for inline switch/retry pending.
Add only transform/opacity/color transitions under 180ms and a
`prefers-reduced-motion` rule that stops spinner animation when appropriate.
- [ ] **Step 4: Run GREEN and commit**
```bash
pnpm nx test ui-playback --runTestsByPath libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts --skip-nx-cache
git add libs/ui/playback/src/lib/playback-diagnostic-panel
git commit -m "feat(playback): show external launch action states"
```
### Task 5: Keep dock errors visible and use exact statuses
**Files:**
- Modify: `apps/web/src/app/services/external-playback.service.ts`
- Modify: `apps/web/src/app/services/external-playback.service.spec.ts`
- Modify: `libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts`
- Modify: `libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html`
- Modify: `libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss`
- Modify: `libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts`
- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts`
- [ ] **Step 1: Write failing service and dock tests**
Change the error-session expectation from hidden to visible, then prove
`dismissActiveSession()` hides it. Add dock cases for:
```typescript
expect(statusText('launching')).toContain('Opening player');
expect(statusText('opened')).toContain('Player started');
expect(statusText('playing')).toContain('Playing');
expect(errorDock.getAttribute('aria-live')).toBe('polite');
expect(dismissButton.textContent).toContain('Dismiss');
```
- [ ] **Step 2: Run RED**
```bash
pnpm nx test web --runTestsByPath apps/web/src/app/services/external-playback.service.spec.ts --skip-nx-cache
pnpm nx test components --runTestsByPath libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts --skip-nx-cache
```
Expected: service error visibility and dock status assertions FAIL.
- [ ] **Step 3: Implement exact dock semantics**
Hide only `closed` in `visibleSession`. Translate launching/opened/playing and
generic error states. Keep detailed existing `session.error` for the visible
error copy. Show “Close player” only for a closable live session and “Dismiss”
for error. Keep the existing output so workspace shell routing does not change.
- [ ] **Step 4: Run GREEN and commit**
Run both commands plus the workspace-shell focused spec, then commit:
```bash
git add apps/web/src/app/services/external-playback.service.ts apps/web/src/app/services/external-playback.service.spec.ts libs/ui/components/src/lib/external-playback-dock libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts
git commit -m "fix(playback): keep external launch errors visible"
```
### Task 6: Translation, documentation, and release note
**Files:**
- Modify: `apps/web/src/assets/i18n/*.json`
- Modify: `docs/architecture/embedded-inline-playback.md`
- Modify: `AGENTS.md`
- Modify: `CLAUDE.md`
- Create: `.changes/playback-external-launch-feedback.md`
- [ ] **Step 1: Add user-facing translation keys**
Add the same complete key set to every locale, translated where practical and
English fallback otherwise:
```json
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "External player error"
```
Add workspace dock keys for opening, started, playing, failed, and dismiss.
- [ ] **Step 2: Update canonical docs**
Replace the v1 “external target disappears after attempt” contract with the
new per-target state/reranking and exact external session correlation. Mirror
the shared-player summary byte-for-meaning between `AGENTS.md` and `CLAUDE.md`.
- [ ] **Step 3: Add release note**
```markdown
---
type: fix
area: playback
---
MPV and VLC recovery buttons now stay available after an attempt and show launch progress, player-started, playback, and failure feedback. External-player errors remain in the bottom status bar until dismissed.
```
- [ ] **Step 4: Validate and commit**
```bash
pnpm run i18n:validate
pnpm run release:notes:validate
git diff --check
git add apps/web/src/assets/i18n docs/architecture/embedded-inline-playback.md AGENTS.md CLAUDE.md .changes/playback-external-launch-feedback.md
git commit -m "docs(playback): document external launch feedback"
```
### Task 7: Electron regression flow
**Files:**
- Modify: `apps/electron-backend-e2e/src/dash-clearkey.e2e.ts`
- [ ] **Step 1: Change the existing E2E expectation before production code is considered complete**
The fixture IPC handler must send repository-owned `launching`, `opened`, and
failure session updates through `EXTERNAL_PLAYER_SESSION_UPDATE`, and implement
the close handler without starting real players.
Assert:
- MPV remains visible during and after the held handshake;
- its spinner/status is visible while launching;
- VLC cannot launch during the handshake;
- after MPV starts, MPV remains available and VLC is primary;
- the dock reads “Player started”;
- a failed VLC session remains in the dock with a Dismiss action;
- dismiss hides the dock while both diagnostic action buttons remain.
- [ ] **Step 2: Run the exact Electron target**
```bash
pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts --skip-nx-cache
```
Expected: PASS with the repository-owned fixture and stubbed IPC only.
- [ ] **Step 3: Commit**
```bash
git add apps/electron-backend-e2e/src/dash-clearkey.e2e.ts
git commit -m "test(playback): cover external launch feedback"
```
### Task 8: Full validation, review, and PR
**Files:**
- Review all changed files from `origin/master...HEAD`.
- [ ] **Step 1: Run affected unit, lint, build, and policy validation**
```bash
pnpm nx test ui-playback --skip-nx-cache
pnpm nx test components --skip-nx-cache
pnpm nx test web --skip-nx-cache
pnpm nx affected -t lint --base=origin/master --head=HEAD --skip-nx-cache
pnpm nx build web --configuration=production --skip-nx-cache
pnpm nx build electron-backend --configuration=production --skip-nx-cache
pnpm run i18n:validate
pnpm run release:notes:validate
pnpm run coverage:policy:check
git diff --check origin/master...HEAD
```
- [ ] **Step 2: Run the targeted Electron E2E again from the final tree**
```bash
pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts --skip-nx-cache
```
- [ ] **Step 3: Perform local Codex P1/P2 review**
Run the local Codex CLI against `origin/master`, inspect every finding, add a
failing regression test before each valid fix, and rerun the affected and full
validation commands. Do not create the PR while an actionable P1/P2 remains.
- [ ] **Step 4: Push and create a ready PR**
Use title:
```text
fix(playback): clarify external player launch feedback
```
The PR body must summarize persistent recovery actions, exact session status,
accessibility/privacy ownership, dock dismissal, docs/release note, and every
validation command. Do not merge.
- [ ] **Step 5: Run the review loop**
Wait for all latest-head CI, Greptile 5/5, and Codex review. Fix actionable
feedback with regression coverage, push, retrigger both reviewers, and repeat
until the ready PR is merge-ready.
@@ -0,0 +1,172 @@
# External Playback Launch Feedback Design
## Goal
Make MPV and VLC recovery actions reversible and understandable. A target stays
available after an attempt, the diagnostic panel reports launch progress and
outcome, and the workspace dock remains the authoritative global view of the
external-player session.
## Current Problem
`WebPlayerViewComponent` records MPV or VLC as attempted before emitting the
fallback request. The recommendation policy then removes attempted targets, so
each button disappears even though IPTVnator only knows that the action was
requested. It does not yet know whether the external player started or played
the stream.
The external-player session already reports `launching`, `opened`, `playing`,
`error`, and `closed`, but the dock hides `error`, renders `opened` and
`playing` as the same “Opened” status, and exposes no dismiss action for an
external-player error.
## Considered Approaches
### 1. Keep actions and show only a local timed spinner
This is the smallest change, but the UI would guess when launching finished
and could disagree with the Electron session. It is rejected because the app
already has structured launch state.
### 2. Use only callbacks on every `PlaybackFallbackRequest` host
Every M3U, Xtream, Stalker, and shared host could make the launch promise the
only feedback source. This supplies an exact initial session ID, but by itself
still misses later `playing` and `error` session updates.
### 3. Correlate the owned launch result with the shared external session
This is the selected approach. The web-player component keeps source launch
ownership in its existing host output. Its credential-free tracker binds the
host's returned launch promise to the current fieldless intent token, and only
that exact result may supply the initial session ID. Later status changes come
from `PORTAL_EXTERNAL_PLAYBACK.activeSession` and must match that ID. This
prevents a delayed timed-out attempt from taking over a retry. No URL, headers,
DRM data, credentials, or raw diagnostic payload enters the ownership state.
## State Model
Each external target has component-local state:
- `idle`: no launch is in flight; a previous successful/closed attempt may
still be represented by an attempt count;
- `launching`: the request is being handed off or Electron is spawning the
player;
- `started`: Electron confirmed the process or existing player accepted the
stream;
- `playing`: MPV/VLC position polling confirmed playback;
- `error`: the launch/session failed.
The state also contains an attempt count and, only after correlation, the exact
external session ID. It never contains the stream URL, headers, credentials,
engine messages, or error details. A fieldless `Symbol` intent plus the current
content-session generation rejects stale completions and timers.
Changing `playbackSessionKey` resets both targets. Destroying the component
invalidates the intent and clears its bounded launch timer. A later session
update is accepted only if it belongs to the correlated session ID.
## Recommendation Behaviour
External attempts no longer remove MPV or VLC from the ranked action list.
Inline engine attempts retain the current family-exclusion rules.
Within an otherwise unchanged policy result:
- an untried external target ranks ahead of an attempted sibling;
- fewer attempts rank ahead of more attempts;
- policy order remains the tie-breaker;
- the first surviving action remains primary and the total remains capped at
three.
Labels describe the action, while adjacent status copy describes the outcome:
| State | Action label | Status |
| ----------------------- | ------------------ | --------------------- |
| `idle`, never attempted | Open in MPV/VLC | none |
| `launching` | Opening MPV/VLC… | Opening player… |
| `started` | Open MPV/VLC again | Player started |
| `playing` | Open MPV/VLC again | Playing |
| `error` | Try MPV/VLC again | External player error |
Buttons remain mounted with stable recommendation keys, preserving layout and
focus. During a launch handshake, recovery actions expose `aria-busy` and
`aria-disabled`; activation handlers enforce the same single-flight guard, so
keyboard or programmatic activation cannot duplicate the request. A small
spinner replaces the external-action icon. State changes are announced through
concise `aria-live="polite"` status text.
Before a recovery action opens another external player, it closes the currently
tracked live external session through `PORTAL_EXTERNAL_PLAYBACK`. If the
session is still live and cannot be closed, the new attempt fails locally
instead of starting a second process. The coordinator applies the exact
confirmed close synchronously before emitting the replacement, so coalesced
Angular effects cannot leave stale Playing feedback. If diagnostic ownership
changes while closing, the unlaunched intent is cancelled without reporting a
launch error. MPV and VLC are available again after the handshake.
If no matching session arrives within a bounded timeout, the target transitions
to `error`. The timeout is feedback for a missing handoff, not evidence about
stream playback.
## Dock Behaviour
`ExternalPlaybackService.visibleSession` keeps error sessions visible until
the user dismisses them or another launch begins. The dock maps session states
without inference:
- `launching` → “Opening player…” with a spinner;
- `opened` → “Player started”;
- `playing` → “Playing”;
- `error` → the existing session error or a localized generic external-player
failure;
- `closed` remains hidden by the global service.
The dock uses an `aria-live="polite"` status region and `aria-busy` while
launching. A live closable session shows “Close player”; an error shows
“Dismiss”. Dismiss only hides the terminal notification and never stores a
retry payload, so headers and credentials are not retained for a dock retry.
## Error And Privacy Boundaries
The diagnostic action shows only localized, app-owned launch-state copy. It
does not render rejected promise messages or external-player stderr. The dock
may continue to show the existing session error supplied by the Electron
boundary; it does not add new logging or persistence.
Recovery recommendations remain user-selected. There is no automatic retry,
automatic player switch, persistent history, telemetry, or learning.
## Testing
Unit and component coverage will prove:
- external attempts stay visible and rerank without weakening inline
engine-family exclusion;
- one handshake at a time, duplicate activation rejection, stable DOM keys,
spinner/labels, and `aria-busy`/`aria-disabled` feedback;
- exact session-ID correlation, stale update/timeout rejection, session-key
reset, close-before-switch, and unclosable-session failure;
- dock `launching`, `opened`, `playing`, `error`, close, and dismiss states;
- `ExternalPlaybackService` keeps errors visible until dismissal.
Electron E2E will extend the deterministic recovery fixture to assert that the
buttons remain after an MPV attempt, VLC is promoted, the lower dock shows
launch feedback, and an error remains dismissible. Existing playback fixtures
and player stubs must be used; no real playlist, account, or external process is
required.
## Documentation And Release Note
Update the canonical playback recovery architecture in
`docs/architecture/embedded-inline-playback.md`, the shared-player summary in
`AGENTS.md` and `CLAUDE.md`, and add a user-facing `fix(playback)` note under
`.changes/`.
## Out Of Scope
- proving playback for sessions that do not expose position polling;
- retry from the dock;
- retaining playback headers or credentials after the original request;
- automatic failover between MPV and VLC;
- redesigning the diagnostic overlay or dock.
@@ -1,5 +1,6 @@
import type {
ExternalPlayerName,
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ErrorDetails, ErrorTypes } from 'hls.js';
@@ -278,4 +279,7 @@ export interface PlaybackFallbackRequest {
readonly player: ExternalPlayerName;
readonly playback: ResolvedPortalPlayback;
readonly diagnostic: PlaybackDiagnostic;
readonly trackLaunch: (
launch: Promise<ExternalPlayerSession | void>
) => void;
}
@@ -41,6 +41,7 @@ import {
import {
Channel,
EpgProgram,
ExternalPlayerSession,
ResolvedPortalPlayback,
Settings,
VideoPlayer,
@@ -197,6 +198,7 @@ describe('VideoPlayerComponent', () => {
const stripCountryPrefix = signal(false);
const epgViewMode = signal<'timeline' | 'list'>('timeline');
const epgUrlSetting = signal<string[]>([]);
const externalSession = signal<ExternalPlayerSession | null>(null);
const originalElectron = window.electron;
const overlayRef = {
@@ -325,6 +327,7 @@ describe('VideoPlayerComponent', () => {
channelsLoading.set(false);
currentEpgProgram.set(null);
activeEpgProgram.set(null);
externalSession.set(null);
currentEpgProgram$.next(null);
epgPrograms$.next([]);
overlayMock.create.mockClear();
@@ -415,7 +418,7 @@ describe('VideoPlayerComponent', () => {
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
activeSession: externalSession,
},
},
],
@@ -582,9 +585,13 @@ describe('VideoPlayerComponent', () => {
Origin: 'https://origin.example.com',
},
};
const launch = Promise.resolve();
const trackLaunch = jest.fn();
dataServiceMock.sendIpcEvent.mockReturnValueOnce(launch);
component.handleExternalFallbackRequest({
player: 'mpv',
playback,
trackLaunch,
diagnostic: {
code: 'unsupported-codec',
source: 'hls',
@@ -605,6 +612,7 @@ describe('VideoPlayerComponent', () => {
origin: 'https://origin.example.com',
}
);
expect(trackLaunch).toHaveBeenCalledWith(launch);
});
it('renders the embedded mpv inline player with the EPG panel', () => {
@@ -641,6 +649,38 @@ describe('VideoPlayerComponent', () => {
).toBe(false);
});
it('clears the channel when a closable error becomes terminal', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.MPV);
const closableError: ExternalPlayerSession = {
id: 'external-1',
player: 'mpv',
status: 'error',
title: sampleChannel.name,
streamUrl: sampleChannel.url,
startedAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
error: 'Process exit was not confirmed',
canClose: true,
};
externalSession.set(closableError);
fixture.detectChanges();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
externalSession.set({
...closableError,
canClose: false,
updatedAt: '2026-08-08T00:00:01.000Z',
});
fixture.detectChanges();
expect(storeMock.dispatch).toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
});
it('keeps DASH channels inline on the HTML5 player even when MPV is configured', () => {
syncStoreState({
...sampleChannel,
@@ -80,6 +80,7 @@ import {
persistLiveEpgPanelState,
persistLiveSidebarState,
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
WorkspaceHeaderContextService,
@@ -1083,10 +1084,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
return;
}
this.dataService.sendIpcEvent(
request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER,
payload
const launch = Promise.resolve(
this.dataService.sendIpcEvent<ExternalPlayerSession>(
request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER,
payload
)
);
request.trackLaunch(launch);
void launch;
}
private toLiveEpgPanelSummary(
@@ -1110,7 +1115,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
return null;
}
return `${session.id}:${session.status}`;
const lifecycle = isLiveExternalPlayerSession(session)
? 'live'
: 'terminal';
return `${session.id}:${session.status}:${lifecycle}`;
}
private isExternalPlayer(
@@ -1122,7 +1130,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private isTerminalExternalSession(
session: ExternalPlayerSession | null | undefined
): boolean {
return session?.status === 'closed' || session?.status === 'error';
return !!session && !isLiveExternalPlayerSession(session);
}
private registerHeaderShortcut(): void {
@@ -204,7 +204,7 @@
}
</mat-menu>
}
@if (categoryItemCount() > 0) {
@if (!supportsInfiniteScroll && categoryItemCount() > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="categoryItemCount()"
@@ -216,17 +216,20 @@
}
</div>
<app-grid-list
appInfiniteScroll
[infiniteHasMore]="infiniteHasMore()"
[infiniteAppending]="infiniteAppending()"
[infiniteItemCount]="contentWithProgress().length"
[infiniteResetKey]="gridResetKey()"
(infiniteLoadMore)="onLoadMore()"
[isLoading]="isPaginatedContentLoading()"
[isAppending]="infiniteAppending()"
[appendError]="infiniteAppendError()"
(retryLoadMore)="onRetryAppend()"
[items]="contentWithProgress()"
(itemClicked)="onItemClick($event)"
[pageIndex]="pageIndex()"
[totalPages]="totalPages()"
[limit]="limit()"
[pageSizeOptions]="pageSizeOptions"
[showPaginator]="false"
[searchTerm]="searchTerm()"
[type]="contentType() ?? ''"
(pageChange)="onPageChange($event)"
/>
</div>
}
@@ -11,6 +11,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EMPTY, ReplaySubject, of } from 'rxjs';
import { InfiniteScrollDirective } from '@iptvnator/portal/shared/ui';
import {
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
@@ -25,16 +26,13 @@ import { CategoryContentViewComponent } from './category-content-view.component'
})
class MockGridListComponent {
readonly isLoading = input<boolean>();
readonly isAppending = input<boolean>();
readonly appendError = input<boolean>();
readonly items = input<unknown[]>();
readonly pageIndex = input<number>();
readonly totalPages = input<number>();
readonly limit = input<number>();
readonly pageSizeOptions = input<number[]>();
readonly showPaginator = input(true);
readonly searchTerm = input<string>('');
readonly type = input<string>('');
readonly itemClicked = output<unknown>();
readonly pageChange = output<unknown>();
readonly retryLoadMore = output<void>();
}
@Component({
@@ -67,8 +65,12 @@ describe('CategoryContentViewComponent', () => {
const contentSortMode = signal<PortalCatalogSortMode | null>(null);
const minRating = signal<number | null>(null);
const selectedItem = signal<Record<string, unknown> | null>(null);
const hasMore = signal(false);
const isAppending = signal(false);
const appendError = signal(false);
const catalog = {
provider: 'xtream' as 'xtream' | 'stalker',
supportsInfiniteScroll: false as boolean,
pageSizeOptions: [10, 25, 50],
contentType: signal('vod'),
limit: signal(25),
@@ -79,6 +81,9 @@ describe('CategoryContentViewComponent', () => {
categoryItemCount,
selectedItem,
totalPages: signal(0),
hasMore,
isAppending,
appendError,
contentSortMode,
supportsRatingSort: true,
minRating,
@@ -89,6 +94,10 @@ describe('CategoryContentViewComponent', () => {
clearSelectedItem: jest.fn(),
setPage: jest.fn(),
setLimit: jest.fn(),
loadMore: jest.fn(),
retryAppend: jest.fn(),
saveScrollPosition: jest.fn(),
consumeSavedScrollPosition: jest.fn().mockReturnValue(null),
setContentSortMode: jest.fn(),
setMinRating: jest.fn(),
selectItem: jest.fn().mockReturnValue(null),
@@ -99,16 +108,25 @@ describe('CategoryContentViewComponent', () => {
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
catalog.provider = 'xtream';
catalog.supportsInfiniteScroll = false;
selectedItem.set(null);
isPaginatedContentLoading.set(true);
categoryItemCount.set(0);
contentSortMode.set(null);
catalog.supportsRatingSort = true;
minRating.set(null);
hasMore.set(false);
isAppending.set(false);
appendError.set(false);
catalog.initialize.mockClear();
catalog.setSearchQuery.mockClear();
catalog.setPage.mockClear();
catalog.setLimit.mockClear();
catalog.loadMore.mockClear();
catalog.retryAppend.mockClear();
catalog.saveScrollPosition.mockClear();
catalog.consumeSavedScrollPosition.mockClear();
catalog.consumeSavedScrollPosition.mockReturnValue(null);
catalog.setContentSortMode.mockClear();
catalog.setMinRating.mockClear();
catalog.selectItem.mockClear();
@@ -177,6 +195,7 @@ describe('CategoryContentViewComponent', () => {
set: {
imports: [
NgComponentOutlet,
InfiniteScrollDirective,
MockGridListComponent,
MockPlaylistErrorViewComponent,
MatIcon,
@@ -554,4 +573,132 @@ describe('CategoryContentViewComponent', () => {
.componentInstance as MockDetailComponent;
expect(detail.providerOnly()).toBe(false);
});
describe('infinite scroll mode', () => {
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
rafCallbacks = [];
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
}
);
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(
() => undefined
);
});
afterEach(() => {
jest.restoreAllMocks();
});
function createInfiniteFixture(): ComponentFixture<CategoryContentViewComponent> {
// The outer paged fixture shares ApplicationRef: an app-wide tick
// would run its ngOnInit and let it consume the same query params.
fixture.destroy();
catalog.supportsInfiniteScroll = true;
isPaginatedContentLoading.set(false);
return TestBed.createComponent(CategoryContentViewComponent);
}
function flushAnimationFrames(): void {
while (rafCallbacks.length) {
const callback = rafCallbacks.shift();
callback?.(0);
}
}
it('renders no paginator and delegates loadMore to the facade', () => {
const infiniteFixture = createInfiniteFixture();
categoryItemCount.set(12);
catalog.paginatedContent.set([{ xtream_id: 1, title: 'A' }]);
infiniteFixture.detectChanges();
expect(
infiniteFixture.nativeElement.querySelector('mat-paginator')
).toBeNull();
infiniteFixture.componentInstance.onLoadMore();
expect(catalog.loadMore).toHaveBeenCalledTimes(1);
});
it('strips a stale page query param instead of paging', () => {
const infiniteFixture = createInfiniteFixture();
queryParamMap$.next(convertToParamMap({ page: '3' }));
infiniteFixture.detectChanges();
expect(catalog.setPage).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(
[],
expect.objectContaining({
queryParams: { page: null },
replaceUrl: true,
})
);
});
it('saves the grid scroll position before opening a detail', () => {
const infiniteFixture = createInfiniteFixture();
infiniteFixture.detectChanges();
const grid = infiniteFixture.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement;
Object.defineProperty(grid, 'scrollTop', {
configurable: true,
value: 333,
});
infiniteFixture.componentInstance.onItemClick({ xtream_id: 42 });
expect(catalog.saveScrollPosition).toHaveBeenCalledWith(333);
});
it('consumes a saved scroll position once the list is rendered', () => {
catalog.consumeSavedScrollPosition.mockReturnValue(500);
const infiniteFixture = createInfiniteFixture();
infiniteFixture.detectChanges();
expect(catalog.consumeSavedScrollPosition).toHaveBeenCalledTimes(
1
);
const grid = infiniteFixture.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement;
const scrollTo = jest.fn();
Object.defineProperty(grid, 'scrollTo', {
configurable: true,
value: scrollTo,
});
flushAnimationFrames();
expect(scrollTo).toHaveBeenCalledWith({ top: 500 });
});
it('scrolls the grid to the top when the reset key changes', () => {
const infiniteFixture = createInfiniteFixture();
contentSortMode.set('date-desc');
infiniteFixture.detectChanges();
const grid = infiniteFixture.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement;
const scrollTo = jest.fn();
Object.defineProperty(grid, 'scrollTo', {
configurable: true,
value: scrollTo,
});
contentSortMode.set('name-asc');
infiniteFixture.detectChanges();
expect(scrollTo).toHaveBeenCalledWith({ top: 0 });
});
});
});
@@ -4,8 +4,10 @@ import {
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
OnDestroy,
OnInit,
signal,
} from '@angular/core';
@@ -20,6 +22,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
GridListComponent,
InfiniteScrollDirective,
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
@@ -52,6 +55,7 @@ interface CategoryContentItem {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GridListComponent,
InfiniteScrollDirective,
MatButtonModule,
MatIcon,
MatMenuModule,
@@ -62,7 +66,7 @@ interface CategoryContentItem {
TranslatePipe,
],
})
export class CategoryContentViewComponent implements OnInit {
export class CategoryContentViewComponent implements OnInit, OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly hostElement = inject(ElementRef<HTMLElement>);
@@ -81,17 +85,33 @@ export class CategoryContentViewComponent implements OnInit {
readonly detailComponent = inject(PORTAL_CATALOG_DETAIL_COMPONENT);
readonly contentType = this.catalog.contentType;
readonly limit = this.catalog.limit;
readonly pageIndex = this.catalog.pageIndex;
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions);
/**
* Transitional (pagination removal, PR 1): infinite scroll drives Xtream,
* while a facade without the capability (Stalker) keeps the paginator and
* the `?page=` round-trip below. PR 2 deletes the paged branch.
*/
readonly supportsInfiniteScroll =
this.catalog.supportsInfiniteScroll === true;
readonly limit = this.catalog.limit ?? computed(() => 0);
readonly pageIndex = this.catalog.pageIndex ?? computed(() => 0);
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions ?? []);
readonly selectedCategory = this.catalog.selectedCategory;
readonly paginatedContent = this.catalog.paginatedContent;
readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle;
readonly categoryItemCount = this.catalog.categoryItemCount;
readonly selectedItem = this.catalog.selectedItem;
readonly totalPages = this.catalog.totalPages;
readonly contentSortMode = this.catalog.contentSortMode;
readonly isPaginatedContentLoading = this.catalog.isPaginatedContentLoading;
readonly infiniteHasMore = computed(
() =>
this.supportsInfiniteScroll && (this.catalog.hasMore?.() ?? false)
);
readonly infiniteAppending = computed(
() => this.catalog.isAppending?.() ?? false
);
readonly infiniteAppendError = computed(
() => this.catalog.appendError?.() ?? false
);
readonly isXtreamLoadingSubtitle = computed(
() =>
this.catalog.provider === 'xtream' &&
@@ -172,6 +192,71 @@ export class CategoryContentViewComponent implements OnInit {
...this.catalog.getItemProgress(item),
}))
);
/**
* Identity of the rendered list. A change means the grid shows a
* different result set: the scroll resets to the top and the infinite
* scroll directive gets a fresh auto-fill budget. Appends keep the key
* stable, so they never scroll.
*/
readonly gridResetKey = computed(() => {
const category = this.selectedCategory();
const categoryId = category?.category_id ?? category?.id ?? '';
return [
this.contentType() ?? '',
String(categoryId),
this.searchTerm(),
this.contentSortMode() ?? '',
String(this.minRating() ?? ''),
].join('|');
});
private previousGridResetKey: string | null = null;
private hasAttemptedScrollRestore = false;
constructor() {
effect(() => {
const resetKey = this.gridResetKey();
if (!this.supportsInfiniteScroll) {
return;
}
if (
this.previousGridResetKey !== null &&
this.previousGridResetKey !== resetKey
) {
this.scrollGridToTop();
}
this.previousGridResetKey = resetKey;
});
// One-shot scroll restore after a detail round-trip: once the list is
// rendered (not loading, no detail overlay), ask the facade for a
// saved position matching the current selection.
effect(() => {
if (
!this.supportsInfiniteScroll ||
this.hasAttemptedScrollRestore ||
this.isPaginatedContentLoading() ||
this.selectedItem()
) {
return;
}
this.hasAttemptedScrollRestore = true;
const scrollTop =
this.catalog.consumeSavedScrollPosition?.() ?? null;
if (scrollTop === null || scrollTop <= 0) {
return;
}
// Two frames: one for change detection to render the restored
// window, one to apply the offset to the grown grid.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.gridElement()?.scrollTo?.({ top: scrollTop });
});
});
});
}
setContentSortMode(mode: PortalCatalogSortMode): void {
this.catalog.setContentSortMode(mode);
@@ -194,14 +279,25 @@ export class CategoryContentViewComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
const searchQuery = params.get('q') ?? '';
const pageIndex = this.toPageIndex(params.get('page'));
this.catalog.setSearchQuery?.(searchQuery);
if (this.supportsInfiniteScroll) {
// A search change resets the render window in the store;
// only a stale `?page=` from a legacy deep link needs
// cleaning up here.
if (params.has('page')) {
this.clearPageQueryParam();
}
return;
}
const pageIndex = this.toPageIndex(params.get('page'));
if (!this.hasAppliedInitialQueryParams) {
this.hasAppliedInitialQueryParams = true;
this.previousSearchQuery = searchQuery;
this.catalog.setPage(pageIndex);
this.catalog.setPage?.(pageIndex);
return;
}
@@ -210,20 +306,32 @@ export class CategoryContentViewComponent implements OnInit {
this.previousSearchQuery = searchQuery;
if (didSearchChange) {
this.catalog.setPage(0);
this.catalog.setPage?.(0);
if (params.has('page')) {
this.clearPageQueryParam();
}
return;
}
this.catalog.setPage(pageIndex);
this.catalog.setPage?.(pageIndex);
});
}
ngOnDestroy(): void {
// Leaving the list without opening a detail (e.g. switching portal
// tabs) still snapshots the spot; the facade validates the selection
// before ever restoring it.
if (this.supportsInfiniteScroll && !this.selectedItem()) {
const grid = this.gridElement();
if (grid) {
this.catalog.saveScrollPosition?.(grid.scrollTop);
}
}
}
onPageChange(event: PageEvent): void {
this.catalog.setPage(event.pageIndex);
this.catalog.setLimit(event.pageSize);
this.catalog.setPage?.(event.pageIndex);
this.catalog.setLimit?.(event.pageSize);
this.scrollGridToTop();
void this.router.navigate([], {
@@ -236,7 +344,23 @@ export class CategoryContentViewComponent implements OnInit {
});
}
onLoadMore(): void {
this.catalog.loadMore?.();
}
onRetryAppend(): void {
this.catalog.retryAppend?.();
}
onItemClick(item: CategoryContentItem): void {
if (this.supportsInfiniteScroll) {
// Snapshot the grid offset before the detail replaces the list.
const grid = this.gridElement();
if (grid) {
this.catalog.saveScrollPosition?.(grid.scrollTop);
}
}
this.providerOnlyStalkerItemId.set(null);
const navigation = this.catalog.selectItem(item);
if (navigation?.length) {
@@ -252,11 +376,14 @@ export class CategoryContentViewComponent implements OnInit {
return Number.isInteger(page) && page > 0 ? page - 1 : 0;
}
private scrollGridToTop(): void {
const gridList = this.hostElement.nativeElement.querySelector(
private gridElement(): HTMLElement | null {
return this.hostElement.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement | null;
gridList?.scrollTo?.({ top: 0 });
}
private scrollGridToTop(): void {
this.gridElement()?.scrollTo?.({ top: 0 });
}
private clearPageQueryParam(): void {
+1
View File
@@ -1,4 +1,5 @@
export * from './lib/components/category-view/category-view.component';
export * from './lib/directives/infinite-scroll.directive';
export * from './lib/components/content-card/content-card.component';
export * from './lib/components/content-rail-shell/content-rail-shell.component';
export * from './lib/components/favorites-layout/favorites-layout.component';
@@ -40,6 +40,8 @@
[isRadio]="ch.radio === 'true'"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[catchupAvailable]="catchupAvailable(ch)"
[catchupDays]="catchupDays(ch)"
[showFavoriteButton]="true"
[isFavorite]="isChannelFavorite(ch)"
[showProgramInfoButton]="true"
@@ -75,6 +75,33 @@ describe('GlobalFavoritesListComponent', () => {
expect(icons).toEqual(['star_outline', 'star']);
});
it('shows the catch-up badge on Xtream rows with a playable archive', () => {
fixture.componentRef.setInput('channels', [
buildChannel('x1', 'Archive Channel', {
sourceType: 'xtream',
xtreamId: 42,
tvArchive: 1,
tvArchiveDuration: 7,
}),
buildChannel('x2', 'Plain Channel', {
sourceType: 'xtream',
xtreamId: 43,
tvArchive: 0,
tvArchiveDuration: 0,
}),
buildChannel('m1', 'M3U Channel'),
]);
fixture.detectChanges();
const badges = fixture.nativeElement.querySelectorAll(
'[data-test-id="catchup-badge"]'
);
expect(badges).toHaveLength(1);
const badgeRow = badges[0].closest('[data-test-id="channel-item"]');
expect(badgeRow?.textContent).toContain('Archive Channel');
});
it('renders radio rows as compact without a false EPG placeholder', () => {
fixture.componentRef.setInput('channels', [
buildChannel('radio', 'Radio One', { radio: 'true' }),
@@ -35,6 +35,8 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
getXtreamCatchupDays,
isXtreamCatchupAvailable,
sortFavoriteChannelItems,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
@@ -144,6 +146,21 @@ export class GlobalFavoritesListComponent {
});
});
/** Catch-up fields arrive camelCase on unified rows — adapt for the
* shared snake_case helper. Only Xtream rows carry them. */
protected catchupAvailable(channel: UnifiedFavoriteChannel): boolean {
return isXtreamCatchupAvailable({
tv_archive: channel.tvArchive,
tv_archive_duration: channel.tvArchiveDuration,
});
}
protected catchupDays(channel: UnifiedFavoriteChannel): number {
return getXtreamCatchupDays({
tv_archive_duration: channel.tvArchiveDuration,
});
}
onChannelClick(channel: UnifiedFavoriteChannel): void {
this.channelSelected.emit(channel);
}
@@ -107,6 +107,41 @@ mat-card:hover {
}
// ─── Rating badge ─────────────────────────────────────────────────────────────
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.catchup-badge {
position: absolute;
top: 8px;
left: 8px;
width: 24px;
height: 24px;
// Solid dark pill like .rating — no expensive backdrop-filter
background: rgba(0, 0, 0, 0.85);
color: rgba(255, 255, 255, 0.88);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
line-height: 15px;
}
}
.rating {
position: absolute;
top: 6px;
@@ -167,3 +202,24 @@ mat-card:hover {
}
}
}
// ─── Infinite scroll ─────────────────────────────────────────────────────────
// Off-screen cards skip rendering work; the intrinsic-size hint keeps the
// scrollbar stable while they are skipped (poster 2/3 ratio + title row).
.grid-list__grid mat-card {
content-visibility: auto;
contain-intrinsic-size: auto 270px;
}
.grid-list__tail {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 18px 0 6px;
color: var(--app-muted-color, inherit);
&.grid-list__tail--error {
color: var(--mat-sys-error, inherit);
}
}
@@ -173,6 +173,70 @@ describe('GridListComponent', () => {
);
});
it('shows the catch-up badge on live cards with a playable archive', () => {
fixture.componentRef.setInput('items', [
{
name: 'Archive Channel',
stream_icon: 'channel-logo.png',
tv_archive: 1,
tv_archive_duration: 7,
},
]);
fixture.componentRef.setInput('variant', 'logo');
fixture.componentRef.setInput('type', 'live');
fixture.detectChanges();
const badge = fixture.debugElement.query(
By.css('[data-test-id="grid-catchup-badge"]')
);
expect(badge).not.toBeNull();
expect(badge.nativeElement.textContent).toContain('history');
// The icon is aria-hidden — the status must also exist as
// visually-hidden text for assistive technology.
const srText = badge.query(By.css('.visually-hidden'));
expect(srText.nativeElement.textContent).toContain(
'CATCHUP_AVAILABLE'
);
});
it('hides the catch-up badge for live cards without a playable archive', () => {
fixture.componentRef.setInput('items', [
{ name: 'Flag Only', tv_archive: 1, tv_archive_duration: 0 },
{ name: 'No Archive', tv_archive: 0, tv_archive_duration: 7 },
{ name: 'Legacy Row' },
]);
fixture.componentRef.setInput('type', 'live');
fixture.detectChanges();
expect(
fixture.debugElement.queryAll(
By.css('[data-test-id="grid-catchup-badge"]')
)
).toHaveLength(0);
});
it('never shows the catch-up badge on VOD grids', () => {
fixture.componentRef.setInput('items', [
{
title: 'Some Movie',
tv_archive: 1,
tv_archive_duration: 7,
},
]);
fixture.componentRef.setInput('type', 'vod');
fixture.detectChanges();
expect(
fixture.debugElement.query(
By.css('[data-test-id="grid-catchup-badge"]')
)
).toBeNull();
});
it('renders raw titles while prefix stripping is disabled', () => {
fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]);
fixture.componentRef.setInput('type', 'live');
@@ -7,12 +7,17 @@ import {
output,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import {
getXtreamCatchupDays,
isXtreamCatchupAvailable,
} from '@iptvnator/portal/shared/util';
import { SettingsStore } from '@iptvnator/services';
import {
ProgressCapsuleComponent,
@@ -38,6 +43,8 @@ interface GridListItem {
progress?: number;
isWatched?: boolean;
hasSeriesProgress?: boolean;
tv_archive?: number | string | null;
tv_archive_duration?: number | string | null;
[key: string]: unknown;
}
@@ -136,6 +143,24 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
@if (i.progress && i.progress > 0) {
<app-progress-capsule [progress]="i.progress" />
}
@if (showCatchupBadge(i)) {
<div
class="catchup-badge"
data-test-id="grid-catchup-badge"
[matTooltip]="
catchupLabelKey(i)
| translate: { days: catchupDays(i) }
"
>
<mat-icon>history</mat-icon>
<!-- mat-icon is aria-hidden; expose the
status as text for AT users -->
<span class="visually-hidden">{{
catchupLabelKey(i)
| translate: { days: catchupDays(i) }
}}</span>
</div>
}
@if (i.isWatched) {
<app-watched-badge
[isWatched]="true"
@@ -196,24 +221,32 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
}
}
</div>
@if (showPaginator() && items().length > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="totalPages() * limit()"
[pageSize]="limit()"
[pageSizeOptions]="pageSizeOptions()"
(page)="pageChange.emit($event)"
aria-label="Select page"
/>
} `,
@if (isAppending()) {
<div class="grid-list__tail" aria-live="polite">
<mat-spinner diameter="28" />
<span>{{ 'PORTALS.GRID.LOADING_MORE' | translate }}</span>
</div>
} @else if (appendError()) {
<div class="grid-list__tail grid-list__tail--error" role="alert">
<span>{{ 'PORTALS.GRID.LOAD_MORE_FAILED' | translate }}</span>
<button
type="button"
mat-stroked-button
(click)="retryLoadMore.emit()"
>
{{ 'PORTALS.GRID.RETRY' | translate }}
</button>
</div>
}`,
styleUrl: './grid-list.component.scss',
imports: [
TranslatePipe,
PlaylistErrorViewComponent,
MatButtonModule,
MatCardModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltip,
MatPaginatorModule,
ProgressCapsuleComponent,
WatchedBadgeComponent,
],
@@ -225,15 +258,14 @@ export class GridListComponent {
readonly items = input<GridListItem[]>([]);
readonly isLoading = input<boolean>(false);
readonly showPaginator = input(true);
/** True while an infinite-scroll append is in flight (tail spinner). */
readonly isAppending = input<boolean>(false);
/** True when the latest append failed; renders the retry tail. */
readonly appendError = input<boolean>(false);
readonly searchTerm = input<string>('');
readonly itemClicked = output<GridListItem>();
readonly pageChange = output<PageEvent>();
readonly retryLoadMore = output<void>();
readonly pageIndex = input<number>(0);
readonly totalPages = input<number>(0);
readonly limit = input<number>(25);
readonly pageSizeOptions = input<number[]>([]);
readonly variant = input<'poster' | 'logo'>('poster');
readonly type = input<'vod' | 'series' | 'live' | string>('');
protected readonly resolveRating = resolveGridRating;
@@ -250,6 +282,16 @@ export class GridListComponent {
private readonly isLiveGrid = computed(() =>
['live', 'itv', 'radio'].includes(this.type())
);
protected readonly catchupDays = getXtreamCatchupDays;
/** Catch-up badge is live-grid only; VOD/series rows never carry it. */
protected showCatchupBadge(item: GridListItem): boolean {
return this.isLiveGrid() && isXtreamCatchupAvailable(item);
}
protected catchupLabelKey(item: GridListItem): string {
return getXtreamCatchupDays(item) > 0
? 'CHANNELS.CATCHUP_AVAILABLE_DAYS'
: 'CHANNELS.CATCHUP_AVAILABLE';
}
protected readonly channelTitle = (item: GridListItem): string => {
const raw = item.title ?? item.o_name ?? item.name ?? '';
const stripped = applyChannelNameStrip(
@@ -259,11 +301,9 @@ export class GridListComponent {
return stripped || 'No name';
};
readonly skeletonRows = computed(() => {
const preferredCount = this.limit() ?? 12;
const count = Math.max(8, Math.min(18, preferredCount));
return Array.from({ length: count }, (_, index) => index);
});
readonly skeletonRows = computed(() =>
Array.from({ length: 12 }, (_, index) => index)
);
protected hasArtworkFailed(poster: string): boolean {
return this.failedArtworkUrls().has(poster);
@@ -61,7 +61,12 @@
<div
class="results-container"
(scroll)="onSearchContentScroll($event)"
appInfiniteScroll
[infiniteHasMore]="nearEndHasMore()"
[infiniteAppending]="nearEndAppending()"
[infiniteItemCount]="nearEndRenderedCount() ?? resultsCount()"
[infiniteResetKey]="nearEndResetKey() ?? searchTerm()"
(infiniteLoadMore)="nearEnd.emit()"
>
@if (isLoading()) {
<div class="loading-state">
@@ -113,4 +113,114 @@ describe('SearchLayoutComponent', () => {
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).toHaveBeenCalledTimes(2);
});
it('resets the near-end latch when the reset identity changes without a new term', () => {
// Regression: a filter-only search transition replaces the result set
// while the term stays the same; the latch must not survive it.
const nearEndSpy = jest.fn();
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
const resultsContainer = renderResultsContainer();
setScrollMetrics(resultsContainer, 700);
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).toHaveBeenCalledTimes(1);
fixture.componentRef.setInput('nearEndResetKey', 'matrix|movie-only');
fixture.detectChanges();
// Still inside the threshold — a stale latch would swallow this.
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).toHaveBeenCalledTimes(2);
});
it('does not emit nearEnd when the consumer reports no more results', () => {
const nearEndSpy = jest.fn();
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
fixture.componentRef.setInput('nearEndHasMore', false);
const resultsContainer = renderResultsContainer();
setScrollMetrics(resultsContainer, 500);
resultsContainer.dispatchEvent(new Event('scroll'));
setScrollMetrics(resultsContainer, 700);
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).not.toHaveBeenCalled();
});
it('re-measures when the rendered window grows while the total stays constant', () => {
// Regression (bots, round 2): binding the constant result-set total to
// the directive left no tracked input changing after the consumer
// revealed a chunk, so the auto-fill stopped after one expansion.
const rafCallbacks: FrameRequestCallback[] = [];
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
}
);
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(
() => undefined
);
try {
const nearEndSpy = jest.fn();
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
fixture.componentRef.setInput('searchTerm', 'matrix');
fixture.componentRef.setInput('resultsCount', 130);
fixture.componentRef.setInput('nearEndRenderedCount', 60);
fixture.detectChanges();
const flush = () => {
while (rafCallbacks.length) {
const callback = rafCallbacks.shift();
callback?.(0);
}
};
flush();
expect(nearEndSpy).toHaveBeenCalledTimes(1);
// The consumer reveals the next chunk; the total does not change,
// but the rendered count must schedule another overflow check.
fixture.componentRef.setInput('nearEndRenderedCount', 120);
fixture.detectChanges();
flush();
expect(nearEndSpy).toHaveBeenCalledTimes(2);
} finally {
jest.restoreAllMocks();
}
});
it('auto-fills via nearEnd when the rendered results do not overflow', () => {
// Regression: a result window larger than the viewport-visible chunk
// must not stall when the rendered cards never create a scrollbar —
// the overflow check has to reveal further chunks without any scroll.
const rafCallbacks: FrameRequestCallback[] = [];
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
}
);
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(
() => undefined
);
try {
const nearEndSpy = jest.fn();
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
renderResultsContainer();
// JSDOM default geometry (all zeros) models a non-overflowing
// container; flushing the scheduled overflow check must emit.
while (rafCallbacks.length) {
const callback = rafCallbacks.shift();
callback?.(0);
}
expect(nearEndSpy).toHaveBeenCalled();
} finally {
jest.restoreAllMocks();
}
});
});
@@ -9,12 +9,14 @@ import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { InfiniteScrollDirective } from '../../directives/infinite-scroll.directive';
import { SearchFormComponent } from '../search-form/search-form.component';
@Component({
selector: 'app-search-layout',
standalone: true,
imports: [
InfiniteScrollDirective,
MatIcon,
MatIconButton,
MatProgressSpinner,
@@ -27,8 +29,6 @@ import { SearchFormComponent } from '../search-form/search-form.component';
})
export class SearchLayoutComponent {
private readonly searchFormComponent = viewChild(SearchFormComponent);
private readonly nearEndThresholdPx = 240;
private isWithinNearEndThreshold = false;
/** Page title translation key */
readonly title = input<string>('PORTALS.SIDEBAR.SEARCH');
@@ -60,6 +60,38 @@ export class SearchLayoutComponent {
/** Minimum characters required for search */
readonly minSearchLength = input<number>(3);
/**
* Whether the consumer can supply more results than are rendered. Drives
* the results container's infinite scroll: near-end crossings and the
* measured auto-fill (which reveals further chunks even when the current
* ones do not overflow a tall viewport) both emit `nearEnd` only while
* this is true. Defaults to true, matching the historical unconditional
* `nearEnd` emission for consumers that manage their own guards.
*/
readonly nearEndHasMore = input<boolean>(true);
/** True while the consumer is appending; suppresses further triggers. */
readonly nearEndAppending = input<boolean>(false);
/**
* Number of currently RENDERED results — the infinite scroll re-measures
* overflow when this changes, so it must grow with the revealed window.
* `resultsCount` cannot serve here: it is the total result-set size and
* stays constant while a consumer reveals chunks of it, which would stop
* the auto-fill after the first chunk. Defaults to `resultsCount` for
* consumers that always render everything they report.
*/
readonly nearEndRenderedCount = input<number | null>(null);
/**
* Identity of the current result set for the infinite scroll's latch and
* auto-fill budget. Must change whenever the result set is replaced —
* including filter-only transitions where the term stays the same, or the
* stale latch can swallow the first jump back into the threshold.
* Defaults to the search term for consumers without extra filters.
*/
readonly nearEndResetKey = input<string | null>(null);
/** Initial state description translation key */
readonly initialDescriptionKey = input<string>(
'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION'
@@ -74,7 +106,11 @@ export class SearchLayoutComponent {
/** Emitted when the back button is clicked */
readonly backClick = output<void>();
/** Emitted when the scroll container is close to the bottom */
/**
* Emitted when more results should be revealed — on scrolling near the
* bottom and by the auto-fill overflow check (see `InfiniteScrollDirective`
* on the results container).
*/
readonly nearEnd = output<void>();
/** Focus the search input */
@@ -94,23 +130,6 @@ export class SearchLayoutComponent {
this.backClick.emit();
}
onSearchContentScroll(event: Event): void {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
const distanceToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
const isNearEnd = distanceToBottom <= this.nearEndThresholdPx;
if (isNearEnd && !this.isWithinNearEndThreshold) {
this.nearEnd.emit();
}
this.isWithinNearEndThreshold = isNearEnd;
}
/** Check if we should show the "no results" state */
get showNoResults(): boolean {
return (
@@ -0,0 +1,122 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import {
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
/**
* Focused spec for the `channelsForList` row mapping — kept separate from
* the main layout spec, which sits at the max-lines test budget.
*/
describe('UnifiedLiveTabComponent channelsForList', () => {
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent],
providers: [
{
provide: StreamResolverService,
useValue: {
resolveLiveDetail: jest.fn(),
loadEpgForItems: jest
.fn()
.mockResolvedValue(new Map()),
},
},
{
provide: UnifiedRecentDataService,
useValue: { recordLivePlayback: jest.fn() },
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsEpg: false },
},
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
player: signal(VideoPlayer.VideoJs),
stripCountryPrefix: signal(false),
resolvedEpgViewMode: signal('timeline'),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
},
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
})
.overrideComponent(UnifiedLiveTabComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(UnifiedLiveTabComponent);
});
afterEach(() => fixture.destroy());
it('passes the archive fields through to the sidebar rows', () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::pl-1::42',
name: 'Archive Channel',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'pl-1',
playlistName: 'Portal One',
logo: null,
posterUrl: null,
xtreamId: 42,
tvArchive: 1,
tvArchiveDuration: 5,
addedAt: '2026-04-30T12:00:00.000Z',
position: 0,
};
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
const row = fixture.componentInstance.channelsForList()[0];
expect(row.tvArchive).toBe(1);
expect(row.tvArchiveDuration).toBe(5);
});
it('normalises absent archive fields to null', () => {
const item = {
uid: 'm3u::pl-2::chan',
name: 'Plain Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'pl-2',
playlistName: 'Playlist Two',
logo: null,
posterUrl: null,
addedAt: '2026-04-30T12:00:00.000Z',
position: 0,
} as UnifiedCollectionItem;
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
const row = fixture.componentInstance.channelsForList()[0];
expect(row.tvArchive).toBeNull();
expect(row.tvArchiveDuration).toBeNull();
});
});
@@ -611,6 +611,7 @@ describe('UnifiedLiveTabComponent', () => {
webPlayer.externalFallbackRequested.emit({
player: 'mpv',
playback,
trackLaunch: jest.fn(),
diagnostic: {
code: 'network-error',
player: 'videojs',
@@ -285,6 +285,8 @@ export class UnifiedLiveTabComponent {
m3uChannel: item.m3uChannel,
radio: item.radio,
xtreamId: item.xtreamId,
tvArchive: item.tvArchive ?? null,
tvArchiveDuration: item.tvArchiveDuration ?? null,
tvgId: item.tvgId,
stalkerCmd: item.stalkerCmd,
stalkerPortalUrl: item.stalkerPortalUrl,
@@ -535,10 +537,12 @@ export class UnifiedLiveTabComponent {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
onClose(): void {
@@ -0,0 +1,235 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { InfiniteScrollDirective } from './infinite-scroll.directive';
@Component({
template: `<div
class="scroll-host"
appInfiniteScroll
[infiniteHasMore]="hasMore()"
[infiniteAppending]="appending()"
[infiniteItemCount]="itemCount()"
[infiniteResetKey]="resetKey()"
(infiniteLoadMore)="onLoadMore()"
></div>`,
imports: [InfiniteScrollDirective],
})
class TestHostComponent {
readonly hasMore = signal(true);
readonly appending = signal(false);
readonly itemCount = signal(0);
readonly resetKey = signal('a');
loads = 0;
onLoadMore(): void {
this.loads += 1;
}
}
describe('InfiniteScrollDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let scrollHost: HTMLElement;
let rafCallbacks: FrameRequestCallback[];
beforeEach(async () => {
rafCallbacks = [];
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
}
);
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(
() => undefined
);
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
scrollHost = fixture.debugElement.query(By.css('.scroll-host'))
.nativeElement as HTMLElement;
});
afterEach(() => {
jest.restoreAllMocks();
});
function setScrollMetrics(
scrollTop: number,
scrollHeight = 1000,
clientHeight = 120
): void {
Object.defineProperties(scrollHost, {
scrollHeight: { configurable: true, value: scrollHeight },
scrollTop: { configurable: true, value: scrollTop },
clientHeight: { configurable: true, value: clientHeight },
});
}
function flushScheduledChecks(): void {
while (rafCallbacks.length) {
const callback = rafCallbacks.shift();
callback?.(0);
}
}
it('emits loadMore only when crossing into the bottom threshold', () => {
flushScheduledChecks();
host.loads = 0;
setScrollMetrics(500);
scrollHost.dispatchEvent(new Event('scroll'));
expect(host.loads).toBe(0);
setScrollMetrics(700);
scrollHost.dispatchEvent(new Event('scroll'));
expect(host.loads).toBe(1);
// Still inside the threshold — no re-fire without leaving it first.
setScrollMetrics(710);
scrollHost.dispatchEvent(new Event('scroll'));
expect(host.loads).toBe(1);
setScrollMetrics(500);
scrollHost.dispatchEvent(new Event('scroll'));
setScrollMetrics(760);
scrollHost.dispatchEvent(new Event('scroll'));
expect(host.loads).toBe(2);
});
it('does not emit on scroll while appending or when nothing more exists', () => {
flushScheduledChecks();
host.loads = 0;
host.appending.set(true);
fixture.detectChanges();
setScrollMetrics(700);
scrollHost.dispatchEvent(new Event('scroll'));
expect(host.loads).toBe(0);
host.appending.set(false);
host.hasMore.set(false);
fixture.detectChanges();
flushScheduledChecks();
host.loads = 0;
setScrollMetrics(0);
scrollHost.dispatchEvent(new Event('scroll'));
setScrollMetrics(700);
scrollHost.dispatchEvent(new Event('scroll'));
expect(host.loads).toBe(0);
});
it('auto-fills when the container does not overflow the viewport', () => {
// JSDOM default geometry (all zeros) models an unfilled container.
setScrollMetrics(0, 0, 0);
flushScheduledChecks();
expect(host.loads).toBe(1);
});
it('keeps auto-filling while loads grow the container, until it overflows', () => {
// A huge viewport: many chunks fit before a scrollbar appears. The
// auto-fill must not stop at an arbitrary load budget — only overflow
// (or lack of progress) may end it, or the tail stays unreachable.
let contentHeight = 0;
setScrollMetrics(0, contentHeight, 5000);
flushScheduledChecks();
expect(host.loads).toBe(1);
for (let round = 0; round < 14; round++) {
contentHeight += 300;
setScrollMetrics(0, contentHeight, 5000);
host.itemCount.update((count) => count + 14);
fixture.detectChanges();
flushScheduledChecks();
}
expect(host.loads).toBe(15);
// The container finally overflows past the threshold — no more
// self-initiated loads; scrolling takes over from here.
setScrollMetrics(0, 6000, 5000);
host.itemCount.update((count) => count + 14);
fixture.detectChanges();
flushScheduledChecks();
expect(host.loads).toBe(15);
});
it('stops auto-filling after consecutive loads without container growth', () => {
setScrollMetrics(0, 0, 0);
flushScheduledChecks();
expect(host.loads).toBe(1);
// Each "append" changes the item count but never grows the container
// — a degenerate source; the stall guard ends the loop.
for (let round = 0; round < 20; round++) {
host.itemCount.update((count) => count + 14);
fixture.detectChanges();
flushScheduledChecks();
}
expect(host.loads).toBe(3);
});
it('resets the stall guard when the reset key changes', () => {
setScrollMetrics(0, 0, 0);
flushScheduledChecks();
for (let round = 0; round < 20; round++) {
host.itemCount.update((count) => count + 14);
fixture.detectChanges();
flushScheduledChecks();
}
expect(host.loads).toBe(3);
host.resetKey.set('b');
fixture.detectChanges();
flushScheduledChecks();
expect(host.loads).toBe(4);
});
it('clears a stale near-end latch when appended content moves the bottom away', () => {
// Empty container: the auto-fill fires and latches "within threshold".
setScrollMetrics(0, 0, 0);
flushScheduledChecks();
expect(host.loads).toBe(1);
// The append grows the content well past the threshold; the fill
// check must refresh the latch from the measured state.
setScrollMetrics(0, 2000, 400);
host.itemCount.set(50);
fixture.detectChanges();
flushScheduledChecks();
expect(host.loads).toBe(1);
// Jumping straight to the bottom (End key / scrollbar drag) is a
// genuine crossing again — a stale latch would swallow it.
setScrollMetrics(1600, 2000, 400);
scrollHost.dispatchEvent(new Event('scroll'));
expect(host.loads).toBe(2);
});
it('does not auto-fill while an append is in flight', () => {
setScrollMetrics(0, 0, 0);
host.appending.set(true);
fixture.detectChanges();
flushScheduledChecks();
host.loads = 0;
host.itemCount.set(14);
fixture.detectChanges();
flushScheduledChecks();
expect(host.loads).toBe(0);
// The append settling schedules the next overflow check.
host.appending.set(false);
fixture.detectChanges();
flushScheduledChecks();
expect(host.loads).toBe(1);
});
});
@@ -0,0 +1,165 @@
import {
DestroyRef,
Directive,
ElementRef,
effect,
inject,
input,
output,
untracked,
} from '@angular/core';
/**
* Matches the `nearEnd` threshold already used by `SearchLayoutComponent` so
* both infinite-scroll surfaces trigger at the same distance from the bottom.
*/
const NEAR_END_THRESHOLD_PX = 240;
/**
* How many consecutive self-initiated loads may complete WITHOUT growing the
* container before the auto-fill stops. Progress (a growing `scrollHeight`)
* always resets the count, so a huge viewport keeps filling until it actually
* overflows — a fixed load budget would strand the remaining items with no
* scrollbar to reach them. Only a degenerate source that reports more items
* but renders nothing trips this guard.
*/
const MAX_AUTO_FILL_STALLS = 3;
/**
* Infinite scroll for a scrollable list container.
*
* Two triggers emit `infiniteLoadMore`:
* - edge-triggered near-end detection while the user scrolls (fires once per
* crossing into the threshold, mirroring `SearchLayoutComponent`);
* - an auto-fill check that measures the container instead of guessing item
* sizes: whenever the rendered list changes (or the container resizes) and
* the bottom is still within the threshold, another load is requested. This
* fills viewports taller than one provider page without any card-size math.
*
* The host element must be the scroll container itself (`overflow-y: auto`).
*/
@Directive({
selector: '[appInfiniteScroll]',
standalone: true,
host: { '(scroll)': 'onScroll()' },
})
export class InfiniteScrollDirective {
private readonly host = inject(ElementRef<HTMLElement>);
private readonly destroyRef = inject(DestroyRef);
/** Whether the data source can supply more items. */
readonly infiniteHasMore = input(false);
/** True while an asynchronous append is in flight; suppresses triggers. */
readonly infiniteAppending = input(false);
/** Rendered item count; a change schedules an overflow re-check. */
readonly infiniteItemCount = input(0);
/** Identity of the rendered list; a change resets the auto-fill budget. */
readonly infiniteResetKey = input('');
readonly infiniteLoadMore = output<void>();
private isWithinNearEndThreshold = false;
private autoFillStalls = 0;
private lastAutoFillScrollHeight: number | null = null;
private pendingCheckFrame: number | null = null;
constructor() {
effect(() => {
this.infiniteResetKey();
untracked(() => {
this.autoFillStalls = 0;
this.lastAutoFillScrollHeight = null;
this.isWithinNearEndThreshold = false;
this.scheduleFillCheck();
});
});
effect(() => {
this.infiniteItemCount();
this.infiniteHasMore();
const appending = this.infiniteAppending();
untracked(() => {
if (!appending) {
this.scheduleFillCheck();
}
});
});
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => this.scheduleFillCheck());
resizeObserver?.observe(this.host.nativeElement);
this.destroyRef.onDestroy(() => {
resizeObserver?.disconnect();
if (this.pendingCheckFrame !== null) {
cancelAnimationFrame(this.pendingCheckFrame);
this.pendingCheckFrame = null;
}
});
}
protected onScroll(): void {
const isNearEnd = this.isNearEnd();
if (isNearEnd && !this.isWithinNearEndThreshold && this.canLoad()) {
this.infiniteLoadMore.emit();
}
this.isWithinNearEndThreshold = isNearEnd;
}
private scheduleFillCheck(): void {
if (this.pendingCheckFrame !== null) {
return;
}
this.pendingCheckFrame = requestAnimationFrame(() => {
this.pendingCheckFrame = null;
this.runFillCheck();
});
}
private runFillCheck(): void {
// Refresh the edge latch from the measured state: appended content can
// move the bottom out of the threshold without any scroll event, and a
// stale latch would swallow the next genuine crossing (End key,
// scrollbar drag straight to the bottom).
const isNearEnd = this.isNearEnd();
this.isWithinNearEndThreshold = isNearEnd;
if (!isNearEnd || !this.canLoad()) {
return;
}
// Terminate on lack of progress, not on a load count: keep requesting
// while loads grow the container (until it overflows and real scroll
// events take over), stop once they demonstrably stop growing it.
const { scrollHeight } = this.host.nativeElement;
if (
this.lastAutoFillScrollHeight !== null &&
scrollHeight <= this.lastAutoFillScrollHeight
) {
this.autoFillStalls += 1;
} else {
this.autoFillStalls = 0;
}
if (this.autoFillStalls >= MAX_AUTO_FILL_STALLS) {
return;
}
this.lastAutoFillScrollHeight = scrollHeight;
this.infiniteLoadMore.emit();
}
private isNearEnd(): boolean {
const { scrollHeight, scrollTop, clientHeight } =
this.host.nativeElement;
return scrollHeight - scrollTop - clientHeight <= NEAR_END_THRESHOLD_PX;
}
private canLoad(): boolean {
return this.infiniteHasMore() && !this.infiniteAppending();
}
}
+1
View File
@@ -26,6 +26,7 @@ export * from './lib/navigation/provider-detail-mode';
export * from './lib/navigation/portal-route.utils';
export * from './lib/navigation/workspace-portal-navigation';
export * from './lib/collection';
export * from './lib/xtream-catchup.util';
export * from './lib/external-playback-button-state';
export * from './lib/inline-playback-position-writer';
export * from './lib/downloads/download-metadata-snapshot';
@@ -40,6 +40,12 @@ export interface UnifiedFavoriteChannel {
/** Xtream numeric stream ID (Xtream channels only) */
xtreamId?: number;
/** Provider catch-up flag (Xtream live channels only; 1 = archive) */
tvArchive?: number | null;
/** Provider catch-up window in days (Xtream live channels only) */
tvArchiveDuration?: number | null;
/**
* TVG id for M3U EPG lookup.
* For Xtream channels, also used for short-EPG lookup.
@@ -3,7 +3,10 @@ import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { createExternalPlaybackButtonState } from './external-playback-button-state';
import {
createExternalPlaybackButtonState,
isLiveExternalPlayerSession,
} from './external-playback-button-state';
function session(
overrides: Partial<ExternalPlayerSession> = {}
@@ -105,6 +108,21 @@ describe('createExternalPlaybackButtonState', () => {
}
);
it('offers Stop for an errored session whose process may still be alive', () => {
const { api } = setup(
session({
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
})
);
expect(api.matchedSession()).not.toBeNull();
expect(api.buttonState()).toBe('stop');
expect(api.primaryLabel()).toBe('Stop MPV');
expect(api.isStopAction()).toBe(true);
});
it('reports a launching session', () => {
const { api } = setup(session({ status: 'launching' }));
@@ -208,3 +226,30 @@ describe('createExternalPlaybackButtonState', () => {
expect(api.matchedSession()).toBeNull();
});
});
describe('isLiveExternalPlayerSession', () => {
it('keeps ownership while a teardown error can still be stopped', () => {
expect(
isLiveExternalPlayerSession(
session({
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
})
)
).toBe(true);
});
it('treats an unclosable error and a confirmed close as terminal', () => {
expect(
isLiveExternalPlayerSession(
session({ status: 'error', canClose: false })
)
).toBe(false);
expect(
isLiveExternalPlayerSession(
session({ status: 'closed', canClose: false })
)
).toBe(false);
});
});
@@ -48,6 +48,21 @@ export interface ExternalPlaybackButtonStateApi {
buttonState: Signal<ExternalPlaybackButtonState>;
}
/**
* An error can still own a real process when bounded teardown could not
* confirm its exit. Keep that session live until Stop succeeds; only a
* confirmed close or an unclosable failure is terminal.
*/
export function isLiveExternalPlayerSession(
session: ExternalPlayerSession | null | undefined
): session is ExternalPlayerSession {
return (
!!session &&
session.status !== 'closed' &&
(session.status !== 'error' || session.canClose)
);
}
export function createExternalPlaybackButtonState(
config: ExternalPlaybackButtonStateConfig
): ExternalPlaybackButtonStateApi {
@@ -56,11 +71,7 @@ export function createExternalPlaybackButtonState(
const matchedSession = computed(() => {
const session = config.session();
// A closed or errored session says nothing about what is playing now.
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
) {
if (!session?.contentInfo || !isLiveExternalPlayerSession(session)) {
return null;
}
@@ -92,6 +103,7 @@ export function createExternalPlaybackButtonState(
return `Opening in ${player}...`;
case 'opened':
case 'playing':
case 'error':
return `Stop ${player}`;
default:
return null;
@@ -104,6 +116,7 @@ export function createExternalPlaybackButtonState(
return 'hourglass_top';
case 'opened':
case 'playing':
case 'error':
return 'stop_circle';
default:
return 'play_arrow';
@@ -116,7 +129,9 @@ export function createExternalPlaybackButtonState(
const isStopAction = computed(() => {
const status = matchedSession()?.status;
return status === 'opened' || status === 'playing';
return (
status === 'opened' || status === 'playing' || status === 'error'
);
});
const buttonState = computed<ExternalPlaybackButtonState>(() => {
@@ -33,19 +33,48 @@ export interface PortalCatalogFacade<
TSelectedItem = unknown,
> {
readonly provider: PortalCatalogProvider;
readonly pageSizeOptions: readonly number[];
readonly contentType: Signal<string | null | undefined>;
readonly limit: Signal<number>;
readonly pageIndex: Signal<number>;
readonly selectedCategory: Signal<TCategory | null | undefined>;
readonly paginatedContent: Signal<readonly TItem[] | undefined>;
readonly selectedItem: Signal<TSelectedItem | null | undefined>;
readonly totalPages: Signal<number>;
readonly isPaginatedContentLoading: Signal<boolean>;
readonly selectedCategoryTitle: Signal<string>;
readonly categoryItemCount: Signal<number>;
readonly contentSortMode: Signal<PortalCatalogSortMode | null>;
readonly playlist: Signal<PortalCatalogPlaylistMeta | null>;
/**
* Infinite-scroll capability. `true` means the facade grows one continuous
* list via `loadMore()` and the catalog view renders no paginator.
*
* Transitional (PR 1 of the pagination removal): Xtream sets `true`;
* Stalker still pages and leaves it unset. Once Stalker appends too, this
* flag and the paged members below are deleted and the infinite-scroll
* members become required.
*/
readonly supportsInfiniteScroll?: boolean;
readonly hasMore?: Signal<boolean>;
/** True while an asynchronous append is in flight (tail spinner). */
readonly isAppending?: Signal<boolean>;
/** True when the latest append failed; the tail shows a retry action. */
readonly appendError?: Signal<boolean>;
loadMore?(): void;
retryAppend?(): void;
/**
* Scroll-position handoff for detail round-trips: the view saves the grid
* offset when an item opens, and consumes it (the facade restores the
* matching list window first) when the same list is shown again. Returns
* null when the saved position no longer matches the current selection.
*/
saveScrollPosition?(scrollTop: number): void;
consumeSavedScrollPosition?(): number | null;
/**
* Legacy paged members — only implemented while `supportsInfiniteScroll`
* is not `true` (Stalker during the transition). Deleted in PR 2.
*/
readonly pageSizeOptions?: readonly number[];
readonly limit?: Signal<number>;
readonly pageIndex?: Signal<number>;
readonly totalPages?: Signal<number>;
/**
* Optional IMDb-rating capability (Xtream VOD/series). Providers without
* structured ratings (e.g. Stalker) leave these undefined, and the rating
@@ -57,8 +86,8 @@ export interface PortalCatalogFacade<
initialize(categoryId?: string | null): void;
setSearchQuery?(query: string): void;
clearSelectedItem(): void;
setPage(page: number): void;
setLimit(limit: number): void;
setPage?(page: number): void;
setLimit?(limit: number): void;
setContentSortMode(mode: PortalCatalogSortMode): void;
setMinRating?(value: number | null): void;
selectItem(item: TItem): string[] | null;
@@ -0,0 +1,82 @@
import {
getXtreamCatchupDays,
isXtreamCatchupAvailable,
} from './xtream-catchup.util';
describe('xtream-catchup.util', () => {
describe('getXtreamCatchupDays', () => {
it('returns the numeric archive window', () => {
expect(
getXtreamCatchupDays({ tv_archive: 1, tv_archive_duration: 7 })
).toBe(7);
});
it('parses string durations sent by some panels', () => {
expect(
getXtreamCatchupDays({
tv_archive: '1',
tv_archive_duration: '3',
})
).toBe(3);
});
it('returns 0 for absent, null, or unparsable values', () => {
expect(getXtreamCatchupDays(undefined)).toBe(0);
expect(getXtreamCatchupDays(null)).toBe(0);
expect(getXtreamCatchupDays({})).toBe(0);
expect(
getXtreamCatchupDays({ tv_archive_duration: null })
).toBe(0);
expect(
getXtreamCatchupDays({ tv_archive_duration: 'soon' })
).toBe(0);
});
it('clamps negative durations to 0', () => {
expect(
getXtreamCatchupDays({ tv_archive_duration: -2 })
).toBe(0);
});
});
describe('isXtreamCatchupAvailable', () => {
it('requires both the flag and a positive window', () => {
expect(
isXtreamCatchupAvailable({
tv_archive: 1,
tv_archive_duration: 7,
})
).toBe(true);
});
it('accepts string field values', () => {
expect(
isXtreamCatchupAvailable({
tv_archive: '1',
tv_archive_duration: '5',
})
).toBe(true);
});
it('rejects a flag without a playable window', () => {
expect(
isXtreamCatchupAvailable({
tv_archive: 1,
tv_archive_duration: 0,
})
).toBe(false);
});
it('rejects channels without the archive flag', () => {
expect(
isXtreamCatchupAvailable({
tv_archive: 0,
tv_archive_duration: 7,
})
).toBe(false);
expect(isXtreamCatchupAvailable({})).toBe(false);
expect(isXtreamCatchupAvailable(null)).toBe(false);
expect(isXtreamCatchupAvailable(undefined)).toBe(false);
});
});
});
@@ -0,0 +1,33 @@
/**
* Provider catch-up (archive/timeshift) availability for Xtream live streams.
*
* The gate mirrors the playback-side checks in `live-stream-layout`
* (`archivePlaybackAvailable`) and `unified-live-tab`
* (`timelineArchiveAvailable`): the provider must flag the channel with
* `tv_archive === 1` AND expose a positive archive window. A bare flag with a
* zero-day window is not playable through the catch-up URL builder, so it
* must not be advertised as available.
*/
export interface XtreamCatchupFields {
readonly tv_archive?: number | string | null;
readonly tv_archive_duration?: number | string | null;
}
/**
* Catch-up window in days, `0` when absent or unparsable. Xtream panels
* report `tv_archive_duration` in days; some send it as a string.
*/
export function getXtreamCatchupDays(
item: XtreamCatchupFields | null | undefined
): number {
return Math.max(0, Number(item?.tv_archive_duration ?? 0) || 0);
}
/** Whether the provider declares playable catch-up for this channel. */
export function isXtreamCatchupAvailable(
item: XtreamCatchupFields | null | undefined
): boolean {
return (
Number(item?.tv_archive ?? 0) === 1 && getXtreamCatchupDays(item) > 0
);
}
@@ -267,10 +267,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
async onVodDownload(item: VodDetailsItem): Promise<void> {
@@ -75,8 +75,6 @@ import {
class="all-items-grid app-scrollbar"
[isLoading]="loading()"
[items]="pagedGridItems()"
[limit]="pageSize()"
[showPaginator]="false"
[searchTerm]="searchTerm()"
[variant]="'logo'"
[type]="'live'"
@@ -113,7 +113,7 @@
[showEpg]="supportsEpg && !isRadioMode()"
[isRadio]="isRadioMode()"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showProgramInfoButton]="true"
[showDetailsContextMenu]="
supportsEpgMapping && !isRadioMode()
"
@@ -543,6 +543,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
const request: PlaybackFallbackRequest = {
player: 'vlc',
playback,
trackLaunch: jest.fn(),
diagnostic: {
code: 'network-error',
player: 'html5',
@@ -560,8 +561,8 @@ describe('StalkerLiveStreamLayoutComponent', () => {
playback,
'vlc'
);
expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe(
playback
expect(request.trackLaunch).toHaveBeenCalledWith(
portalPlayer.openExternalPlayback.mock.results[0].value
);
});
@@ -1394,9 +1394,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
}
@@ -6,6 +6,7 @@
[showSearchInput]="!isWorkspaceLayout"
[showBackButton]="isWorkspaceLayout"
[showDetails]="showingDetails"
[nearEndHasMore]="false"
(searchTermChange)="updateSearchTerm($event)"
(backClick)="goBack()"
>
@@ -33,6 +33,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { SafePipe } from '@iptvnator/pipes';
import {
isLiveExternalPlayerSession,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -438,7 +439,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return;
}
if (session.status === 'opened' || session.status === 'playing') {
if (isLiveExternalPlayerSession(session)) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
@@ -900,10 +901,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
playPreviousEpisode(): void {
@@ -153,9 +153,11 @@ export class StalkerVodPlaybackController {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.config.portalPlayer.openExternalPlayback(
const launch = this.config.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
}
@@ -1,6 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { withSelection } from './with-selection.feature';
import {
CATALOG_INITIAL_WINDOW,
CATALOG_WINDOW_CHUNK,
withSelection,
} from './with-selection.feature';
/** A category larger than two render windows, for window-growth tests. */
const BULK_CATEGORY_SIZE = CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK + 20;
const bulkVodStreams = Array.from({ length: BULK_CATEGORY_SIZE }, (_, i) => ({
xtream_id: 1000 + i,
category_id: '90',
title: `Bulk ${i + 1}`,
added: String(1000 - i),
}));
const TestSelectionStore = signalStore(
withState({
@@ -53,6 +66,12 @@ const TestSelectionStore = signalStore(
category_name: 'Documentaries',
type: 'vod',
},
{
id: 90,
category_id: '90',
category_name: 'Bulk',
type: 'vod',
},
],
vodStreams: [
{
@@ -91,6 +110,7 @@ const TestSelectionStore = signalStore(
title: 'Cosmos',
added: '6',
},
...bulkVodStreams,
],
serialCategories: [
{
@@ -140,8 +160,6 @@ describe('withSelection', () => {
let store: InstanceType<typeof TestSelectionStore>;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [TestSelectionStore],
});
@@ -149,37 +167,106 @@ describe('withSelection', () => {
store = TestBed.inject(TestSelectionStore);
});
afterEach(() => {
localStorage.clear();
it('reveals the first window and grows it with loadMoreContent', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(90);
expect(store.getPaginatedContent().length).toBe(
CATALOG_INITIAL_WINDOW
);
expect(store.hasMoreContent()).toBe(true);
store.loadMoreContent();
expect(store.getPaginatedContent().length).toBe(
CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK
);
store.loadMoreContent();
expect(store.getPaginatedContent().length).toBe(BULK_CATEGORY_SIZE);
expect(store.hasMoreContent()).toBe(false);
// No-op once the window covers everything.
const coveredCount = store.visibleCount();
store.loadMoreContent();
expect(store.visibleCount()).toBe(coveredCount);
});
it('keeps the current page when the category search term is unchanged', () => {
it('keeps the render window when the category search term is unchanged', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(10);
store.setLimit(2);
store.setPage(1);
store.setSelectedCategory(90);
store.loadMoreContent();
store.setCategorySearchTerm('');
expect(store.page()).toBe(1);
expect(store.getPaginatedContent().map((item) => item.title)).toEqual([
'Third',
'Fourth',
]);
expect(store.visibleCount()).toBe(
CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK
);
});
it('resets the current page when the category search term changes', () => {
it('resets the render window when the category search term changes', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(90);
store.loadMoreContent();
store.setCategorySearchTerm('bulk');
expect(store.visibleCount()).toBe(CATALOG_INITIAL_WINDOW);
expect(store.getPaginatedContent().length).toBe(
CATALOG_INITIAL_WINDOW
);
});
it('resets the render window when the category changes', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(90);
store.loadMoreContent();
store.setSelectedCategory(10);
store.setLimit(2);
store.setPage(1);
store.setCategorySearchTerm('first');
expect(store.visibleCount()).toBe(CATALOG_INITIAL_WINDOW);
});
expect(store.page()).toBe(0);
expect(store.getPaginatedContent().map((item) => item.title)).toEqual([
'First',
]);
it('keeps per-selection snapshots so a tab detour cannot overwrite them', () => {
// Regression: VOD (scrolled) → Series → back to VOD. The series view
// saves its own spot on destroy; with a single slot that save used to
// replace the VOD snapshot and the round trip lost the position.
store.setSelectedContentType('vod');
store.setSelectedCategory(90);
store.loadMoreContent();
store.saveCatalogScrollState(1234);
store.setSelectedContentType('series');
store.saveCatalogScrollState(0);
store.setSelectedContentType('vod');
store.setSelectedCategory(90);
expect(store.consumeCatalogScrollState()).toBe(1234);
expect(store.visibleCount()).toBe(
CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK
);
// The series snapshot survived too, under its own identity.
store.setSelectedContentType('series');
expect(store.consumeCatalogScrollState()).toBe(0);
});
it('restores a saved scroll state only for the matching selection', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(90);
store.loadMoreContent();
store.saveCatalogScrollState(1234);
// A detour to another category must not consume the slot.
store.setSelectedCategory(10);
expect(store.consumeCatalogScrollState()).toBeNull();
// Returning to the saved selection restores window + offset once.
store.setSelectedCategory(90);
expect(store.consumeCatalogScrollState()).toBe(1234);
expect(store.visibleCount()).toBe(
CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK
);
expect(store.consumeCatalogScrollState()).toBeNull();
});
it('filters all VOD items when no category is selected', () => {
@@ -26,14 +26,49 @@ export type XtreamCategorySortMode =
| 'rating-asc';
/**
* Selection state for managing UI selection and pagination
* First render window of the infinite-scroll catalog grid. Growing the window
* is a free in-memory slice, so the initial batch is a generous constant — the
* shared scroll directive tops it up by measuring container overflow rather
* than by computing card counts from the viewport.
*/
export const CATALOG_INITIAL_WINDOW = 50;
/** How many more items each `loadMoreContent()` reveals. */
export const CATALOG_WINDOW_CHUNK = 50;
/**
* Grid scroll position captured when a list view goes away (detail opened,
* tab switched), so returning to the same list restores both the render
* window and the scroll offset. The selection coordinates identify the
* snapshot: only an exact match may restore it.
*/
export interface CatalogScrollState {
contentType: ContentType;
categoryId: number | null;
searchTerm: string;
sortMode: XtreamCategorySortMode;
minRating: number | null;
visibleCount: number;
scrollTop: number;
}
/**
* Snapshots are kept per selection identity (one slot would let a tab detour
* — VOD → Series → VOD — overwrite the first tab's spot with the second's on
* destroy). Bounded so a long browsing session cannot accumulate one entry
* per category visited.
*/
const MAX_SAVED_CATALOG_SCROLLS = 8;
/**
* Selection state for managing UI selection and the infinite-scroll window
*/
export interface SelectionState {
selectedContentType: ContentType;
selectedCategoryId: number | null;
selectedItem: XtreamSelectionItem | null;
page: number;
limit: number;
visibleCount: number;
savedCatalogScrolls: CatalogScrollState[];
contentSortMode: XtreamCategorySortMode;
categorySearchTerm: string;
minRating: number | null;
@@ -48,8 +83,8 @@ const initialSelectionState: SelectionState = {
selectedContentType: 'vod',
selectedCategoryId: null,
selectedItem: null,
page: 0,
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
visibleCount: CATALOG_INITIAL_WINDOW,
savedCatalogScrolls: [],
contentSortMode: 'date-desc',
categorySearchTerm: '',
minRating: null,
@@ -57,6 +92,16 @@ const initialSelectionState: SelectionState = {
detailsError: null,
};
const matchesCurrentSelection = (
snapshot: CatalogScrollState,
current: Omit<CatalogScrollState, 'visibleCount' | 'scrollTop'>
): boolean =>
snapshot.contentType === current.contentType &&
snapshot.categoryId === current.categoryId &&
snapshot.searchTerm === current.searchTerm &&
snapshot.sortMode === current.sortMode &&
snapshot.minRating === current.minRating;
interface XtreamSelectionCategory {
readonly [key: string]: unknown;
readonly category_name?: string;
@@ -174,12 +219,12 @@ export const filterByMinRating = (
};
/**
* Selection feature store for managing UI selection and pagination.
* Handles:
* Selection feature store for managing UI selection and the infinite-scroll
* render window. Handles:
* - Content type selection (live, vod, series)
* - Category selection
* - Item selection
* - Pagination (page, limit)
* - Infinite-scroll window (visibleCount) + detail-round-trip scroll restore
*/
export function withSelection() {
return signalStoreFeature(
@@ -431,17 +476,13 @@ export function withSelection() {
}),
/**
* Get paginated content for the selected category.
* Slices from the stable `filteredAndSortedContent` intermediate so
* page navigation never triggers a full re-sort of the array.
* The visible slice of the selected category — the first
* `visibleCount` items of the stable `filteredAndSortedContent`
* intermediate, so growing the window never re-sorts the array.
*/
getPaginatedContent: computed(() => {
const start = store.page() * store.limit();
return filteredAndSortedContent().slice(
start,
start + store.limit()
);
}),
getPaginatedContent: computed(() =>
filteredAndSortedContent().slice(0, store.visibleCount())
),
/**
* Get all items from the selected category (without pagination).
@@ -453,11 +494,12 @@ export function withSelection() {
),
/**
* Get total pages for the selected category.
* Derives length from the shared `filteredAndSortedContent` intermediate.
* Whether the filtered list extends beyond the current render
* window.
*/
getTotalPages: computed(() =>
Math.ceil(filteredAndSortedContent().length / store.limit())
hasMoreContent: computed(
() =>
filteredAndSortedContent().length > store.visibleCount()
),
/**
@@ -518,7 +560,7 @@ export function withSelection() {
patchState(store, {
selectedContentType: type,
selectedCategoryId: null,
page: 0,
visibleCount: CATALOG_INITIAL_WINDOW,
categorySearchTerm: '',
minRating: null,
});
@@ -526,18 +568,18 @@ export function withSelection() {
/**
* Set the selected category
* Only resets page to 0 when category actually changes
* Only resets the render window when the category actually changes
*/
setSelectedCategory(categoryId: number | null): void {
const newCategoryId =
categoryId !== null ? Number(categoryId) : null;
const currentCategoryId = store.selectedCategoryId();
// Only reset page if category actually changed
// Only reset the window if the category actually changed
if (currentCategoryId !== newCategoryId) {
patchState(store, {
selectedCategoryId: newCategoryId,
page: 0,
visibleCount: CATALOG_INITIAL_WINDOW,
categorySearchTerm: '',
// Clear the rating filter on category change too, mirroring
// categorySearchTerm and setSelectedContentType — otherwise a
@@ -569,18 +611,82 @@ export function withSelection() {
},
/**
* Set the current page
* Reveal the next chunk of the filtered list. No-op once the
* window already covers everything.
*/
setPage(page: number): void {
patchState(store, { page });
loadMoreContent(): void {
const total = store.selectItemsFromSelectedCategory().length;
if (store.visibleCount() >= total) {
return;
}
patchState(store, {
visibleCount: store.visibleCount() + CATALOG_WINDOW_CHUNK,
});
},
/**
* Set the page limit (items per page)
* Capture the grid scroll offset together with the selection
* coordinates it belongs to. One snapshot per selection identity:
* re-saving the same list replaces its entry, saving another list
* (a tab detour's destroy hook) leaves it intact, and the oldest
* entry falls out past the bound.
*/
setLimit(limit: number): void {
patchState(store, { limit });
localStorage.setItem('xtream-page-size', String(limit));
saveCatalogScrollState(scrollTop: number): void {
const snapshot: CatalogScrollState = {
contentType: store.selectedContentType(),
categoryId: store.selectedCategoryId(),
searchTerm: store.categorySearchTerm(),
sortMode: store.contentSortMode(),
minRating: store.minRating(),
visibleCount: store.visibleCount(),
scrollTop,
};
patchState(store, {
savedCatalogScrolls: [
...store
.savedCatalogScrolls()
.filter(
(saved) =>
!matchesCurrentSelection(saved, snapshot)
),
snapshot,
].slice(-MAX_SAVED_CATALOG_SCROLLS),
});
},
/**
* If a snapshot exists for the current selection, restore its
* render window, remove it, and return the scroll offset to
* re-apply. Returns null (leaving other snapshots intact)
* otherwise, so a detour through another list never destroys a
* saved spot.
*/
consumeCatalogScrollState(): number | null {
const current = {
contentType: store.selectedContentType(),
categoryId: store.selectedCategoryId(),
searchTerm: store.categorySearchTerm(),
sortMode: store.contentSortMode(),
minRating: store.minRating(),
};
const saved = store
.savedCatalogScrolls()
.find((snapshot) =>
matchesCurrentSelection(snapshot, current)
);
if (!saved) {
return null;
}
patchState(store, {
visibleCount: saved.visibleCount,
savedCatalogScrolls: store
.savedCatalogScrolls()
.filter((snapshot) => snapshot !== saved),
});
return saved.scrollTop;
},
/**
@@ -592,7 +698,7 @@ export function withSelection() {
}
patchState(store, {
contentSortMode: mode,
page: 0,
visibleCount: CATALOG_INITIAL_WINDOW,
});
},
@@ -607,7 +713,7 @@ export function withSelection() {
}
patchState(store, {
minRating: normalized,
page: 0,
visibleCount: CATALOG_INITIAL_WINDOW,
});
},
@@ -621,7 +727,7 @@ export function withSelection() {
patchState(store, {
categorySearchTerm: term,
page: 0,
visibleCount: CATALOG_INITIAL_WINDOW,
});
},
@@ -629,12 +735,7 @@ export function withSelection() {
* Reset selection state
*/
resetSelection(): void {
patchState(store, {
...initialSelectionState,
limit: Number(
localStorage.getItem('xtream-page-size') ?? 25
),
});
patchState(store, initialSelectionState);
},
}))
);
@@ -137,26 +137,16 @@
{{ liveRootSubtitle() }}
</div>
</div>
@if (showLiveRootPaginator()) {
<mat-paginator
[pageIndex]="liveRootPageIndex()"
[length]="liveRootItemCount()"
[pageSize]="liveRootLimit()"
[pageSizeOptions]="liveRootPageSizeOptions"
(page)="onLiveRootPageChange($event)"
aria-label="Select page"
/>
}
</div>
<app-grid-list
class="live-all-items-grid app-scrollbar"
appInfiniteScroll
[infiniteHasMore]="liveRootHasMore()"
[infiniteItemCount]="liveRootItems().length"
[infiniteResetKey]="'live-root'"
(infiniteLoadMore)="onLiveRootLoadMore()"
[isLoading]="isSelectedTypeContentLoading()"
[items]="liveRootItems()"
[pageIndex]="liveRootPageIndex()"
[totalPages]="liveRootTotalPages()"
[limit]="liveRootLimit()"
[pageSizeOptions]="liveRootPageSizeOptions"
[showPaginator]="false"
[searchTerm]="workspaceSearchTerm()"
[variant]="'logo'"
[type]="'live'"
@@ -42,7 +42,6 @@ import { GridListComponent } from '@iptvnator/portal/shared/ui';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { PageEvent } from '@angular/material/paginator';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -68,16 +67,13 @@ class StubPortalChannelsListComponent {
class StubGridListComponent {
readonly items = input<unknown[]>([]);
readonly isLoading = input(false);
readonly showPaginator = input(true);
readonly isAppending = input(false);
readonly appendError = input(false);
readonly searchTerm = input('');
readonly pageIndex = input(0);
readonly totalPages = input(0);
readonly limit = input(25);
readonly pageSizeOptions = input<number[]>([]);
readonly variant = input<'poster' | 'logo'>('poster');
readonly type = input<string>();
readonly itemClicked = output<unknown>();
readonly pageChange = output<PageEvent>();
readonly retryLoadMore = output<void>();
}
@Component({
@@ -163,15 +159,13 @@ describe('LiveStreamLayoutComponent', () => {
const currentPlaylist = signal(playlist);
const liveStreams = signal<unknown[]>([]);
const paginatedContent = signal<unknown[]>([]);
const totalPages = signal(0);
const page = signal(0);
const limit = signal(25);
const hasMoreContent = signal(false);
const xtreamStore = {
getCategoriesBySelectedType: categories,
getCategoryItemCounts: categoryItemCounts,
getPaginatedContent: paginatedContent,
getTotalPages: totalPages,
hasMoreContent,
epgItems,
currentEpgItem,
isLoadingEpg,
@@ -181,15 +175,12 @@ describe('LiveStreamLayoutComponent', () => {
selectedItem,
currentPlaylist,
liveStreams,
page,
limit,
selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]),
constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'),
openPlayer: jest.fn(),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
setPage: jest.fn((nextPage: number) => page.set(nextPage)),
setLimit: jest.fn((nextLimit: number) => limit.set(nextLimit)),
loadMoreContent: jest.fn(),
};
let routerEvents: Subject<unknown>;
@@ -237,16 +228,13 @@ describe('LiveStreamLayoutComponent', () => {
xtreamStore.openPlayer.mockClear();
xtreamStore.setSelectedItem.mockClear();
xtreamStore.setSelectedCategory.mockClear();
xtreamStore.setPage.mockClear();
xtreamStore.setLimit.mockClear();
xtreamStore.loadMoreContent.mockClear();
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([
sampleChannel,
]);
liveStreams.set([]);
paginatedContent.set([]);
totalPages.set(0);
page.set(0);
limit.set(25);
hasMoreContent.set(false);
favoritesService.getFavorites.mockClear();
xtreamUrlService.resolveCatchupUrl.mockClear();
portalPlayer.isEmbeddedPlayer.mockReset();
@@ -497,7 +485,7 @@ describe('LiveStreamLayoutComponent', () => {
).toBeNull();
});
it('shows live all-items content with the shared grid and header paginator before a category is selected', () => {
it('shows live all-items content with the shared grid and no paginator before a category is selected', () => {
const firstChannel = {
xtream_id: 301,
name: 'First Channel',
@@ -524,9 +512,7 @@ describe('LiveStreamLayoutComponent', () => {
};
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
limit.set(25);
page.set(0);
totalPages.set(2);
hasMoreContent.set(true);
paginatedContent.set([firstChannel, secondChannel]);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([
firstChannel,
@@ -546,13 +532,6 @@ describe('LiveStreamLayoutComponent', () => {
]);
expect(grid.componentInstance.variant()).toBe('logo');
expect(grid.componentInstance.type()).toBe('live');
expect(grid.componentInstance.showPaginator()).toBe(false);
expect(grid.componentInstance.pageIndex()).toBe(0);
expect(grid.componentInstance.totalPages()).toBe(2);
expect(grid.componentInstance.limit()).toBe(25);
expect(grid.componentInstance.pageSizeOptions()).toEqual([
10, 25, 50, 100,
]);
expect(
fixture.nativeElement.querySelector('.category-title').textContent
).toContain('All Items');
@@ -562,7 +541,7 @@ describe('LiveStreamLayoutComponent', () => {
).toContain('3 channels');
expect(
fixture.nativeElement.querySelector('mat-paginator')
).not.toBeNull();
).toBeNull();
expect(
fixture.debugElement.query(
By.directive(StubPortalChannelsListComponent)
@@ -646,6 +625,7 @@ describe('LiveStreamLayoutComponent', () => {
component.handleExternalFallbackRequest({
player: 'mpv',
playback,
trackLaunch: jest.fn(),
} as PlaybackFallbackRequest);
const [forwardedPlayback, forwardedPlayer] =
@@ -654,9 +634,10 @@ describe('LiveStreamLayoutComponent', () => {
expect(forwardedPlayer).toBe('mpv');
});
it('updates live root pagination from the header paginator', () => {
it('delegates live root loadMore to the store window', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
hasMoreContent.set(true);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue(
Array.from({ length: 80 }, (_, index) => ({
xtream_id: index + 1,
@@ -667,33 +648,9 @@ describe('LiveStreamLayoutComponent', () => {
fixture.detectChanges();
const paginator = fixture.debugElement.query(By.css('mat-paginator'));
expect(paginator).not.toBeNull();
paginator.triggerEventHandler('page', {
pageIndex: 1,
pageSize: 50,
length: 80,
previousPageIndex: 0,
} satisfies PageEvent);
component.onLiveRootLoadMore();
expect(xtreamStore.setPage).toHaveBeenCalledWith(1);
expect(xtreamStore.setLimit).toHaveBeenCalledWith(50);
expect(router.navigate).toHaveBeenCalledWith([], {
relativeTo: expect.any(Object),
queryParams: { page: 2 },
queryParamsHandling: 'merge',
replaceUrl: true,
});
});
it('applies the live root page query parameter', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
routeQueryParamMap.next(convertToParamMap({ page: '3' }));
fixture.detectChanges();
expect(xtreamStore.setPage).toHaveBeenCalledWith(2);
expect(xtreamStore.loadMoreContent).toHaveBeenCalledTimes(1);
});
it('shows the cross-category live channel list while searching from the live root', () => {
@@ -17,12 +17,12 @@ import { filter } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
GridListComponent,
InfiniteScrollDirective,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
@@ -98,11 +98,11 @@ interface XtreamLiveChannelItem {
MatButtonModule,
MatIcon,
MatMenuModule,
MatPaginatorModule,
MatProgressSpinnerModule,
MatTooltipModule,
NgTemplateOutlet,
GridListComponent,
InfiniteScrollDirective,
PortalChannelsListComponent,
PortalEmptyStateComponent,
ResizableDirective,
@@ -144,11 +144,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
'q',
(value) => (value ?? '').trim()
);
private readonly routePageIndex = queryParamSignal(
this.route,
'page',
(value) => this.toPageIndex(value)
);
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
@@ -230,7 +225,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly liveChannelSortLabel = computed(() =>
getPortalChannelSortModeLabel(this.liveChannelSortMode())
);
readonly liveRootPageSizeOptions = [10, 25, 50, 100];
readonly liveRootItems = computed(
() =>
this.xtreamStore.getPaginatedContent() as unknown as Record<
@@ -245,12 +239,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
const count = this.liveRootItemCount();
return `${count} ${count === 1 ? 'channel' : 'channels'}`;
});
readonly liveRootPageIndex = this.xtreamStore.page;
readonly liveRootLimit = this.xtreamStore.limit;
readonly liveRootTotalPages = this.xtreamStore.getTotalPages;
readonly showLiveRootPaginator = computed(
() => this.liveRootItemCount() > 0
);
readonly liveRootHasMore = this.xtreamStore.hasMoreContent;
readonly selectedCategoryInfo = computed(() => {
const categoryId = this.selectedCategoryId();
@@ -298,20 +287,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
onCleanup(() => clearInterval(intervalId));
});
effect(() => {
if (
this.xtreamStore.selectedContentType() !== 'live' ||
this.showLiveChannelSidebar()
) {
return;
}
const pageIndex = this.routePageIndex();
if (this.liveRootPageIndex() !== pageIndex) {
this.xtreamStore.setPage(pageIndex);
}
});
// Read pending auto-open state on every NavigationEnd — covers both the
// initial navigation (Angular fires NavigationEnd after component creation)
// and re-navigation to the same /live route when the component is reused.
@@ -488,19 +463,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
persistPortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY, mode);
}
onLiveRootPageChange(event: PageEvent): void {
this.xtreamStore.setPage(event.pageIndex);
this.xtreamStore.setLimit(event.pageSize);
this.scrollLiveRootGridToTop();
void this.router.navigate([], {
relativeTo: this.route,
queryParams: {
page: event.pageIndex > 0 ? event.pageIndex + 1 : null,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
onLiveRootLoadMore(): void {
this.xtreamStore.loadMoreContent();
}
onLiveRootItemClick(item: unknown): void {
@@ -601,28 +565,18 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
private getAllLiveStreams(): XtreamLiveChannelItem[] {
return this.xtreamStore.liveStreams() as unknown as XtreamLiveChannelItem[];
}
private toPageIndex(value: string | null): number {
const page = Number(value);
return Number.isInteger(page) && page > 0 ? page - 1 : 0;
}
private scrollLiveRootGridToTop(): void {
const gridList = this.hostElement.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement | null;
gridList?.scrollTo?.({ top: 0 });
}
private getVisibleChannels(): XtreamLiveChannelItem[] {
return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[];
}
@@ -42,8 +42,10 @@
: 0
"
[showEpg]="supportsEpg"
[catchupAvailable]="isCatchupAvailable(item)"
[catchupDays]="catchupDays(item)"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showProgramInfoButton]="true"
[showDetailsContextMenu]="supportsEpgMapping"
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
(clicked)="playClicked.emit(item)"
@@ -38,6 +38,8 @@ import {
EpgMappingDialogComponent,
} from '@iptvnator/ui/components';
import {
getXtreamCatchupDays,
isXtreamCatchupAvailable,
PortalChannelSortMode,
sortPortalChannelItems,
} from '@iptvnator/portal/shared/util';
@@ -57,6 +59,8 @@ export interface XtreamChannelListItem {
readonly type?: 'live' | 'movie' | 'series' | 'vod';
readonly xtream_id: number;
readonly epg_channel_id?: string | null;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | string | null;
}
interface XtreamCategoryLike {
@@ -193,6 +197,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
return item.xtream_id;
}
protected readonly isCatchupAvailable = isXtreamCatchupAvailable;
protected readonly catchupDays = getXtreamCatchupDays;
ngOnInit(): void {
const { categoryId } = this.route.snapshot.params;
if (categoryId && !this.channelsOverride())
@@ -8,10 +8,14 @@
[showSearchInput]="showInlineSearchInput"
[minSearchLength]="minSearchLength"
[initialDescriptionKey]="initialDescriptionKey"
[nearEndHasMore]="hasMoreSearchResults()"
[nearEndAppending]="isAppendingSearchResults()"
[nearEndRenderedCount]="renderedSearchResultsCount()"
[nearEndResetKey]="searchResetIdentity()"
(searchTermChange)="updateSearchTerm($event)"
(closeClick)="onCloseDialog()"
(backClick)="goBack()"
(nearEnd)="loadMoreGlobalResults()"
(nearEnd)="onResultsNearEnd()"
>
<!-- Type filters -->
<ng-container filters>
@@ -155,9 +159,9 @@
</div>
}
} @else {
<!-- Playlist-level Search Results -->
<!-- Playlist-level Search Results (windowed; grows via nearEnd) -->
<div class="results-grid">
@for (item of xtreamStore.searchResults(); track item.id) {
@for (item of visibleInPortalResults(); track item.id) {
<app-content-card
[posterUrl]="item.poster_url ?? undefined"
[title]="item.title"
@@ -368,6 +368,10 @@ describe('SearchResultsComponent initialQuery contract', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchResults.set([createSearchItem()]);
component.hasMoreGlobalResults.set(true);
// Appends only continue the last executed search.
(
component as unknown as { lastGlobalSearchTerm: string }
).lastGlobalSearchTerm = 'matrix';
const appendPromise = component.searchGlobal(
'matrix',
@@ -418,6 +422,10 @@ describe('SearchResultsComponent initialQuery contract', () => {
}
).globalSearchResults = signal([]);
component.hasMoreGlobalResults.set(true);
// Appends only continue the last executed search.
(
component as unknown as { lastGlobalSearchTerm: string }
).lastGlobalSearchTerm = 'matrix';
await component.searchGlobal('matrix', ['movie'], false, true);
@@ -433,4 +441,142 @@ describe('SearchResultsComponent initialQuery contract', () => {
);
expect(store.searchResults()).toHaveLength(2);
});
it('refuses to append pages while an edited query is still debouncing', async () => {
// Regression: the auto-fill can request more results right after the
// reset identity changes, i.e. inside the 300ms search debounce. An
// append with the edited term would fetch a page of the NEW query at
// the OLD offset and interleave it into the old query's results.
const firstPage = Array.from({ length: 101 }, (_, index) =>
createSearchItem({
id: index + 1,
title: `Result ${index + 1}`,
xtream_id: index + 1,
})
);
const databaseService = TestBed.inject(DatabaseService) as {
globalSearchContent: jest.Mock;
};
databaseService.globalSearchContent.mockResolvedValueOnce(firstPage);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
undefined
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
await component.searchGlobal('matrix', ['movie'], false);
expect(component.hasMoreGlobalResults()).toBe(true);
databaseService.globalSearchContent.mockClear();
// The user edits the query; the fresh offset-zero search has not run
// yet (debounce pending), so appending must be refused entirely.
store.searchTerm.set('matrix reloaded');
await component.loadMoreGlobalResults();
expect(databaseService.globalSearchContent).not.toHaveBeenCalled();
expect(component.isLoadingMoreGlobalResults()).toBe(false);
});
});
describe('SearchResultsComponent in-portal result window', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: XtreamStore,
useClass: MockXtreamStore,
},
{
provide: Router,
useValue: { navigate: jest.fn() },
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { layout: 'workspace' },
queryParamMap: convertToParamMap({ q: '' }),
},
queryParamMap: of(convertToParamMap({ q: '' })),
},
},
{
provide: DatabaseService,
useValue: {
globalSearchContent: jest.fn().mockResolvedValue([]),
},
},
],
});
});
it('windows the full result set, reveals further chunks, and resets on a new result set', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const component = TestBed.runInInjectionContext(
() => new SearchResultsComponent(null, undefined)
);
const items = Array.from({ length: 130 }, (_, index) =>
createSearchItem({
id: index + 1,
xtream_id: index + 1,
title: `Item ${index + 1}`,
})
);
store.searchResults.set(items);
expect(component.isGlobalSearch).toBe(false);
expect(component.visibleInPortalResults()).toHaveLength(60);
expect(component.renderedSearchResultsCount()).toBe(60);
expect(component.hasMoreSearchResults()).toBe(true);
component.onResultsNearEnd();
expect(component.visibleInPortalResults()).toHaveLength(120);
// The layout re-measures on the RENDERED count — it must follow the
// window, not the constant 130-item total.
expect(component.renderedSearchResultsCount()).toBe(120);
component.onResultsNearEnd();
expect(component.visibleInPortalResults()).toHaveLength(130);
expect(component.hasMoreSearchResults()).toBe(false);
// Growing past the total is a no-op.
component.onResultsNearEnd();
expect(component.visibleInPortalResults()).toHaveLength(130);
// A replaced result set resets the window to the first chunk.
store.searchResults.set(items.slice(0, 90));
expect(component.visibleInPortalResults()).toHaveLength(60);
expect(component.hasMoreSearchResults()).toBe(true);
});
it('changes the reset identity on filter-only transitions', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const component = TestBed.runInInjectionContext(
() => new SearchResultsComponent(null, undefined)
);
const initialIdentity = component.searchResetIdentity();
// Same term, different type filters — the result set is replaced, so
// the identity feeding the layout's near-end latch must change.
store.searchFilters.set({ live: false, movie: true, series: true });
expect(component.searchResetIdentity()).not.toBe(initialIdentity);
const filteredIdentity = component.searchResetIdentity();
try {
component.toggleExcludeHidden(true);
expect(component.searchResetIdentity()).not.toBe(
filteredIdentity
);
} finally {
// toggleExcludeHidden persists; do not leak into other tests.
localStorage.removeItem('xtream-search-exclude-hidden');
}
});
});
@@ -6,6 +6,7 @@ import {
effect,
inject,
Inject,
linkedSignal,
Optional,
signal,
viewChild,
@@ -56,6 +57,13 @@ interface GlobalSearchResultGroup {
const GLOBAL_SEARCH_PAGE_SIZE = 100;
/**
* Render window for the in-portal (single playlist) search, which receives its
* complete result set in one response. The window grows via the layout's
* `nearEnd` scroll hook, mirroring the global search's paged appends.
*/
const IN_PORTAL_RESULTS_CHUNK = 60;
function groupResultsByPlaylistId(
items: XtreamSearchResultItem[]
): GlobalSearchResultGroup[] {
@@ -170,6 +178,73 @@ export class SearchResultsComponent implements AfterViewInit {
return groupResultsByPlaylistId(results);
});
/** Resets to the first chunk whenever a new result set replaces the old. */
private readonly inPortalVisibleCount = linkedSignal({
source: () => this.xtreamStore.searchResults(),
computation: () => IN_PORTAL_RESULTS_CHUNK,
});
/** Windowed slice of the in-portal search results. */
readonly visibleInPortalResults = computed(() =>
this.xtreamStore.searchResults().slice(0, this.inPortalVisibleCount())
);
/**
* Feeds the search layout's infinite scroll so its auto-fill can reveal
* further chunks even when the rendered ones do not overflow the viewport.
*/
readonly hasMoreSearchResults = computed(() =>
this.isGlobalSearch
? this.hasMoreGlobalResults()
: this.xtreamStore.searchResults().length >
this.inPortalVisibleCount()
);
readonly isAppendingSearchResults = computed(
() => this.isGlobalSearch && this.isLoadingMoreGlobalResults()
);
/**
* RENDERED result count for the layout's overflow re-measure: the
* windowed slice for in-portal mode (grows chunk by chunk), the full
* loaded set for global mode (server pages append into it).
*/
readonly renderedSearchResultsCount = computed(() =>
this.isGlobalSearch
? this.xtreamStore.searchResults().length
: this.visibleInPortalResults().length
);
/**
* Result-set identity for the layout's near-end latch and auto-fill
* budget. Type filters and the hidden-categories toggle replace the
* result set without changing the term, so they are part of the identity.
*/
readonly searchResetIdentity = computed(() => {
const filters = this.filters();
return [
this.searchTerm(),
String(filters.live),
String(filters.movie),
String(filters.series),
String(this.excludeHidden()),
].join('|');
});
onResultsNearEnd(): void {
if (this.isGlobalSearch) {
void this.loadMoreGlobalResults();
return;
}
const total = this.xtreamStore.searchResults().length;
if (this.inPortalVisibleCount() < total) {
this.inPortalVisibleCount.update(
(count) => count + IN_PORTAL_RESULTS_CHUNK
);
}
}
constructor(
@Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData | null,
@Optional() public dialogRef: MatDialogRef<SearchResultsComponent>
@@ -322,7 +397,12 @@ export class SearchResultsComponent implements AfterViewInit {
append &&
(!this.hasMoreGlobalResults() ||
this.isLoadingMoreGlobalResults() ||
this.xtreamStore.isSearching())
this.xtreamStore.isSearching() ||
// An append continues the LAST EXECUTED search. While an
// edited query is still debouncing, the visible results
// belong to the old term — fetching a new-term page at the
// old offset would interleave two different queries.
trimmedTerm !== this.lastGlobalSearchTerm)
) {
return;
}
@@ -10,6 +10,7 @@ import {
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
isLiveExternalPlayerSession,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -125,7 +126,7 @@ export class SerialDetailsPlaybackService {
return;
}
if (session.status === 'opened' || session.status === 'playing') {
if (isLiveExternalPlayerSession(session)) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
@@ -295,12 +296,14 @@ export class SerialDetailsPlaybackService {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void this.playbackPositionState.recordExternalLaunch(
request.playback,
this.portalPlayer.openExternalPlayback(
request.playback,
request.player
),
launch,
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
@@ -776,8 +776,10 @@ describe('SerialDetailsComponent', () => {
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const trackLaunch = jest.fn();
playbackService.handleExternalFallbackRequest({
player: 'mpv',
trackLaunch,
playback: {
streamUrl: 'http://xtream.example/series/2001.mp4',
title: 'Season 2 Episode 1',
@@ -795,6 +797,9 @@ describe('SerialDetailsComponent', () => {
await fixture.whenStable();
expect(openExternalPlayback).toHaveBeenCalledTimes(1);
expect(trackLaunch).toHaveBeenCalledWith(
openExternalPlayback.mock.results[0].value
);
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
@@ -0,0 +1,88 @@
import { Signal, computed, signal } from '@angular/core';
import type { PlayerContentInfo } from '@iptvnator/shared/interfaces';
interface RouteScopedExternalLaunch {
readonly routePlaylistId: string;
readonly routeContentId: number;
readonly content: PlayerContentInfo;
}
export interface ExternalLaunchRouteIdentity {
readonly playlistId: string;
readonly contentId: number;
}
export interface ExternalLaunchOwner {
readonly current: Signal<PlayerContentInfo | null>;
/** Exact last destination retained for close-before-replacement. */
readonly retained: Signal<PlayerContentInfo | null>;
captureRoute(): ExternalLaunchRouteIdentity | null;
ownsRoute(identity: ExternalLaunchRouteIdentity | null): boolean;
set(content: PlayerContentInfo | null | undefined): void;
clear(): void;
}
/** Retains an external destination only while its initiating VOD route owns it. */
export function createExternalLaunchOwner(
routePlaylistId: () => string | undefined,
routeContentId: () => number | undefined
): ExternalLaunchOwner {
const launch = signal<RouteScopedExternalLaunch | null>(null);
const captureRoute = (): ExternalLaunchRouteIdentity | null => {
const playlistId = routePlaylistId();
const contentId = routeContentId();
return playlistId &&
contentId !== undefined &&
Number.isSafeInteger(contentId) &&
contentId > 0
? { playlistId, contentId }
: null;
};
return {
current: computed(() => {
const value = launch();
return value &&
value.routePlaylistId === routePlaylistId() &&
value.routeContentId === routeContentId()
? value.content
: null;
}),
retained: computed(() => launch()?.content ?? null),
captureRoute,
ownsRoute: (identity) => {
const current = captureRoute();
return (
!!identity &&
current?.playlistId === identity.playlistId &&
current.contentId === identity.contentId
);
},
set: (content) => {
const route = captureRoute();
launch.set(
content && route
? {
routePlaylistId: route.playlistId,
routeContentId: route.contentId,
content,
}
: null
);
},
clear: () => launch.set(null),
};
}
/** Runs a Play/Resume only while its initiating VOD route still owns it. */
export async function startRouteOwnedPlayback(
owner: ExternalLaunchOwner,
start: (isCurrent: () => boolean) => Promise<boolean>
): Promise<boolean> {
const route = owner.captureRoute();
if (!route) return false;
try {
return await start(() => owner.ownsRoute(route));
} catch {
return false;
}
}
@@ -0,0 +1,60 @@
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
interface OwnedExternalLaunchOptions {
launch: Promise<ExternalPlayerSession | void>;
owns: () => boolean;
close: (session: ExternalPlayerSession) => Promise<void>;
warnCloseFailure: (error: unknown) => void;
clearPending: () => void;
clearOwnership: () => void;
}
/** Settles an exact MPV/VLC launch without letting stale results take ownership. */
export async function settleOwnedExternalLaunch(
options: OwnedExternalLaunchOptions
): Promise<boolean> {
try {
const launched = await options.launch;
const accepted =
launched?.status === 'opened' || launched?.status === 'playing';
if (accepted && options.owns()) {
options.clearPending();
return true;
}
if (isClosableExternalLaunch(launched)) {
try {
await options.close(launched);
} catch (error) {
options.warnCloseFailure(error);
// The exact child is still potentially live. Keep the
// credential-free destination owner so the next source start
// can retry its close instead of depending on the global dock.
options.clearPending();
return false;
}
}
options.clearPending();
options.clearOwnership();
return false;
} catch {
// A partial reuse can reject while Electron retains a matching
// closable error session. Keep its credential-free identity so the
// next source start can still find and close that exact process.
options.clearPending();
return false;
}
}
function isClosableExternalLaunch(
session: ExternalPlayerSession | void
): session is ExternalPlayerSession {
return (
!!session &&
(session.status === 'launching' ||
session.status === 'opened' ||
session.status === 'playing' ||
(session.status === 'error' && session.canClose))
);
}
@@ -7,10 +7,7 @@ import {
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import type { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
/**
@@ -30,18 +27,24 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
const ROUTE_VOD_ID = 650020;
let service: VodDetailsPlaybackService;
/** The bridge callback the service registers at construction. */
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const addRecentItem = jest.fn();
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const openResolvedPlayback = jest.fn();
const activeSource = signal<PlayerContentInfo | null>(null);
const currentPlaylist = signal({ id: ROUTE_PLAYLIST });
const routeVodId = signal(ROUTE_VOD_ID);
function sessionFor(playlistId: string, contentXtreamId: number) {
return {
id: `${playlistId}:${contentXtreamId}`,
player: 'mpv',
status: 'playing',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mkv',
startedAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
canClose: true,
contentInfo: {
playlistId,
contentXtreamId,
@@ -53,8 +56,13 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
beforeEach(() => {
activeSession.set(null);
activeSource.set(null);
positionListener = undefined;
currentPlaylist.set({ id: ROUTE_PLAYLIST });
routeVodId.set(ROUTE_VOD_ID);
addRecentItem.mockClear();
closeSession.mockReset().mockResolvedValue(undefined);
openResolvedPlayback
.mockReset()
.mockResolvedValue(sessionFor('playlist-2', 991));
TestBed.configureTestingModule({
providers: [
@@ -62,7 +70,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
{
provide: XtreamStore,
useValue: {
currentPlaylist: signal({ id: ROUTE_PLAYLIST }),
currentPlaylist,
addRecentItem,
constructVodStreamUrl: jest
.fn()
@@ -90,12 +98,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: (
listener: (data: PlaybackPositionData) => void
) => {
positionListener = listener;
return () => undefined;
},
onPlaybackPositionUpdate: () => () => undefined,
},
},
],
@@ -103,17 +106,15 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
service = TestBed.inject(VodDetailsPlaybackService);
service.bind({
vodId: signal(ROUTE_VOD_ID),
vodId: routeVodId,
vodInfo: signal(null),
activeSource,
});
});
it('closes the alternative it launched, once the badge has moved on', async () => {
// The controller marks the DESTINATION active before playback is
// handed over, so by the time the switch reaches the service the
// running process no longer looks like "ours" — and was left playing
// beside its replacement.
// Ownership survives controller refreshes or overlapping UI state:
// the exact process this page launched still has to close first.
activeSession.set(sessionFor('playlist-2', 991));
activeSource.set({
playlistId: 'playlist-3',
@@ -153,22 +154,79 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
);
});
it('still starts the replacement when closing the old player fails', async () => {
it('cancels the replacement when closing the old player fails', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
closeSession.mockRejectedValue(new Error('close ipc failed'));
openResolvedPlayback.mockClear();
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
})
).resolves.toBe(false);
expect(openResolvedPlayback).not.toHaveBeenCalled();
closeSession.mockResolvedValue(undefined);
});
it('cancels the replacement while a live session cannot be closed', async () => {
activeSession.set({
...sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID),
status: 'launching',
canClose: false,
});
openResolvedPlayback.mockClear();
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
})
).resolves.toBe(false);
expect(closeSession).not.toHaveBeenCalled();
expect(openResolvedPlayback).not.toHaveBeenCalled();
});
it('rejects a handoff when the external player launch fails', async () => {
openResolvedPlayback.mockRejectedValueOnce(
new Error('previous player is still shutting down')
);
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
})
).resolves.toBe(false);
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
});
it('closes a closable error before starting a replacement source', async () => {
activeSession.set({
...sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID),
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
});
closeSession.mockClear();
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The switch is already committed — the badge names the new source.
// Bailing out here left the page claiming a source with nothing
// started at all.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
closeSession.mockResolvedValue(undefined);
expect(closeSession).toHaveBeenCalledWith(
expect.objectContaining({ status: 'error', canClose: true })
);
});
it('launches only the newest source when two switches overlap', async () => {
@@ -209,6 +267,232 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
closeSession.mockResolvedValue(undefined);
});
it('checks host ownership after teardown before applying playback', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
releaseClose = resolve;
});
closeSession.mockReturnValue(closing);
openResolvedPlayback.mockClear();
let ownsSwitch = true;
const switching = service.startResolvedPlayback(
{
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
},
() => ownsSwitch
);
ownsSwitch = false;
releaseClose?.();
await expect(switching).resolves.toBe(false);
expect(openResolvedPlayback).not.toHaveBeenCalled();
closeSession.mockResolvedValue(undefined);
});
it('closes a launch that loses host ownership while IPC is pending', async () => {
let releaseLaunch: ((value: unknown) => void) | undefined;
const launched = sessionFor('playlist-2', 991);
openResolvedPlayback.mockReturnValueOnce(
new Promise((resolve) => {
releaseLaunch = resolve;
})
);
let ownsSwitch = true;
const switching = service.startResolvedPlayback(
{
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: launched.contentInfo,
},
() => ownsSwitch
);
while (openResolvedPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
ownsSwitch = false;
releaseLaunch?.(launched);
await expect(switching).resolves.toBe(false);
expect(closeSession).toHaveBeenCalledWith(launched);
});
it('retains a stale launch when its exact close fails', async () => {
let releaseLaunch: ((value: unknown) => void) | undefined;
const launched = sessionFor('playlist-2', 991);
const failed = {
...launched,
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
};
openResolvedPlayback.mockReturnValueOnce(
new Promise((resolve) => {
releaseLaunch = resolve;
})
);
closeSession.mockRejectedValueOnce(new Error('close ipc failed'));
let ownsSwitch = true;
const switching = service.startResolvedPlayback(
{
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: launched.contentInfo,
},
() => ownsSwitch
);
while (openResolvedPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
ownsSwitch = false;
activeSession.set(failed);
releaseLaunch?.(launched);
await expect(switching).resolves.toBe(false);
expect(service.matchedExternalPlayback()?.id).toBe(failed.id);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/third.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 992,
contentType: 'vod',
},
});
expect(closeSession).toHaveBeenLastCalledWith(failed);
});
it('does not commit a session stopped while launch IPC is pending', async () => {
openResolvedPlayback.mockResolvedValueOnce({
...sessionFor('playlist-2', 991),
status: 'closed',
canClose: false,
});
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
})
).resolves.toBe(false);
});
it('owns an alternative immediately while its external launch is pending', async () => {
let releaseLaunch: ((value: unknown) => void) | undefined;
const launching = {
...sessionFor('playlist-2', 991),
status: 'launching',
};
openResolvedPlayback.mockReturnValueOnce(
new Promise((resolve) => {
releaseLaunch = resolve;
})
);
const switching = service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: launching.contentInfo,
});
while (openResolvedPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
expect(service.isExternalLaunchPending()).toBe(true);
activeSession.set(launching);
expect(service.matchedExternalPlayback()?.id).toBe(launching.id);
const opened = { ...launching, status: 'opened' };
activeSession.set(opened);
releaseLaunch?.(opened);
await expect(switching).resolves.toBe(true);
expect(service.isExternalLaunchPending()).toBe(false);
});
it('keeps the pending launch when an unclosable source replacement is denied', async () => {
let releaseLaunch: ((value: unknown) => void) | undefined;
const launching = {
...sessionFor('playlist-2', 991),
status: 'launching',
canClose: false,
};
openResolvedPlayback.mockReturnValueOnce(
new Promise((resolve) => {
releaseLaunch = resolve;
})
);
const first = service.startResolvedPlayback({
streamUrl: 'https://example.com/first.mkv',
title: 'Example Movie',
contentInfo: launching.contentInfo,
});
while (openResolvedPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
activeSession.set(launching);
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/second.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 77,
contentType: 'vod',
},
})
).resolves.toBe(false);
const opened = { ...launching, status: 'opened' };
activeSession.set(opened);
releaseLaunch?.(opened);
await expect(first).resolves.toBe(true);
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(closeSession).not.toHaveBeenCalled();
});
it('retains a closable failed alternative for the next exact close', async () => {
const failed = {
...sessionFor('playlist-2', 991),
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
};
openResolvedPlayback.mockImplementationOnce(async () => {
activeSession.set(failed);
throw new Error('External player process did not exit');
});
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: failed.contentInfo,
})
).resolves.toBe(false);
expect(service.matchedExternalPlayback()?.id).toBe(failed.id);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/third.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 992,
contentType: 'vod',
},
});
expect(closeSession).toHaveBeenCalledWith(failed);
});
it('drops a switch that a plain Play overtook', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
@@ -246,6 +530,36 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
closeSession.mockResolvedValue(undefined);
});
it('drops a plain Play when its initiating route changes during close', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
closeSession.mockReturnValue(
new Promise<void>((resolve) => {
releaseClose = resolve;
})
);
openResolvedPlayback.mockClear();
addRecentItem.mockClear();
const playing = service.playVod({
movie_data: {
stream_id: ROUTE_VOD_ID,
name: 'First movie',
container_extension: 'mkv',
},
} as never);
while (closeSession.mock.calls.length === 0) {
await Promise.resolve();
}
routeVodId.set(ROUTE_VOD_ID + 1);
releaseClose?.();
await expect(playing).resolves.toBe(false);
expect(openResolvedPlayback).not.toHaveBeenCalled();
expect(addRecentItem).not.toHaveBeenCalled();
});
it('stops the running external player before switching sources', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
@@ -2,14 +2,15 @@ import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { isLiveExternalPlayerSession } from '@iptvnator/portal/shared/util';
/**
* Which external player process, and which position rows, belong to this page.
*
* Multi-source makes both questions harder than they look: playback can be on
* a copy of the film in ANOTHER playlist, whose ids the session and the
* position rows then carry, and during a switch the controller has already
* moved "active" to the destination before playback is handed over.
* position rows then carry. A source handoff also waits for exact process
* teardown before the destination can become active.
*/
/** Ids this page owns, beyond the route's own copy. */
@@ -53,10 +54,9 @@ export function ownsContent(
/**
* The external process this page started, if it is still up.
*
* Matched on the ids we LAUNCHED with rather than on what is active now: a
* switch marks the destination active before handing playback over, so asking
* "is this session ours?" at that moment answers no and leaves the running
* process playing beside its replacement.
* Matched on the ids we LAUNCHED with rather than only on what is active now:
* refreshes and overlapping handoffs may update controller state while exact
* process teardown is still in flight.
*/
export function runningExternalSession(
session: ExternalPlayerSession | null,
@@ -66,7 +66,7 @@ export function runningExternalSession(
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
(session.status === 'error' && !session.canClose)
) {
return null;
}
@@ -83,26 +83,29 @@ export function runningExternalSession(
/**
* Close the running external player before its replacement starts.
*
* A failure is logged rather than propagated: the caller has already
* committed the switch, so refusing to launch would leave the page naming a
* source with nothing playing — worse than a possibly-lingering process, and
* a close that rejects usually means the session was gone already.
* A failed close leaves teardown unconfirmed. Report false so the caller can
* cancel the replacement instead of starting a second external process.
*/
export async function closeRunningExternalSession(
session: ExternalPlayerSession | null,
close: (session: ExternalPlayerSession) => Promise<void>,
warn: (message: string, error: unknown) => void
): Promise<void> {
): Promise<boolean> {
if (!session) {
return;
return true;
}
if (isLiveExternalPlayerSession(session) && !session.canClose) {
return false;
}
try {
await close(session);
return true;
} catch (error) {
warn(
'Closing the previous external player failed; starting the replacement anyway.',
'Closing the previous external player failed; cancelling the replacement.',
error
);
return false;
}
}
@@ -31,6 +31,8 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const openResolvedPlayback = jest.fn();
const openExternalPlayback = jest.fn();
const supersedePendingSwitch = jest.fn();
const activeSource = signal<PlayerContentInfo | null>(null);
const currentPlaylist = signal({ id: ROUTE_PLAYLIST });
const routeVodId = signal(ROUTE_VOD_ID);
@@ -63,6 +65,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
return {
player: 'mpv',
status: 'playing',
canClose: true,
contentInfo: {
playlistId,
contentXtreamId,
@@ -78,6 +81,14 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
routeVodId.set(ROUTE_VOD_ID);
positionListener = undefined;
addRecentItem.mockClear();
closeSession.mockReset().mockResolvedValue(undefined);
openResolvedPlayback
.mockReset()
.mockResolvedValue(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
openExternalPlayback
.mockReset()
.mockResolvedValue(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
supersedePendingSwitch.mockClear();
getPlaybackPosition.mockReset();
getPlaybackPosition.mockResolvedValue(null);
@@ -110,6 +121,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback,
openExternalPlayback,
},
},
{
@@ -131,6 +143,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
vodId: routeVodId,
vodInfo: signal(null),
activeSource,
supersedePendingSwitch,
});
});
@@ -176,6 +189,142 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(service.matchedExternalPlayback()).toBeNull();
});
it('disowns its previous external launch when the routed movie changes', async () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/route.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
});
activeSession.set(launched);
routeVodId.set(ROUTE_VOD_ID + 1);
expect(service.matchedExternalPlayback()).toBeNull();
expect(service.isExternalStopAction()).toBe(false);
});
it('closes the previous route launch before playing the new movie', async () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/first.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
});
activeSession.set(launched);
routeVodId.set(ROUTE_VOD_ID + 1);
closeSession.mockClear();
openResolvedPlayback.mockClear();
service.playVod({
movie_data: {
stream_id: ROUTE_VOD_ID + 1,
name: 'Second movie',
container_extension: 'mkv',
},
} as never);
for (let attempt = 0; attempt < 10; attempt += 1) {
if (openResolvedPlayback.mock.calls.length > 0) break;
await Promise.resolve();
}
expect(closeSession).toHaveBeenCalledWith(launched);
expect(closeSession.mock.invocationCallOrder[0]).toBeLessThan(
openResolvedPlayback.mock.invocationCallOrder[0]
);
});
it('closes a diagnostic fallback before playing the next routed movie', async () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
const launch = Promise.resolve(launched);
const trackLaunch = jest.fn();
openExternalPlayback.mockImplementationOnce(() => {
activeSession.set(launched);
expect(service.isExternalLaunchPending()).toBe(true);
expect(service.matchedExternalPlayback()).toBe(launched);
return launch;
});
service.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://example.com/first.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
},
diagnostic: {},
trackLaunch,
} as never);
expect(trackLaunch).toHaveBeenCalledWith(launch);
await launch;
await Promise.resolve();
routeVodId.set(ROUTE_VOD_ID + 1);
closeSession.mockClear();
openResolvedPlayback.mockClear();
service.playVod({
movie_data: {
stream_id: ROUTE_VOD_ID + 1,
name: 'Second movie',
container_extension: 'mkv',
},
} as never);
for (let attempt = 0; attempt < 10; attempt += 1) {
if (openResolvedPlayback.mock.calls.length > 0) break;
await Promise.resolve();
}
expect(closeSession).toHaveBeenCalledWith(launched);
expect(closeSession.mock.invocationCallOrder[0]).toBeLessThan(
openResolvedPlayback.mock.invocationCallOrder[0]
);
});
it('supersedes a pending source switch before starting a diagnostic fallback', () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
service.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://example.com/first.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
},
diagnostic: {},
trackLaunch: jest.fn(),
} as never);
expect(supersedePendingSwitch).toHaveBeenCalledTimes(1);
expect(supersedePendingSwitch.mock.invocationCallOrder[0]).toBeLessThan(
openExternalPlayback.mock.invocationCallOrder[0]
);
});
it('closes a diagnostic fallback that resolves after its route changed', async () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
const pending = deferred<typeof launched>();
const trackLaunch = jest.fn();
openExternalPlayback.mockReturnValueOnce(pending.promise);
service.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://example.com/first.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
},
diagnostic: {},
trackLaunch,
} as never);
routeVodId.set(ROUTE_VOD_ID + 1);
pending.resolve(launched);
await pending.promise;
await Promise.resolve();
expect(trackLaunch).toHaveBeenCalledWith(pending.promise);
expect(closeSession).toHaveBeenCalledWith(launched);
});
it('records a source started through multi-source as recently viewed', async () => {
// Playing an alternative from the picker, or letting a pin decide the
// primary Play, is still watching the movie — it belongs in Recently
@@ -21,6 +21,7 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
ExternalPlayerSession,
PlaybackPositionData,
PlayerContentInfo,
ResolvedPortalPlayback,
@@ -33,6 +34,11 @@ import {
ownsContent,
runningExternalSession,
} from './vod-details-external-session';
import {
createExternalLaunchOwner,
startRouteOwnedPlayback,
} from './vod-details-external-launch-owner';
import { settleOwnedExternalLaunch } from './vod-details-external-launch';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
import { formatPlaybackPosition } from './vod-primary-action-position';
@@ -47,6 +53,8 @@ export interface VodDetailsPlaybackBindings {
* this the session belongs to no page and never offers Stop.
*/
activeSource?: Signal<PlayerContentInfo | null>;
/** Retires a source resolution that the accepted fallback now supersedes. */
supersedePendingSwitch: () => void;
}
/**
@@ -69,6 +77,10 @@ export class VodDetailsPlaybackService {
/** Signals bound from the host component via `bind()` */
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
private readonly externalLaunchOwner = createExternalLaunchOwner(
() => this.xtreamStore.currentPlaylist()?.id,
() => this.bindings()?.vodId()
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
/**
@@ -95,13 +107,22 @@ export class VodDetailsPlaybackService {
session: this.externalPlayback.activeSession,
playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
contentId: computed(() => this.bindings()?.vodId()),
alsoOwns: computed(() => this.bindings()?.activeSource?.() ?? null),
alsoOwns: computed(
() =>
this.externalLaunchOwner.current() ??
this.bindings()?.activeSource?.() ??
null
),
});
readonly matchedExternalPlayback = this.externalButton.matchedSession;
readonly externalPrimaryLabel = this.externalButton.primaryLabel;
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
readonly isExternalLaunchPending = this.externalButton.isLaunchPending;
readonly isExternalLaunchPending = computed(
() =>
this.externalLaunchGeneration() !== null ||
this.externalButton.isLaunchPending()
);
readonly isExternalStopAction = this.externalButton.isStopAction;
readonly externalPrimaryButtonState = this.externalButton.buttonState;
readonly vodPlaybackProgress = computed(() =>
@@ -177,23 +198,22 @@ export class VodDetailsPlaybackService {
this.bindings.set(bindings);
}
playVod(vodItem: XtreamVodDetails | null): void {
async playVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
if (!vodItem) {
return;
return false;
}
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
return false;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
return false;
}
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
this.addToRecentlyViewed();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = this.bindings()?.vodId();
const id =
@@ -217,26 +237,25 @@ export class VodDetailsPlaybackService {
contentInfo,
};
this.startPlayback(playback);
return await this.startPlayback(playback);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
async resumeVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
if (!vodItem) {
return;
return false;
}
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
return false;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
return false;
}
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
this.addToRecentlyViewed();
// Master's sparse-details fallback: a provider that omits the route
// id still has the stream id on the resolved source.
const routeVodId = this.bindings()?.vodId();
@@ -265,7 +284,7 @@ export class VodDetailsPlaybackService {
contentInfo,
};
this.startPlayback(playback);
return await this.startPlayback(playback);
}
onPrimaryAction(vodItem: XtreamVodDetails | null): void {
@@ -274,22 +293,20 @@ export class VodDetailsPlaybackService {
}
if (this.isExternalStopAction()) {
void this.stopExternalPlayback();
void this.stopExternalPlayback().catch(() => undefined);
return;
}
if (this.hasPlaybackPosition()) {
this.resumeVod(vodItem);
void this.resumeVod(vodItem);
return;
}
this.playVod(vodItem);
void this.playVod(vodItem);
}
async stopExternalPlayback(): Promise<void> {
await this.externalPlayback.closeSession(
this.matchedExternalPlayback()
);
stopExternalPlayback(): Promise<void> {
return this.externalPlayback.closeSession(this.matchedExternalPlayback());
}
formatPosition(): string {
@@ -324,10 +341,20 @@ export class VodDetailsPlaybackService {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const routeIdentity = this.externalLaunchOwner.captureRoute();
this.bindings()?.supersedePendingSwitch();
const generation = ++this.startGeneration;
this.claimExternalLaunch(request.playback, generation);
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void this.settleExternalLaunch(
generation,
() => this.externalLaunchOwner.ownsRoute(routeIdentity),
launch
);
}
async loadPosition(playlistId: string, vodId: number): Promise<void> {
@@ -375,27 +402,40 @@ export class VodDetailsPlaybackService {
* What we last launched externally, remembered independently of the
* controller's active source.
*
* `matchedExternalPlayback` cannot answer this during a switch: the
* controller marks the DESTINATION active before playback is handed over,
* so by the time we get here the running process no longer looks like
* ours and would be left playing beside its replacement.
* Kept independently of the controller so a refresh or an overlapping
* handoff cannot make the exact process this page launched look foreign
* before its teardown has been confirmed. Its route owner prevents a
* reused component from attributing that process to a different movie.
*/
private launchedExternally: PlayerContentInfo | null = null;
private launchedExternallyGeneration = 0;
private readonly externalLaunchGeneration = signal<number | null>(null);
async startResolvedPlayback(
playback: ResolvedPortalPlayback
): Promise<void> {
playback: ResolvedPortalPlayback,
isCurrent: () => boolean = () => true
): Promise<boolean> {
if (this.externalLaunchGeneration() !== null) {
return false;
}
const runningSession = runningExternalSession(
this.externalPlayback.activeSession(),
this.externalLaunchOwner.retained(),
this.matchedExternalPlayback()
);
// A still-opening process has no exact closer yet. Denying this
// replacement must not supersede that launch generation, or its late
// successful result would be closed even though nothing replaced it.
if (runningSession && !runningSession.canClose) {
return false;
}
const generation = ++this.startGeneration;
// A switch REPLACES what is playing. With MPV or VLC and instance
// reuse off, the backend spawns a second detached player otherwise —
// both sources keep running and Stop owns only the newer one.
await closeRunningExternalSession(
runningExternalSession(
this.externalPlayback.activeSession(),
this.launchedExternally,
this.matchedExternalPlayback()
),
const previousPlayerClosed = await closeRunningExternalSession(
runningSession,
(session) => this.externalPlayback.closeSession(session),
(message, error) => this.logger.warn(message, error)
);
@@ -404,30 +444,90 @@ export class VodDetailsPlaybackService {
// reach this line too: both would have seen the same session, closed
// it once, and then launched independently — two detached players
// again, the older one holding a source the user has moved on from.
if (generation !== this.startGeneration) {
return;
if (
!previousPlayerClosed ||
generation !== this.startGeneration ||
!isCurrent()
) {
return false;
}
// Same movie, different source: still a view.
this.addToRecentlyViewed();
this.startPlayback(playback);
return await this.applyPlayback(playback, isCurrent);
}
private startPlayback(playback: ResolvedPortalPlayback): void {
private startPlayback(playback: ResolvedPortalPlayback): Promise<boolean> {
return startRouteOwnedPlayback(this.externalLaunchOwner, (isCurrent) =>
this.startResolvedPlayback(playback, isCurrent)
);
}
private async applyPlayback(
playback: ResolvedPortalPlayback,
isCurrent: () => boolean = () => true
): Promise<boolean> {
// EVERY start claims the generation, not just the switch path. Play,
// Resume and Restart reach here directly, and a switch still waiting
// on its `closeSession` would otherwise pass the check afterwards and
// launch on top of what the user just chose.
this.startGeneration++;
const generation = ++this.startGeneration;
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
this.launchedExternally = null;
return;
this.externalLaunchOwner.clear();
this.externalLaunchGeneration.set(null);
return true;
}
this.closeInlinePlayer();
this.launchedExternally = playback.contentInfo ?? null;
void this.portalPlayer.openResolvedPlayback(playback, true);
this.claimExternalLaunch(playback, generation);
const launch = this.portalPlayer.openResolvedPlayback(playback, true);
return await this.settleExternalLaunch(
generation,
isCurrent,
launch
);
}
private claimExternalLaunch(
playback: ResolvedPortalPlayback,
generation: number
): void {
this.externalLaunchOwner.set(playback.contentInfo);
this.launchedExternallyGeneration = generation;
this.externalLaunchGeneration.set(generation);
}
private async settleExternalLaunch(
generation: number,
isCurrent: () => boolean,
launch: Promise<ExternalPlayerSession | void>
): Promise<boolean> {
return await settleOwnedExternalLaunch({
launch,
owns: () => generation === this.startGeneration && isCurrent(),
close: (session) => this.externalPlayback.closeSession(session),
warnCloseFailure: (error) =>
this.logger.warn(
'Closing a superseded external player failed.',
error
),
clearPending: () => this.clearExternalLaunchPending(generation),
clearOwnership: () =>
this.clearExternalLaunchOwnership(generation),
});
}
private clearExternalLaunchOwnership(generation: number): void {
if (this.launchedExternallyGeneration === generation) {
this.externalLaunchOwner.clear();
}
}
private clearExternalLaunchPending(generation: number): void {
if (this.externalLaunchGeneration() === generation) {
this.externalLaunchGeneration.set(null);
}
}
}
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more