Merge remote-tracking branch 'origin/master' into perf/zoneless-flip

# Conflicts:
#	tools/performance/journey-baselines.json
This commit is contained in:
4gray committed 2026-10-14 19:58:21 +02:00
commit 8e807242db
191 files changed
+15429 -6865

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
type: fix
area: a11y
---
The radio player's play and pause button now shows the keyboard focus ring
when you tab to it, like every other control, and the app loads a little less
style code at startup.
@@ -0,0 +1,6 @@
---
type: fix
area: catalog
---
The Refine menu on Movies and Series now fits in a short window: the minimum-rating choices open in their own submenu, and the menu shows the active one.
+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: sources
---
Playlists added from a URL no longer show the login or password from that URL in the Sources list or the toolbar: they show the server's host instead.
@@ -0,0 +1,7 @@
---
type: feature
area: sources
highlight: Redesigned Sources and playlist switcher
---
Sources and the playlist switcher share a new, compact row with a status dot. Filter Sources by type and by whether sources respond, delete with Undo, and open the switcher with ⌘P / Ctrl+P to search, filter by type and switch from the keyboard.
@@ -0,0 +1,8 @@
---
type: fix
area: workspace
---
Going back to the dashboard from a portal section no longer makes the page
jump: the dashboard replaces the portal page in one step instead of appearing
below it for a moment.
@@ -0,0 +1,6 @@
---
type: fix
area: workspace
---
The side panel next to Sources, categories and Settings no longer draws a thin divider line against the content area, whose colour, shadow and rounded corner already mark the edge. Dragging the panel's edge still resizes it.
@@ -28,6 +28,9 @@ import { applyTheme, expectTextContrast } from './theme-contrast';
// Single-choice menus (catalog refine, live channels, categories, sources)
// are radio groups: every row reserves the same leading check slot, so the
// labels share one left edge, and `aria-checked` marks the active choice.
//
// The refine menu keeps its rating thresholds in a submenu, so it opens
// whole below its button in a short window.
// ---------------------------------------------------------------------------
const locales = ['en', 'de', 'ru', 'hu'] as const;
@@ -83,14 +86,62 @@ function openMenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last();
}
/** The refine menu's own panel, also while its rating submenu is open. */
function refineMenuPanel(page: Page): Locator {
return page.locator(
'.cdk-overlay-pane .mat-mdc-menu-panel.refine-menu:not(.refine-rating-menu)'
);
}
function ratingSubmenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .refine-rating-menu');
}
async function openRefineMenu(page: Page): Promise<Locator> {
await page.locator('app-category-content-view .refine-action').click();
const menu = openMenuPanel(page);
const menu = refineMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
return menu;
}
/** Opens the minimum-rating thresholds from the open refine menu. */
async function openRatingSubmenu(page: Page): Promise<Locator> {
await page.locator('.refine-rating-trigger').click();
const submenu = ratingSubmenuPanel(page);
await expect(submenu).toBeVisible();
await settleAnimations(submenu);
return submenu;
}
/** The panel lies inside the window and shows every row without scrolling. */
async function expectWholeOnScreen(
panel: Locator,
label: string
): Promise<{ top: number; bottom: number }> {
const geometry = await panel.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
windowHeight: window.innerHeight,
scrolls: element.scrollHeight > element.clientHeight + 1,
};
});
expect(
geometry.top,
`${label}: ${JSON.stringify(geometry)}`
).toBeGreaterThanOrEqual(0);
expect(
geometry.bottom,
`${label}: ${JSON.stringify(geometry)}`
).toBeLessThanOrEqual(geometry.windowHeight);
expect(geometry.scrolls, `${label}: ${JSON.stringify(geometry)}`).toBe(
false
);
return geometry;
}
async function settleAnimations(menu: Locator): Promise<void> {
await menu.evaluate((element) =>
Promise.all(
@@ -210,12 +261,8 @@ test.describe('Electron catalog sort menus', () => {
// A rating threshold shows the second chip next to the sort one.
let menu = await openRefineMenu(page);
await menu
.getByRole('group')
.nth(1)
.getByRole('menuitemradio')
.nth(5)
.click();
const thresholds = await openRatingSubmenu(page);
await thresholds.getByRole('menuitemradio').nth(5).click();
await expect(menu).toBeHidden();
for (const locale of locales) {
@@ -252,16 +299,33 @@ test.describe('Electron catalog sort menus', () => {
menu = await openRefineMenu(page);
const groups = menu.getByRole('group');
await expect(groups).toHaveCount(2);
await expect(groups).toHaveCount(1);
await expectRadioGroup(groups.nth(0), 5, `${locale} sort`);
await expectRadioGroup(groups.nth(1), 5, `${locale} rating`);
const lefts = (await radioRows(menu)).map(
(row) => row.labelLeft
);
// The thresholds row starts its label on the sort labels'
// edge and names the active threshold.
const ratingRow = menu.locator('.refine-rating-trigger');
await expect(ratingRow).toContainText('5.0+');
const lefts = [
...(await radioRows(menu)).map((row) => row.labelLeft),
Math.round(
await ratingRow
.locator('.mat-mdc-menu-item-text')
.evaluate(
(text) => text.getBoundingClientRect().left
)
),
];
expect(
[...new Set(lefts)],
`${locale}: both groups`
`${locale}: sort and rating rows`
).toHaveLength(1);
const ratings = await openRatingSubmenu(page);
await expectRadioGroup(
ratings.getByRole('group'),
5,
`${locale} rating`
);
await closeMenu(page, ratings);
await closeMenu(page, menu);
}
} finally {
@@ -269,6 +333,63 @@ test.describe('Electron catalog sort menus', () => {
}
});
test('opens the refine menu whole below its button in a 600px-tall window, with the rating thresholds in a keyboard submenu', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page.setViewportSize({ width: 1280, height: 600 });
await openWorkspaceSection(page, 'Movies');
await expect(
page.locator('app-category-content-view mat-card').first()
).toBeVisible({ timeout: 20_000 });
const trigger = page.locator(
'app-category-content-view .refine-action'
);
await trigger.focus();
await page.keyboard.press('Enter');
const menu = refineMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
const triggerBottom = await trigger.evaluate(
(element) => element.getBoundingClientRect().bottom
);
const menuBox = await expectWholeOnScreen(menu, 'refine menu');
// Below the button, not pushed up over it.
expect(menuBox.top).toBeGreaterThanOrEqual(triggerBottom);
// The last row opens the thresholds with the arrow key.
await page.keyboard.press('ArrowUp');
await expect(menu.locator('.refine-rating-trigger')).toBeFocused();
await page.keyboard.press('ArrowRight');
const thresholds = ratingSubmenuPanel(page);
await expect(thresholds).toBeVisible();
await settleAnimations(thresholds);
await expect(thresholds.getByRole('menuitemradio')).toHaveCount(6);
await expect(
thresholds.getByRole('menuitemradio').first()
).toBeFocused();
await expectWholeOnScreen(thresholds, 'rating submenu');
// Escape steps back out: the submenu, then the menu.
await page.keyboard.press('Escape');
await expect(thresholds).toBeHidden();
await expect(menu.locator('.refine-rating-trigger')).toBeFocused();
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
await expect(trigger).toBeFocused();
} finally {
await closeElectronApp(app);
}
});
test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
dataDir,
request,
@@ -545,8 +545,15 @@ async function expectCategoryCentered(row: Locator): Promise<void> {
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
// Refresh for the active source lives in its switcher row menu.
await page.getByTestId('playlist-switcher-trigger').click();
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
)
.click();
await page
.getByRole('menuitem', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
@@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl(
playlistUrl
);
if (userAgent !== undefined) {
await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent);
await setInputValue(
dialog.getByRole('textbox', { name: 'User agent', exact: true }),
userAgent
);
}
await dialog.getByRole('button', { name: /Add playlist/i }).click();
await dialog.waitFor({ state: 'detached' });
@@ -1187,7 +1190,10 @@ export async function createMutableTextServer(
return;
}
if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) {
if (
options.requiredUserAgent &&
req.headers['user-agent'] !== options.requiredUserAgent
) {
res.writeHead(403);
res.end('User-Agent required');
return;
@@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact(
}
export function sourceRowByTitle(page: Page, title: string): Locator {
return page.locator('app-playlist-item').filter({
return page.locator('app-workspace-sources app-source-row').filter({
hasText: flexibleTextPattern(title),
});
}
export async function getVisibleSourceTitles(page: Page): Promise<string[]> {
return page.locator('app-playlist-item').evaluateAll((elements) =>
elements
.map((element) => {
const titleElement = element.querySelector('.playlist-title');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
return page
.locator('app-workspace-sources app-source-row')
.evaluateAll((elements) =>
elements
.map((element) => {
const titleElement =
element.querySelector('.source-row__name');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
}
export async function selectSourceTypeFilter(
@@ -1497,8 +1506,10 @@ export async function selectSourceSort(
sortLabel:
| 'Date added (Newest first)'
| 'Date added (Oldest first)'
| 'Last updated'
| 'Name (A-Z)'
| 'Name (Z-A)'
| 'Most channels'
| 'Custom order'
): Promise<void> {
const sortTrigger = page.locator('app-workspace-sources .sort-trigger');
@@ -1523,10 +1534,10 @@ export async function dragSourceBefore(
sourceTitle: string,
targetTitle: string
): Promise<void> {
const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon');
const target = sourceRowByTitle(page, targetTitle).locator(
'.playlist-item'
const source = sourceRowByTitle(page, sourceTitle).locator(
'.source-row__handle'
);
const target = sourceRowByTitle(page, targetTitle).locator('.source-row');
await expect(source.first()).toBeVisible();
await expect(target.first()).toBeVisible();
@@ -1560,15 +1571,18 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.edit-btn').click();
await row.hover();
await row.locator('.source-row__edit').click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
// Material applies its initial focus after the opening transition.
await dialog.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) =>
animation.finished.catch(() => undefined)
));
await Promise.all(
element
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined))
);
});
return dialog;
}
@@ -1678,12 +1692,24 @@ export async function expectSourceDialogValues(
}
}
/**
* Deletes a source from its ⋯ menu. The row leaves at once; the delete
* itself runs when the Undo toast closes, so this waits for the toast to go
* before the caller checks persisted state.
*/
export async function deleteSource(page: Page, title: string): Promise<void> {
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.delete-btn').click();
await confirmDialog(page, 'Remove playlist');
await row.hover();
await row.locator('.source-row__more').click();
await page
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.click();
const toast = page.locator('mat-snack-bar-container.app-toast');
await expect(toast).toContainText('Removed');
await expect(toast).toBeHidden({ timeout: 15000 });
}
export async function refreshSource(
@@ -1697,7 +1723,8 @@ export async function refreshSource(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.refresh-btn').click();
await row.hover();
await row.locator('.source-row__refresh').click();
if (confirm) {
await confirmDialog(page, 'Refresh playlist');
@@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await expect(row.locator('.busy-state__message')).toHaveCount(0, {
await expect(row.locator('.source-row__busy')).toHaveCount(0, {
timeout: 60000,
});
await expect(row.locator('.action-spinner')).toHaveCount(0, {
await expect(row.locator('mat-spinner')).toHaveCount(0, {
timeout: 60000,
});
}
@@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => {
});
await openSources(app.mainWindow);
await expect(
app.mainWindow.locator('app-playlist-item')
app.mainWindow.locator('app-workspace-sources app-source-row')
).toHaveCount(2, { timeout: 30000 });
} finally {
await closeElectronApp(app);
@@ -435,6 +435,7 @@ async function openMoviesWithRatingFilter(page: Page): Promise<void> {
timeout: 20_000,
});
await view.locator('.refine-action').click();
await page.locator('.refine-rating-trigger').click();
await page
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
.click();
@@ -550,19 +551,21 @@ test.describe('Keyboard focus ring', () => {
// Sources: the type filters, then the list's row actions.
await openSources(page);
await expect(
page.locator('app-playlist-item').first()
page.locator('app-workspace-sources app-source-row').first()
).toBeVisible();
// Add, the Type and Status rows, Clean up, Sort, then the first
// row and its actions; Check again lives in the row's menu.
await walk(
'sources list',
page.locator('app-workspace-shell'),
page
.locator('app-workspace-sources-filters-panel button')
.first(),
12
16
);
expect(
report['sources list (dark)'].map((stop) => stop.label)
).toContain('Check again');
).toContain('Playlist actions');
// The live EPG list: its rows are Tab stops (`role="button"`)
// with their own inset ring, then the row's info button.
@@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel',
assert.equal(options.splashId, 'initial-splash');
assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
assert.match(options.cardSelector, /app-playlist-item/);
assert.match(options.cardSelector, /app-source-row/);
assert.deepEqual(options.settle, {
capMs: JOURNEY_SETTLE_CAP_MS,
quietMs: JOURNEY_SETTLE_QUIET_MS,
@@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route',
options.startClick?.selector ?? '',
/dashboard-recent-sources-rail-card/
);
assert.match(options.startClick?.selector ?? '', /app-playlist-item/);
assert.match(options.startClick?.selector ?? '', /app-source-row/);
assert.equal(options.routeFragment, '/workspace/xtreams/');
assert.match(options.cardSelector, /app-grid-list mat-card/);
assert.match(options.cardSelector, /channel-item/);
@@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR =
export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */
export const JOURNEY_OPEN_SOURCE_START_SELECTOR =
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item';
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row';
/**
* Bridge method used for the sentinels. The preload emits the trace event
* before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id
@@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions(
): JourneyRendererProbeOptions {
return {
cardSelector:
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row',
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
...(idleWindowMs === null
? {}
@@ -262,7 +262,7 @@ async function runIteration(
await renderer.markOperationStart();
const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first();
await row.locator('.refresh-btn').click();
await row.locator('.source-row__refresh').click();
await renderer.waitForCancelClick();
await Promise.all([
renderer.waitForTerminal(),
@@ -301,7 +301,7 @@ async function installRendererProbe(
const epoch = (): number =>
performance.timeOrigin + performance.now();
const sourceRow = (): Element | undefined =>
Array.from(document.querySelectorAll('app-playlist-item')).find(
Array.from(document.querySelectorAll('app-source-row')).find(
(element) => element.textContent?.includes(title)
);
const state = {
@@ -330,7 +330,7 @@ async function installRendererProbe(
const row = sourceRow();
const message =
row
?.querySelector('.busy-state__message')
?.querySelector('.source-row__busy-message')
?.textContent?.trim()
.toLowerCase() ?? '';
for (const phase of ['fetch', 'pars', 'sav']) {
@@ -345,9 +345,10 @@ async function installRendererProbe(
state.cancelClickEpochMs !== null &&
state.uiSettledEpochMs === null &&
row !== undefined &&
row.querySelector('.busy-state__message') === null &&
row.querySelector('.action-spinner') === null &&
row.querySelector('.cancel-btn') === null
row.querySelector('.source-row__busy-message') === null &&
row.querySelector('.source-row__refresh mat-spinner') ===
null &&
row.querySelector('.source-row__cancel') === null
) {
state.uiSettledEpochMs = epoch();
state.uiPaintFrameRequestId = requestAnimationFrame(() => {
@@ -421,7 +422,9 @@ async function installRendererProbe(
event.phase === 'parsing' &&
state.cancelClickEpochMs === null
) {
const button = sourceRow()?.querySelector('.cancel-btn');
const button = sourceRow()?.querySelector(
'.source-row__cancel'
);
state.cancelButtonFound =
button instanceof HTMLButtonElement;
if (button instanceof HTMLButtonElement) {
@@ -152,11 +152,11 @@ export async function installXtreamRendererProbe(
);
const sourceRowAbsent = (): boolean =>
!Array.from(
document.querySelectorAll('app-playlist-item')
document.querySelectorAll('app-source-row')
).some(
(row) =>
row
.querySelector('.playlist-title')
.querySelector('.source-row__name')
?.textContent?.trim() === state.sourceTitle
);
const terminalDomGate = (): boolean => {
@@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => {
"'#serverUrl'",
"'#username'",
"'#password'",
"'app-playlist-item'",
"'.refresh-btn'",
"'.delete-btn'",
"'app-workspace-sources app-source-row'",
"'.source-row__refresh'",
"'.source-row__more'",
"'Refresh playlist'",
"'Remove playlist'",
"'.busy-state__message'",
"'.action-spinner'",
"'.cancel-btn'",
"'.source-row__busy'",
"'mat-spinner'",
"'.source-row__cancel'",
"'.workspace-loading-overlay'",
]) {
assert.ok(ui.includes(selector), `missing ${selector}`);
@@ -68,6 +68,7 @@ describe('Xtream scenario driver source wiring', () => {
);
assert.ok(driver.includes('installXtreamCancellationObserver'));
assert.ok(driver.includes('installXtreamOperationWindow'));
assert.ok(driver.includes("'delete-playlist'"));
assert.ok(driver.includes('runBackgroundProbe'));
assert.doesNotMatch(combined, /waitForTimeout\s*\(/);
assert.doesNotMatch(combined, /setTimeout\s*\(/);
@@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => {
});
it('does not require a misleading portal-status badge before refresh or delete', async () => {
for (const [scenarioId, selector, confirmLabel] of [
for (const [scenarioId, selector, confirmClick] of [
[
XTREAM_SCENARIO_ID.REFRESH_LARGE,
'.refresh-btn',
'Refresh playlist',
'.source-row__refresh',
'name=Refresh playlist;exact=true',
],
[
XTREAM_SCENARIO_ID.DELETE_LARGE,
'.source-row__more',
'.source-removal-toast__close',
],
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'],
] as const) {
const confirmClick = `name=${confirmLabel};exact=true`;
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
fake.asPage(),
@@ -188,6 +191,54 @@ describe('Xtream Playwright scenario driver', () => {
}
});
it('arms the playlist delete before its commit click and ends on its completion', async () => {
const fake = new FakePage();
const completed = {
active: false,
operation: 'delete-playlist',
operationId: 'delete-playlist-1',
playlistId: 'playlist-1',
startedEpochMs: 100,
terminalEpochMs: 150,
terminalStatus: 'completed',
};
fake.operationSnapshot = completed;
const prepared = await prepareXtreamScenario(
fake.asPage(),
scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE)
);
// Preparation stops at the open menu: Remove would start the Undo
// window, which commits on its own before a slow capture setup ends.
assert.equal(fake.clicksMatching('filter(Remove playlist)'), 0);
await prepared.trigger();
assert.equal(fake.clicksMatching('filter(Remove playlist)'), 1);
const armIndex = fake.log.indexOf('evaluate:install-operation');
const clickIndex = fake.log.findIndex(
(entry) =>
entry.startsWith('click:') &&
entry.includes('.source-removal-toast__close')
);
assert.ok(armIndex >= 0, 'the delete window is armed');
assert.ok(armIndex < clickIndex, 'before the commit click');
await prepared.waitForTerminal();
assert.ok(
fake.log.indexOf('waitForFunction:operation-terminal') > clickIndex
);
const failed = new FakePage();
failed.operationSnapshot = { ...completed, terminalStatus: 'error' };
const failing = await prepareXtreamScenario(
failed.asPage(),
scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE)
);
await failing.trigger();
await assert.rejects(
() => failing.waitForTerminal(),
/xtream-delete-did-not-complete/
);
});
it('arms cancellation after preparation and immediately before the final Add trigger', async () => {
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
@@ -19,7 +19,8 @@ import {
prepareXtreamSourceActionTrigger,
waitForXtreamCancellationTerminal,
waitForXtreamCatalogTerminal,
waitForXtreamDeleteTerminal,
removeXtreamSource,
waitForXtreamSourceRowGone,
} from './xtream-scenario-ui';
import { assertXtreamMockOrigin } from './xtream-control-client';
import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe';
@@ -106,6 +107,7 @@ export async function prepareXtreamScenario(
return createPreparedScenario({
backgroundOperation,
cancellationObserver: null,
deleteOperation: null,
options,
page,
trigger,
@@ -119,6 +121,8 @@ export async function prepareXtreamScenario(
interface PreparedState {
readonly backgroundOperation: XtreamOperationWindow | null;
cancellationObserver: XtreamCancellationObserver | null;
/** The playlist delete, armed before its commit click. */
deleteOperation: XtreamOperationWindow<'delete-playlist'> | null;
readonly options: PrepareXtreamScenarioOptions;
readonly page: Page;
readonly trigger: Locator;
@@ -136,6 +140,7 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario {
await Promise.allSettled([
state.cancellationObserver?.dispose(),
state.backgroundOperation?.dispose(),
state.deleteOperation?.dispose(),
]);
};
const prepared: PreparedXtreamScenario = {
@@ -189,8 +194,21 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario {
state.cancellationObserver =
await installXtreamCancellationObserver(state.page);
}
if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) {
// A fast delete can bring its row back and drop it again
// before a UI wait starts; the operation's own events cannot
// be missed once armed.
state.deleteOperation = await installXtreamOperationWindow(
state.page,
'delete-playlist'
);
}
let commit = state.trigger;
if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) {
commit = await removeXtreamSource(state.page, state.trigger);
}
const triggerEpochMs = Date.now();
await state.trigger.click();
await commit.click();
return {
scenarioId: state.options.scenarioId,
triggerEpochMs,
@@ -224,12 +242,20 @@ async function waitForScenarioTerminal(
cancellation = await state.cancellationObserver.waitForClick();
await waitForXtreamCancellationTerminal(state.page);
break;
case XTREAM_SCENARIO_ID.DELETE_LARGE:
await waitForXtreamDeleteTerminal(
case XTREAM_SCENARIO_ID.DELETE_LARGE: {
if (!state.deleteOperation) {
throw new Error('xtream-delete-operation-unavailable');
}
const deleted = await state.deleteOperation.waitForTerminal();
if (deleted.terminalStatus !== 'completed') {
throw new Error('xtream-delete-did-not-complete');
}
await waitForXtreamSourceRowGone(
state.page,
state.options.sourceTitle
);
break;
}
case XTREAM_SCENARIO_ID.BACKGROUND_UI:
if (readBackgroundProbe() === null) {
throw new Error('xtream-background-probe-incomplete');
@@ -2,11 +2,16 @@ import type { Page } from '@playwright/test';
const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow';
const DELETE_OPERATION = 'delete-xtream-content';
/** A content delete before a refresh, or a whole playlist's delete. */
export type XtreamObservedOperation =
typeof DELETE_OPERATION | 'delete-playlist';
const OPERATION_TIMEOUT_MS = 120_000;
export interface XtreamOperationWindowSnapshot {
export interface XtreamOperationWindowSnapshot<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
> {
readonly active: boolean;
readonly operation: typeof DELETE_OPERATION;
readonly operation: Operation;
readonly operationId: string;
readonly playlistId: string;
readonly startedEpochMs: number;
@@ -14,16 +19,21 @@ export interface XtreamOperationWindowSnapshot {
readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null;
}
export interface XtreamOperationWindow {
assertActive(): Promise<XtreamOperationWindowSnapshot>;
export interface XtreamOperationWindow<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
> {
assertActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
dispose(): Promise<void>;
waitForActive(): Promise<XtreamOperationWindowSnapshot>;
waitForTerminal(): Promise<XtreamOperationWindowSnapshot>;
waitForActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
waitForTerminal(): Promise<XtreamOperationWindowSnapshot<Operation>>;
}
export async function installXtreamOperationWindow(
page: Page
): Promise<XtreamOperationWindow> {
export async function installXtreamOperationWindow<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
>(
page: Page,
operation: Operation = DELETE_OPERATION as Operation
): Promise<XtreamOperationWindow<Operation>> {
await page.evaluate(
({ command, operation, stateKey }) => {
interface State {
@@ -104,20 +114,20 @@ export async function installXtreamOperationWindow(
},
{
command: 'install-operation',
operation: DELETE_OPERATION,
operation,
stateKey: OPERATION_STATE_KEY,
}
);
return {
assertActive: () => assertOperationActive(page),
assertActive: () => assertOperationActive(page, operation),
dispose: () => disposeOperationState(page),
async waitForActive() {
await waitForOperationState(page, 'operation-active');
return assertOperationActive(page);
return assertOperationActive(page, operation);
},
async waitForTerminal() {
await waitForOperationState(page, 'operation-terminal');
return readOperationSnapshot(page);
return readOperationSnapshot(page, operation);
},
};
}
@@ -144,19 +154,21 @@ async function waitForOperationState(
);
}
async function assertOperationActive(
page: Page
): Promise<XtreamOperationWindowSnapshot> {
const snapshot = await readOperationSnapshot(page);
async function assertOperationActive<Operation extends XtreamObservedOperation>(
page: Page,
operation: Operation
): Promise<XtreamOperationWindowSnapshot<Operation>> {
const snapshot = await readOperationSnapshot(page, operation);
if (!snapshot.active || snapshot.terminalEpochMs !== null) {
throw new Error('xtream-background-action-outside-delete-window');
}
return snapshot;
}
async function readOperationSnapshot(
page: Page
): Promise<XtreamOperationWindowSnapshot> {
async function readOperationSnapshot<Operation extends XtreamObservedOperation>(
page: Page,
operation: Operation
): Promise<XtreamOperationWindowSnapshot<Operation>> {
const value = await page.evaluate(
({ stateKey }) => {
const target = globalThis as unknown as Record<string, unknown>;
@@ -183,7 +195,7 @@ async function readOperationSnapshot(
},
{ command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY }
);
if (!isOperationSnapshot(value)) {
if (!isOperationSnapshot(value, operation)) {
throw new Error('xtream-operation-window-invalid');
}
return value;
@@ -202,16 +214,17 @@ async function disposeOperationState(page: Page): Promise<void> {
);
}
function isOperationSnapshot(
value: unknown
): value is XtreamOperationWindowSnapshot {
function isOperationSnapshot<Operation extends XtreamObservedOperation>(
value: unknown,
operation: Operation
): value is XtreamOperationWindowSnapshot<Operation> {
if (!isRecord(value)) return false;
const active = value['active'];
const startedEpochMs = value['startedEpochMs'];
const terminalEpochMs = value['terminalEpochMs'];
const terminalStatus = value['terminalStatus'];
return (
value['operation'] === DELETE_OPERATION &&
value['operation'] === operation &&
typeof active === 'boolean' &&
finitePositive(startedEpochMs) &&
nonEmptyString(value['operationId']) &&
@@ -68,28 +68,54 @@ export async function prepareXtreamSourceActionTrigger(
const row = xtreamSourceRow(page, sourceTitle);
await row.waitFor({ state: 'visible' });
for (const selector of [
'.busy-state__message',
'.action-spinner',
'.cancel-btn',
'.source-row__busy',
'mat-spinner',
'.source-row__cancel',
]) {
await row.locator(selector).waitFor({ state: 'detached' });
}
const actionSelector =
action === 'refresh' ? '.refresh-btn' : '.delete-btn';
const actionButton = row.locator(actionSelector);
await assertTriggerReady(actionButton, `xtream-source-${action}`);
await actionButton.click();
if (action === 'refresh') {
const refresh = row.locator('.source-row__refresh');
await assertTriggerReady(refresh, 'xtream-source-refresh');
await refresh.click();
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', { name: 'Refresh playlist', exact: true })
.last();
await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm');
return confirmation;
}
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', {
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist',
exact: true,
})
.last();
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`);
return confirmation;
// Remove starts the Undo window, which commits on its own after four
// seconds: preparation stops at the open menu, and the trigger removes
// and commits at once (removeXtreamSource).
const more = row.locator('.source-row__more');
await assertTriggerReady(more, 'xtream-source-delete-menu');
await more.click();
const remove = page
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.first();
await assertTriggerReady(remove, 'xtream-source-delete');
return remove;
}
/**
* Clicks Remove and returns the Undo toast's close button, which commits the
* delete without waiting out the Undo window: the measured delete starts
* with that click.
*/
export async function removeXtreamSource(
page: Page,
remove: Locator
): Promise<Locator> {
await remove.click();
const commit = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__close'
);
await assertTriggerReady(commit, 'xtream-source-delete-confirm');
return commit;
}
export async function waitForXtreamCatalogTerminal(page: Page): Promise<void> {
@@ -125,7 +151,11 @@ export async function waitForXtreamCancellationTerminal(
);
}
export async function waitForXtreamDeleteTerminal(
/**
* After the database reports the delete complete: the row, back as
* "Removing…" while it ran or never shown again for a fast delete, leaves.
*/
export async function waitForXtreamSourceRowGone(
page: Page,
sourceTitle: string
): Promise<void> {
@@ -157,9 +187,9 @@ async function selectXtreamMethod(dialog: Locator): Promise<void> {
function xtreamSourceRow(page: Page, sourceTitle: string): Locator {
const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u');
return page
.locator('app-playlist-item')
.locator('app-workspace-sources app-source-row')
.filter({
has: page.locator('.playlist-title', {
has: page.locator('.source-row__name', {
hasText: exactTitle,
}),
})
@@ -178,7 +178,7 @@ class SearchCompletionFakePage {
}
this.searchGateObserved =
/searchParams\.get\((['"])q\1\)/u.test(source) &&
source.includes('app-playlist-item') &&
source.includes('app-source-row') &&
source.includes('no-results-state');
if (this.searchGateObserved) {
this.searchResultsApplied = true;
@@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions(
);
const resultRows = Array.from(
document.querySelectorAll(
'app-workspace-sources app-playlist-item'
'app-workspace-sources app-source-row'
)
);
const resultPainted =
resultRows.length > 0
? resultRows.every((row) =>
(
row.querySelector('.playlist-title')
row.querySelector('.source-row__name')
?.textContent ?? ''
)
.trim()
@@ -245,44 +245,41 @@ test.describe('Electron Playlist Switcher', () => {
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-recent$/
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
@@ -319,7 +316,9 @@ test.describe('Electron Playlist Switcher', () => {
username: xtreamStressUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
const overlay = app.mainWindow.locator(
'.workspace-loading-overlay'
);
await expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
@@ -428,17 +427,172 @@ test.describe('Electron Playlist Switcher', () => {
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('runs from the keyboard: Cmd/Ctrl+P, filter, arrows, Enter and Escape', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Switcher Local A',
url: 'https://streams.example.test/switcher-local-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: 'Switcher Local B',
url: 'https://streams.example.test/switcher-local-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await addXtreamPortal(page, { name: xtreamAName });
await waitForXtreamCatalog(page);
const trigger = page.getByTestId('playlist-switcher-trigger');
const panel = page.locator('app-playlist-switcher-panel');
const search = panel.locator('input[role="combobox"]');
await expect(playlistSwitcherTitle(page)).toContainText(
xtreamAName
);
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
// No list shows a credential: the Xtream row names the account
// on purpose, never its password.
await expect(panel).not.toContainText(defaultXtreamPassword);
await expect(panel).not.toContainText('password=');
await page.keyboard.type('switcher-local');
await expect(switcherOptions(page)).toHaveCount(2);
// A search highlights its first match; ↓ moves on, ↑ wraps.
const options = switcherOptions(page);
const firstId = (await options.nth(0).getAttribute('id')) ?? '';
const secondId = (await options.nth(1).getAttribute('id')) ?? '';
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowUp');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
const targetName =
(
await options
.nth(1)
.locator('.source-row__name')
.textContent()
)?.trim() ?? '';
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
await waitForM3uCatalog(page);
await expect(playlistSwitcherTitle(page)).toContainText(targetName);
// Escape closes and puts focus back on the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
// In a short window only the list shrinks and scrolls; the
// search field and the type filter keep their height.
const viewport = page.viewportSize();
await page.setViewportSize({
width: viewport?.width ?? 1280,
height: 320,
});
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
const chrome = await panel.evaluate((host) => {
const height = (element: Element | null) =>
element?.getBoundingClientRect().height ?? 0;
const list = host.querySelector('.switcher__list');
return {
search: height(host.querySelector('.switcher__search')),
toggles: Array.from(
host.querySelectorAll('.mat-button-toggle'),
height
),
listScrolls:
!!list && list.scrollHeight > list.clientHeight,
};
});
expect(chrome.search, JSON.stringify(chrome)).toBeCloseTo(36, 0);
expect(chrome.toggles.length, JSON.stringify(chrome)).toBe(3);
for (const toggle of chrome.toggles) {
expect(toggle, JSON.stringify(chrome)).toBeGreaterThanOrEqual(
30
);
}
expect(chrome.listScrolls, JSON.stringify(chrome)).toBe(true);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
if (viewport) await page.setViewportSize(viewport);
// A delete from the keyboard: the popover closes, since its
// focus trap would keep Tab from Undo; Undo takes the focus and
// gives it back to the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Shift+F10');
const rowMenu = page.locator('.mat-mdc-menu-panel');
await expect(rowMenu).toBeVisible();
await page.keyboard.press('End');
await expect(
rowMenu.getByRole('menuitem', { name: /Remove/ })
).toBeFocused();
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
const undo = page.locator('.source-removal-toast__undo');
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(undo).toHaveCount(0);
await expect(trigger).toBeFocused();
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(3);
await expect(
page.locator('app-workspace-sources')
).not.toContainText(defaultXtreamPassword);
} finally {
await closeElectronApp(app);
}
@@ -446,12 +600,12 @@ test.describe('Electron Playlist Switcher', () => {
});
async function openPlaylistSwitcher(page: Page): Promise<void> {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first()
).toBeVisible();
await page.getByTestId('playlist-switcher-trigger').click();
await expect(switcherOptions(page).first()).toBeVisible();
}
function switcherOptions(page: Page) {
return page.locator('app-playlist-switcher-panel [role="option"]');
}
async function switchPlaylistFromHeader(
@@ -459,16 +613,11 @@ async function switchPlaylistFromHeader(
title: string
): Promise<void> {
await openPlaylistSwitcher(page);
const item = page
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
const item = switcherOptions(page).filter({ hasText: title }).first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
0
);
await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
@@ -503,16 +652,21 @@ async function selectSettingsOption(
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({
timeout: 20000,
});
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -31,7 +31,7 @@ type RowRing = {
function readRowRing(page: Page): Promise<RowRing> {
return page.evaluate(() => {
const focused = document.activeElement as HTMLElement;
const row = focused.closest('.playlist-item') as HTMLElement;
const row = focused.closest('.source-row') as HTMLElement;
const probe = document.createElement('span');
probe.style.color = 'var(--app-focus-ring)';
row.append(probe);
@@ -93,16 +93,19 @@ test.describe('Electron Sources keyboard', () => {
timeout: 30_000,
});
await openSources(page);
await expect(page.locator('app-playlist-item')).toHaveCount(2, {
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(2, {
timeout: 30_000,
});
// Bravo opened last, so it is the active source; open the other.
const row = sourceRowByTitle(page, 'keyboard-alpha').first();
await expect(row.locator('.playlist-item')).not.toHaveClass(
/\bselected\b/
await expect(row.locator('.source-row__body')).not.toHaveAttribute(
'aria-current',
'true'
);
const title = (
await row.locator('.playlist-title').textContent()
await row.locator('.source-row__name').textContent()
)?.trim();
expect(title).toBeTruthy();
@@ -153,7 +156,7 @@ test.describe('Electron Sources keyboard', () => {
await expect(open).not.toBeFocused();
expect(
await row
.locator('.playlist-item')
.locator('.source-row')
.evaluate((el) => getComputedStyle(el).outlineStyle)
).toBe('none');
await page.keyboard.press('Shift+Tab');
@@ -172,4 +175,69 @@ test.describe('Electron Sources keyboard', () => {
await closeElectronApp(app);
}
});
test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({
dataDir,
}) => {
const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [
{
groupTitle: 'News',
name: 'Undo Alpha News',
url: 'https://streams.example.test/undo-alpha.m3u8',
},
]);
const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [
{
groupTitle: 'Sports',
name: 'Undo Bravo Sports',
url: 'https://streams.example.test/undo-bravo.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
appArgs: [alpha, bravo],
});
const page = app.mainWindow;
const rows = page.locator('app-workspace-sources app-source-row');
try {
await page.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30_000,
});
await openSources(page);
await expect(rows).toHaveCount(2, { timeout: 30_000 });
const before = await rows
.locator('.source-row__name')
.allTextContents();
// Focus the first row's ⋯ and open its menu from the keyboard.
const row = rows.first();
await row.locator('.source-row__more').focus();
await page.keyboard.press('Enter');
const remove = page.getByRole('menuitem', {
name: 'Remove playlist',
});
await expect(remove).toBeVisible();
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
// The row leaves at once; Undo takes the focus the row had.
await expect(rows).toHaveCount(1);
const undo = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__undo'
);
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(rows).toHaveCount(2);
expect(
await rows.locator('.source-row__name').allTextContents()
).toEqual(before);
await expect(
page.locator('mat-snack-bar-container.app-toast')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
+27 -16
View File
@@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator(
'app-source-health-indicator [data-state]'
'.source-tile__dot[data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator(
'app-source-health-indicator [data-state]'
'.source-tile__dot[data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
await expect(
@@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8
try {
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
await waitForM3uCatalog(app.mainWindow);
// Refresh for the active source lives in its switcher row menu.
await app.mainWindow.getByTestId('playlist-switcher-trigger').click();
const currentRow = app.mainWindow.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"]'
);
await currentRow.locator('.source-row__more').click();
await expect(
app.mainWindow.locator(
'app-playlist-switcher .trigger-refresh-button'
)
app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' })
).toBeVisible();
await app.mainWindow.keyboard.press('Escape');
await app.mainWindow.keyboard.press('Escape');
await expect(
app.mainWindow.locator('app-playlist-switcher-panel')
).toHaveCount(0);
await goToDashboard(app.mainWindow);
const sourceCard = app.mainWindow
@@ -822,8 +831,8 @@ https://streams.example.test/original-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
.first()
.locator('.refresh-btn')
).toBeVisible();
.locator('.source-row__refresh')
).toBeAttached();
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
{
@@ -865,8 +874,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
.first()
.locator('.meta')
).toContainText('Updated:');
.locator('.source-row__meta')
).toContainText('updated');
await waitForSourceRowIdle(
app.mainWindow,
'refresh-url-source.m3u'
@@ -876,9 +885,11 @@ https://streams.example.test/refreshed-url.m3u8
'refresh-url-source.m3u'
).first();
await expect(
refreshedUrlRow.locator('.playlist-item')
).not.toHaveClass(/is-busy/);
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled();
refreshedUrlRow.locator('.source-row')
).not.toHaveClass(/source-row--busy/);
await expect(
refreshedUrlRow.locator('.source-row__refresh')
).toBeEnabled();
await refreshedUrlRow
.getByText('refresh-url-source.m3u', { exact: true })
.click();
@@ -904,8 +915,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
.first()
.locator('.meta')
).toContainText('Updated:');
.locator('.source-row__meta')
).toContainText('updated');
} finally {
await closeElectronApp(app);
await urlServer.close();
@@ -0,0 +1,132 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
/**
* Leaving a portal section for the dashboard must replace the routed page
* in one task. With the @angular/animations renderer the router inserted the
* dashboard at once but the old page (and the context panel) left only at
* the end of the next, rAF-coalesced change detection: in between Chromium
* laid out the dashboard below the portal page, at y=720 in a 720 px window,
* and scored its jump into view as a layout shift of 0.337 at 1280 px.
*/
type RouteSwapProbe = {
overlaps: string[][];
shiftSources: string[];
};
declare global {
interface Window {
__routeSwapProbe?: RouteSwapProbe;
}
}
/** Sources whose shift the old page's late removal caused. */
const SWAP_SHIFT_SOURCES = [
'lib-workspace-dashboard-rails',
'main.workspace-content',
];
async function armRouteSwapProbe(page: Page): Promise<void> {
await page.evaluate(() => {
const content = document.querySelector('main.workspace-content');
if (!content) {
throw new Error('workspace content pane is missing');
}
const probe: RouteSwapProbe = { overlaps: [], shiftSources: [] };
window.__routeSwapProbe = probe;
// Mutation callbacks run at the microtask checkpoint after the
// router's activation, before any frame or deferred change detection.
new MutationObserver(() => {
const pages = [...content.children]
.filter((child) => child.tagName !== 'ROUTER-OUTLET')
.map((child) => child.tagName.toLowerCase());
if (pages.length > 1) {
probe.overlaps.push(pages);
}
}).observe(content, { childList: true });
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const sources =
(entry as PerformanceEntry & {
sources?: { node?: Node | null }[];
}).sources ?? [];
for (const { node } of sources) {
if (node instanceof Element) {
const className = node.classList.contains(
'workspace-content'
)
? '.workspace-content'
: '';
probe.shiftSources.push(
node.tagName.toLowerCase() + className
);
}
}
}
}).observe({ type: 'layout-shift' });
});
}
async function waitForTwoFrames(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => setTimeout(resolve, 0))
)
)
);
}
test.describe('Workspace route swap', () => {
test('replaces a portal page with the dashboard without a frame showing both', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setContentSize(1280, 720);
});
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod/);
await expect(
page.locator('app-workspace-shell-context-sidebar')
).toBeVisible();
await armRouteSwapProbe(page);
await goToDashboard(page);
await expect(
page.locator('lib-workspace-dashboard-rails')
).toBeVisible();
await expect(
page.locator('app-workspace-shell-context-sidebar')
).toHaveCount(0);
await waitForTwoFrames(page);
const probe = await page.evaluate(() => window.__routeSwapProbe);
expect(probe?.overlaps).toEqual([]);
expect(
probe?.shiftSources.filter((source) =>
SWAP_SHIFT_SOURCES.includes(source)
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
});
});
@@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20';
const dbWorkerRowsPerTransaction = '100';
function playlistRowByName(name: string) {
return `app-playlist-item:has-text("${name}")`;
return `app-workspace-sources app-source-row:has-text("${name}")`;
}
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
@@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => {
);
await expect(playlistRow).toBeVisible({ timeout: 20000 });
await playlistRow.locator('.delete-btn').click();
const confirmDialog = app.mainWindow.locator(
'mat-dialog-container'
);
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole('button', { name: 'Remove playlist', exact: true })
// Delete from the row menu; the delete starts when the Undo
// toast closes, and the row comes back to show its progress.
await playlistRow.hover();
await playlistRow.locator('.source-row__more').click();
await app.mainWindow
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.click();
await waitForDbOperationEvent(app.mainWindow, {
@@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => {
timeoutMs: 20000,
});
await expect(
playlistRow.locator('.busy-state__message')
).toBeVisible({ timeout: 20000 });
const cancel = playlistRow.locator('.cancel-btn');
await expect(playlistRow.locator('.source-row__busy')).toBeVisible({
timeout: 20000,
});
const cancel = playlistRow.locator('.source-row__cancel');
await expect(cancel).toBeVisible();
// The row's action buttons inherit its color; the cancel action
// must still read as destructive on the selected row.
+2 -6
View File
@@ -111,16 +111,12 @@ test('@a11y icon-only buttons are named in Sources and the playlist info dialog'
}) => {
await addM3uUrlPlaylist(page, M3U_TITLE);
// Sources list: refresh, details and remove on each row.
// Sources list: refresh, edit and the actions menu on each row.
await openSources(page);
const row = sourceRowByTitle(page, M3U_TITLE);
await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover();
for (const name of [
'Refresh playlist',
'Show playlist details',
'Remove playlist',
]) {
for (const name of ['Refresh playlist', 'Edit…', 'Playlist actions']) {
await expect(row.getByRole('button', { name })).toBeAttached();
}
await expectNamedButtons(page, 3);
@@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise<string> {
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
await expect(page.getByText('1 channels')).toBeVisible();
await expect(page.getByText('1 channel', { exact: false })).toBeVisible();
return page.url();
}
+1 -1
View File
@@ -259,7 +259,7 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
channel = 'Refreshed UA Channel';
const row = sourceRowByTitle(page, 'Protected M3U');
await row.hover();
await row.locator('.refresh-btn').click();
await row.locator('.source-row__refresh').click();
await expect(
page.locator('.mat-mdc-snack-bar-label').last()
).toContainText('updated');
+5 -2
View File
@@ -190,7 +190,10 @@ export async function openSources(page: Page): Promise<void> {
}
export function sourceRowByTitle(page: Page, title: string): Locator {
return page.locator('app-playlist-item').filter({ hasText: title }).first();
return page
.locator('app-workspace-sources app-source-row')
.filter({ hasText: title })
.first();
}
export async function openSourceEditor(
@@ -200,7 +203,7 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover();
await row.locator('.edit-btn').click();
await row.locator('.source-row__edit').click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
return dialog;
@@ -0,0 +1,78 @@
import type { Page } from '@playwright/test';
import { pressTab } from './e2e-helpers';
import { expect } from './fixtures';
/** The computed colour a custom property resolves to on this page. */
async function computedColor(page: Page, property: string): Promise<string> {
return page.evaluate((name) => {
const probe = document.createElement('span');
probe.style.color = `var(${name})`;
document.body.append(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}, property);
}
/**
* The radio player's play/pause control is a `mat-icon-button` drawn as the
* 56px primary circle the fab used to draw (the fab tokens alone were about
* 6 KB of the initial stylesheet), with the icon centred, and keyboard focus
* draws the app ring that the fab reset with `outline: none`.
*/
export async function verifyRadioPlayControl(
page: Page,
browserName: string
): Promise<void> {
const player = page.locator('app-audio-player');
const play = player.locator('.play-btn');
await expect(play).toBeVisible();
await expect(play).toHaveClass(/mat-mdc-icon-button/);
await expect(play).not.toHaveClass(/mat-mdc-fab/);
const style = await play.evaluate((button) => {
const icon = button.querySelector('mat-icon') as HTMLElement;
const box = button.getBoundingClientRect();
const iconBox = icon.getBoundingClientRect();
const { width, height, borderRadius, backgroundColor } =
getComputedStyle(button);
const centre = (rect: DOMRect) => [
rect.left + rect.width / 2,
rect.top + rect.height / 2,
];
const [x, y] = centre(box);
const [iconX, iconY] = centre(iconBox);
return {
width,
height,
borderRadius,
backgroundColor,
iconColor: getComputedStyle(icon).color,
iconFontSize: getComputedStyle(icon).fontSize,
iconOffset: [Math.round(iconX - x), Math.round(iconY - y)],
};
});
expect(style).toEqual({
width: '56px',
height: '56px',
borderRadius: '50%',
backgroundColor: await computedColor(page, '--app-selection-color'),
iconColor: 'rgb(255, 255, 255)',
iconFontSize: '32px',
iconOffset: [0, 0],
});
await player.locator('.skip-btn').first().focus();
await pressTab(page, browserName);
await expect(play).toBeFocused();
const ring = await play.evaluate((button) => {
const { outlineStyle, outlineWidth, outlineColor } =
getComputedStyle(button);
return { outlineStyle, outlineWidth, outlineColor };
});
expect(ring).toEqual({
outlineStyle: 'solid',
outlineWidth: '2px',
outlineColor: await computedColor(page, '--app-focus-ring'),
});
}
+10 -4
View File
@@ -18,6 +18,7 @@ import {
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture';
import { playFirstItvChannel } from './stalker-itv-playback.fixture';
import { verifyRadioPlayControl } from './stalker-radio-player.fixture';
import {
AUTH_REJECTED_MAC,
BACKEND_PROXY,
@@ -182,12 +183,15 @@ test('@stalker account info dialog shows subscription facts from the portal', as
}) => {
await addStalkerPortal(page);
// Open the header playlist switcher and its "Account info" entry for
// the active stalker portal.
await page.locator('.playlist-switcher-trigger').click();
// Open the header playlist switcher and the "Account info" entry in the
// active stalker portal's row menu.
await page.locator('[data-test-id="playlist-switcher-trigger"]').click();
await page
.locator('.context-action-item', { hasText: 'Account info' })
.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
)
.click();
await page.getByRole('menuitem', { name: 'Account info' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
@@ -293,6 +297,7 @@ test('@stalker ITV playback survives a category switch', async ({ page }) => {
test('@stalker radio — stations use the inline audio player without EPG', async ({
page,
browserName,
}) => {
await addStalkerPortal(page);
@@ -330,6 +335,7 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn
await expect(page.locator('app-audio-player')).toBeVisible({
timeout: 20_000,
});
await verifyRadioPlayControl(page, browserName);
// Level 2: only the categories rail folds; the channels header turns
// its title into a category dropdown that opens the same rail as a
+4 -7
View File
@@ -61,18 +61,15 @@ async function openPlaylistDetailsDialog(page: Page, title: string) {
await playlistSettingsButton.click();
} else {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.locator('[data-test-id="playlist-switcher-trigger"]')
.click();
const switcherMenu = page.getByRole('menu').filter({ hasText: title });
const sourceRow = switcherMenu
.locator('.playlist-item')
const sourceRow = page
.locator('app-playlist-switcher-panel [role="option"]')
.filter({ hasText: title })
.first();
await expect(sourceRow).toBeVisible();
await sourceRow
.getByRole('button', { name: 'Playlist actions' })
.click();
await sourceRow.locator('.source-row__more').click();
await page.getByRole('menuitem', { name: 'Playlist info' }).click();
}
+33
View File
@@ -0,0 +1,33 @@
import { ANIMATION_MODULE_TYPE, RendererFactory2 } from '@angular/core';
type ProviderEntry = { provide?: unknown };
function flattenProviders(providers: readonly unknown[]): ProviderEntry[] {
return providers.flatMap((provider) => {
if (Array.isArray(provider)) {
return flattenProviders(provider);
}
// makeEnvironmentProviders() wraps its list in ɵproviders.
const wrapped = (provider as { ɵproviders?: unknown[] } | null)
?.ɵproviders;
return wrapped
? flattenProviders(wrapped)
: [provider as ProviderEntry];
});
}
describe('appConfig', () => {
// The @angular/animations renderer queues every DOM removal until the
// end of the next change detection, while the router inserts a routed
// page at once: leaving a portal for the dashboard laid out both pages
// until the coalesced tick and scored the swap as a layout shift.
it('keeps the default DOM renderer, so routed pages swap in one task', async () => {
const { appConfig } = await import('./app.config');
const tokens = flattenProviders(appConfig.providers).map(
(provider) => provider?.provide
);
expect(tokens).not.toContain(RendererFactory2);
expect(tokens).not.toContain(ANIMATION_MODULE_TYPE);
});
});
+3 -1
View File
@@ -112,7 +112,9 @@ export const appConfig: ApplicationConfig = {
// No provideAnimations(): nothing declares an Angular animation
// trigger, and Material 22 animates with CSS, reading
// prefers-reduced-motion itself. The provider only put the 66 KB
// animation engine on the initial path.
// animation engine on the initial path, and its renderer deferred
// every DOM removal, so a routed page stayed beside the next one
// until the following tick (workspace-shell.md, "Route swaps").
// CDK overlays (menus, tooltips, dialogs) live in a container under
// <body>, which the browser hides behind an element in DOM fullscreen.
// This container follows the fullscreen element instead, so the
@@ -164,7 +164,8 @@ export class ElectronService extends DataService {
}
if (type === PLAYLIST_UPDATE) {
this.updateM3uPlaylistFromFile(
// Settles when the refresh does, so callers keep the source busy.
await this.updateM3uPlaylistFromFile(
payload as {
id: string;
filePath?: string;
@@ -201,6 +201,55 @@ describe('PwaService', () => {
}
});
it('settles a playlist refresh only once the backend has answered', async () => {
let settled = false;
const pending = Promise.resolve(
service.sendIpcEvent(PLAYLIST_UPDATE, {
id: 'playlist-1',
url: 'https://provider.example/list.m3u',
})
).then(() => {
settled = true;
});
http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({
targetId: 'target-1',
});
await new Promise((resolve) => setTimeout(resolve));
// The caller keeps the source busy while the parse is in flight.
expect(settled).toBe(false);
http.expectOne((req) => req.url.endsWith('/parse')).flush({
_id: 'playlist-1',
});
await pending;
expect(settled).toBe(true);
expect(TestBed.inject(Store).dispatch).toHaveBeenCalled();
});
it('settles a failed playlist refresh after reporting it', async () => {
const pending = service.sendIpcEvent(PLAYLIST_UPDATE, {
id: 'playlist-1',
url: 'https://provider.example/list.m3u',
});
http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({
targetId: 'target-1',
});
await new Promise((resolve) => setTimeout(resolve));
http.expectOne((req) => req.url.endsWith('/parse')).flush(
{ message: 'failed', status: 500 },
{ status: 500, statusText: 'Internal Server Error' }
);
await expect(pending).resolves.toBeUndefined();
expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith(
expect.any(String),
'CLOSE',
{ duration: 5000 }
);
});
it('appends the proxy network code to the playlist-refresh failure toast', async () => {
const unhandled: unknown[] = [];
const previousHandler = rxjsConfig.onUnhandledError;
+36 -26
View File
@@ -133,8 +133,10 @@ export class PwaService extends DataService {
}
if (type === PLAYLIST_UPDATE) {
this.refreshPlaylist(payload as Partial<Playlist & { id: string }>);
return undefined as T;
// Settles when the refresh does, so callers keep the source busy.
return this.refreshPlaylist(
payload as Partial<Playlist & { id: string }>
) as T;
}
if (type === XTREAM_REQUEST) {
@@ -215,16 +217,39 @@ export class PwaService extends DataService {
}
}
refreshPlaylist(payload?: Partial<Playlist & { id: string }>) {
/** Resolves once the backend has answered, whether it succeeded or not. */
refreshPlaylist(
payload?: Partial<Playlist & { id: string }>
): Promise<void> {
if (!payload?.url || !payload?.id) {
return;
return Promise.resolve();
}
const playlistId = payload.id;
const { url, userAgent } = payload;
this.getPlaylistFromUrl(payload.url, payload.userAgent)
.pipe(
catchError((error) => {
return new Promise<void>((resolve) => {
this.getPlaylistFromUrl(url, userAgent).subscribe({
next: (playlist: Playlist) => {
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist,
playlistId,
refreshEpg: true,
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
},
error: (error: unknown) => {
// Redacted: the error can carry the playlist URL.
this.logger.error('Playlist refresh error:', error);
this.snackBar.open(
this.appendProviderErrorCode(
this.getPlaylistRefreshErrorMessage(error),
@@ -235,26 +260,11 @@ export class PwaService extends DataService {
duration: 5000,
}
);
return throwError(() => error);
})
)
.subscribe((playlist: Playlist) => {
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist,
playlistId,
refreshEpg: true,
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
resolve();
},
complete: () => resolve(),
});
});
}
private getPlaylistRefreshErrorMessage(error: unknown): string {
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)"
"CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)",
"UPDATED": "آخر تحديث",
"CHANNELS": "الأكثر قنوات",
"FAILING_FIRST": "غير المستجيبة أولاً"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "قوائم Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل واحدة",
"PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل"
"PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل",
"ROW": {
"TYPE_M3U_FILE": "M3U · ملف",
"ADDED": "أُضيف {{date}}",
"UPDATED": "حُدِّث {{date}}",
"UNREACHABLE": "لا يستجيب",
"UNREACHABLE_FOR": "لا يستجيب · {{duration}}",
"EXPIRED": "منتهي",
"EDIT": "تعديل…",
"COPY_URL": "نسخ الرابط",
"OPEN": "فتح",
"CHANNELS_ONE": "قناة واحدة"
},
"STATUS": "الحالة",
"STATUS_ANY": "أي حالة",
"STATUS_OK": "تستجيب",
"STATUS_BAD": "لا تستجيب",
"NO_MATCHES": "لا توجد قوائم تطابق عوامل التصفية هذه",
"RESET_FILTERS": "إعادة تعيين عوامل التصفية",
"REMOVED": "تمت إزالة «{{name}}»",
"REMOVED_SWITCHED": "تمت إزالة «{{name}}»، والانتقال إلى «{{next}}»",
"REMOVE_FAILED": "تعذّرت إزالة «{{name}}»",
"ADD_SOURCE": "إضافة مصدر"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "نقل تحديد لوحة الأوامر",
"COMMAND_PALETTE_RUN": "تشغيل عنصر لوحة الأوامر المحدد",
"CLOSE_DIALOGS": "إغلاق مربع الحوار أو إخفاء التراكب",
"DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة"
"DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة",
"OPEN_PLAYLIST_SWITCHER": "تبديل قائمة التشغيل"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "أضف قائمة تشغيل وسيظهر سجل المشاهدة هنا.",
"NO_ITEMS_TITLE": "لا توجد مشاهدات بعد",
"NO_ITEMS_BODY": "ابدأ بمشاهدة أي محتوى من مكتبتك وسيظهر هنا."
},
"SWITCHER": {
"SEARCH": "ابحث عن قائمة تشغيل…",
"RECENT": "الأخيرة",
"NO_MATCHES": "لم يُعثر على شيء",
"ALL_SOURCES": "كل المصادر",
"HINT_SELECT": "تحديد",
"HINT_OPEN": "فتح",
"HINT_CLOSE": "إغلاق"
}
},
"LOADING": "جارٍ التحميل",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)"
"CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)",
"UPDATED": "آخر تحديث",
"CHANNELS": "اللي فيها كتر القنوات",
"FAILING_FIRST": "اللي ما كيجاوبوش اللولين"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "قوائم تشغيل Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل وحدة",
"PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل"
"PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل",
"ROW": {
"TYPE_M3U_FILE": "M3U · فيشي",
"ADDED": "تزاد {{date}}",
"UPDATED": "تحدّث {{date}}",
"UNREACHABLE": "ما كيجاوبش",
"UNREACHABLE_FOR": "ما كيجاوبش · {{duration}}",
"EXPIRED": "سالات",
"EDIT": "بدّل…",
"COPY_URL": "نسخ الرابط",
"OPEN": "حلّ",
"CHANNELS_ONE": "قناة وحدة"
},
"STATUS": "الحالة",
"STATUS_ANY": "أي حالة",
"STATUS_OK": "كيجاوبو",
"STATUS_BAD": "ما كيجاوبوش",
"NO_MATCHES": "حتى ليستة ما كتوافق هاد الفلاتر",
"RESET_FILTERS": "رجّع الفلاتر",
"REMOVED": "تحيّدات «{{name}}»",
"REMOVED_SWITCHED": "تحيّدات «{{name}}»، دوّزنا لـ «{{next}}»",
"REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»",
"ADD_SOURCE": "زيد مصدر"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "حرك الاختيار فلوحة الأوامر",
"COMMAND_PALETTE_RUN": "شغل العنصر المختار فلوحة الأوامر",
"CLOSE_DIALOGS": "سد النافذة ولا خبي الطبقة",
"DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة"
"DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة",
"OPEN_PLAYLIST_SWITCHER": "بدّل الليستة"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "زيد قائمة تشغيل وسجل التفرج ديالك غادي يبان هنا.",
"NO_ITEMS_TITLE": "ما تشاف حتى شي حاجة دابا",
"NO_ITEMS_BODY": "بدا تفرج فشي حاجة من المكتبة ديالك وغادي تبان هنا."
},
"SWITCHER": {
"SEARCH": "قلّب على ليستة…",
"RECENT": "الجداد",
"NO_MATCHES": "ما لقينا والو",
"ALL_SOURCES": "كاع المصادر",
"HINT_SELECT": "ختار",
"HINT_OPEN": "حلّ",
"HINT_CLOSE": "سدّ"
}
},
"LOADING": "كيتشارجا",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Назва (Я-А)",
"NEWEST": "Дата дадання (спачатку новыя)",
"OLDEST": "Дата дадання (спачатку старыя)",
"CUSTOM_ORDER": "Уласны парадак (перацягванне)"
"CUSTOM_ORDER": "Уласны парадак (перацягванне)",
"UPDATED": "Апошняе абнаўленне",
"CHANNELS": "Колькасць каналаў",
"FAILING_FIRST": "Спачатку тыя, што не адказваюць"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (лакальны, URL, тэкст)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker плэйлісты",
"ALL_PLAYLISTS": "Усе плэйлісты",
"PLAYLIST_COUNT_ONE": "1 плэйліст",
"PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў"
"PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў",
"ROW": {
"TYPE_M3U_FILE": "M3U · файл",
"ADDED": "дададзены {{date}}",
"UPDATED": "абноўлены {{date}}",
"UNREACHABLE": "Не адказвае",
"UNREACHABLE_FOR": "Не адказвае · {{duration}}",
"EXPIRED": "Скончыўся",
"EDIT": "Змяніць…",
"COPY_URL": "Скапіяваць URL",
"OPEN": "Адкрыць",
"CHANNELS_ONE": "1 канал"
},
"STATUS": "Статус",
"STATUS_ANY": "Любы",
"STATUS_OK": "Адказваюць",
"STATUS_BAD": "Не адказваюць",
"NO_MATCHES": "Няма плэйлістоў пад гэтыя фільтры",
"RESET_FILTERS": "Скінуць фільтры",
"REMOVED": "Выдалены «{{name}}»",
"REMOVED_SWITCHED": "Выдалены «{{name}}», адкрыты «{{next}}»",
"REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»",
"ADD_SOURCE": "Дадаць крыніцу"
},
"CONTEXT": {
"CATEGORIES": "Катэгорыі",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Перамясціць выбар у палітры каманд",
"COMMAND_PALETTE_RUN": "Выканаць выбраны элемент палітры каманд",
"CLOSE_DIALOGS": "Закрыць дыялог або схаваць накладку",
"DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы"
"DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы",
"OPEN_PLAYLIST_SWITCHER": "Пераключыць плэйліст"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Дадайце плэйліст, і гісторыя прагляду з’явіцца тут.",
"NO_ITEMS_TITLE": "Праглядаў пакуль няма",
"NO_ITEMS_BODY": "Пачніце глядзець што-небудзь з бібліятэкі, і гэта з’явіцца тут."
},
"SWITCHER": {
"SEARCH": "Знайсці плэйліст…",
"RECENT": "Нядаўнія",
"NO_MATCHES": "Нічога не знойдзена",
"ALL_SOURCES": "Усе крыніцы",
"HINT_SELECT": "выбар",
"HINT_OPEN": "адкрыць",
"HINT_CLOSE": "закрыць"
}
},
"LOADING": "Загрузка",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Name (Z-A)",
"NEWEST": "Hinzugefügt (Neueste zuerst)",
"OLDEST": "Hinzugefügt (Älteste zuerst)",
"CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)"
"CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)",
"UPDATED": "Zuletzt aktualisiert",
"CHANNELS": "Meiste Sender",
"FAILING_FIRST": "Nicht erreichbare zuerst"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokal, URL, Text)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker-Playlists",
"ALL_PLAYLISTS": "Alle Playlists",
"PLAYLIST_COUNT_ONE": "1 Playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} Playlists"
"PLAYLIST_COUNT_OTHER": "{{count}} Playlists",
"ROW": {
"TYPE_M3U_FILE": "M3U · Datei",
"ADDED": "hinzugefügt am {{date}}",
"UPDATED": "aktualisiert am {{date}}",
"UNREACHABLE": "Antwortet nicht",
"UNREACHABLE_FOR": "Antwortet nicht · {{duration}}",
"EXPIRED": "Abgelaufen",
"EDIT": "Bearbeiten…",
"COPY_URL": "URL kopieren",
"OPEN": "Öffnen",
"CHANNELS_ONE": "1 Sender"
},
"STATUS": "Status (Erreichbarkeit)",
"STATUS_ANY": "Alle",
"STATUS_OK": "Erreichbar",
"STATUS_BAD": "Nicht erreichbar",
"NO_MATCHES": "Keine Playlists passen zu diesen Filtern",
"RESET_FILTERS": "Filter zurücksetzen",
"REMOVED": "„{{name}}“ entfernt",
"REMOVED_SWITCHED": "„{{name}}“ entfernt, zu „{{next}}“ gewechselt",
"REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden",
"ADD_SOURCE": "Quelle hinzufügen"
},
"CONTEXT": {
"CATEGORIES": "Kategorien",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Auswahl in der Befehlspalette verschieben",
"COMMAND_PALETTE_RUN": "Ausgewählten Eintrag der Befehlspalette ausführen",
"CLOSE_DIALOGS": "Dialog schließen oder Overlay ausblenden",
"DOWNLOADS_OPEN_ITEM": "Fokussierten Download-Eintrag in der Bibliothek öffnen"
"DOWNLOADS_OPEN_ITEM": "Fokussierten Download-Eintrag in der Bibliothek öffnen",
"OPEN_PLAYLIST_SWITCHER": "Playlist wechseln"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Füge eine Wiedergabeliste hinzu, und dein Wiedergabeverlauf erscheint hier.",
"NO_ITEMS_TITLE": "Noch nichts angesehen",
"NO_ITEMS_BODY": "Beginne, etwas aus deiner Bibliothek anzusehen, dann erscheint es hier."
},
"SWITCHER": {
"SEARCH": "Playlist suchen…",
"RECENT": "Zuletzt geöffnet",
"NO_MATCHES": "Nichts gefunden",
"ALL_SOURCES": "Alle Quellen",
"HINT_SELECT": "auswählen",
"HINT_OPEN": "öffnen",
"HINT_CLOSE": "schließen"
}
},
"LOADING": "Laden",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Όνομα (Z-A)",
"NEWEST": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)",
"OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)",
"CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)"
"CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)",
"UPDATED": "Τελευταία ενημέρωση",
"CHANNELS": "Περισσότερα κανάλια",
"FAILING_FIRST": "Πρώτα όσα δεν αποκρίνονται"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (τοπικό, διεύθυνση url, κείμενο)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Λίστες αναπαραγωγής Stalker",
"ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής",
"PLAYLIST_COUNT_ONE": "1 λίστα αναπαραγωγής",
"PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής"
"PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής",
"ROW": {
"TYPE_M3U_FILE": "M3U · αρχείο",
"ADDED": "προστέθηκε {{date}}",
"UPDATED": "ενημερώθηκε {{date}}",
"UNREACHABLE": "Δεν αποκρίνεται",
"UNREACHABLE_FOR": "Δεν αποκρίνεται · {{duration}}",
"EXPIRED": "Έληξε",
"EDIT": "Επεξεργασία…",
"COPY_URL": "Αντιγραφή URL",
"OPEN": "Άνοιγμα",
"CHANNELS_ONE": "1 κανάλι"
},
"STATUS": "Κατάσταση",
"STATUS_ANY": "Οποιαδήποτε",
"STATUS_OK": "Αποκρίνονται",
"STATUS_BAD": "Δεν αποκρίνονται",
"NO_MATCHES": "Καμία λίστα δεν ταιριάζει με αυτά τα φίλτρα",
"RESET_FILTERS": "Επαναφορά φίλτρων",
"REMOVED": "Καταργήθηκε το «{{name}}»",
"REMOVED_SWITCHED": "Καταργήθηκε το «{{name}}», μετάβαση στο «{{next}}»",
"REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»",
"ADD_SOURCE": "Προσθήκη πηγής"
},
"CONTEXT": {
"CATEGORIES": "Κατηγορίες",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Μετακίνηση επιλογής παλέτας εντολών",
"COMMAND_PALETTE_RUN": "Εκτέλεση επιλεγμένου στοιχείου παλέτας εντολών",
"CLOSE_DIALOGS": "Κλείσιμο διαλόγου ή απόκρυψη overlay",
"DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη"
"DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη",
"OPEN_PLAYLIST_SWITCHER": "Εναλλαγή λίστας"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Προσθέστε μια λίστα αναπαραγωγής και το ιστορικό προβολής σας θα εμφανιστεί εδώ.",
"NO_ITEMS_TITLE": "Δεν έχει παρακολουθηθεί τίποτα ακόμη",
"NO_ITEMS_BODY": "Ξεκινήστε να παρακολουθείτε οτιδήποτε από τη βιβλιοθήκη σας και θα εμφανιστεί εδώ."
},
"SWITCHER": {
"SEARCH": "Εύρεση λίστας…",
"RECENT": "Πρόσφατα",
"NO_MATCHES": "Δεν βρέθηκε τίποτα",
"ALL_SOURCES": "Όλες οι πηγές",
"HINT_SELECT": "επιλογή",
"HINT_OPEN": "άνοιγμα",
"HINT_CLOSE": "κλείσιμο"
}
},
"LOADING": "Φόρτωση",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Name (Z-A)",
"NEWEST": "Date added (Newest first)",
"OLDEST": "Date added (Oldest first)",
"CUSTOM_ORDER": "Custom order (Drag & Drop)"
"CUSTOM_ORDER": "Custom order (Drag & Drop)",
"UPDATED": "Last updated",
"CHANNELS": "Most channels",
"FAILING_FIRST": "Not responding first"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, text)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker Playlists",
"ALL_PLAYLISTS": "All Playlists",
"PLAYLIST_COUNT_ONE": "1 playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} playlists"
"PLAYLIST_COUNT_OTHER": "{{count}} playlists",
"ROW": {
"TYPE_M3U_FILE": "M3U · file",
"ADDED": "added {{date}}",
"UPDATED": "updated {{date}}",
"UNREACHABLE": "Not responding",
"UNREACHABLE_FOR": "Not responding · {{duration}}",
"EXPIRED": "Expired",
"EDIT": "Edit…",
"COPY_URL": "Copy URL",
"OPEN": "Open",
"CHANNELS_ONE": "1 channel"
},
"STATUS": "Status",
"STATUS_ANY": "Any",
"STATUS_OK": "Responding",
"STATUS_BAD": "Not responding",
"NO_MATCHES": "No playlists match these filters",
"RESET_FILTERS": "Reset filters",
"REMOVED": "Removed “{{name}}”",
"REMOVED_SWITCHED": "Removed “{{name}}”, switched to “{{next}}”",
"REMOVE_FAILED": "Could not remove “{{name}}”",
"ADD_SOURCE": "Add a source"
},
"CONTEXT": {
"CATEGORIES": "Categories",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Move command palette selection",
"COMMAND_PALETTE_RUN": "Run selected command palette item",
"CLOSE_DIALOGS": "Close dialog or dismiss overlay",
"DOWNLOADS_OPEN_ITEM": "Open focused download item in the library"
"DOWNLOADS_OPEN_ITEM": "Open focused download item in the library",
"OPEN_PLAYLIST_SWITCHER": "Switch playlist"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Add a playlist and your watching history will appear here.",
"NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
},
"SWITCHER": {
"SEARCH": "Find a playlist…",
"RECENT": "Recent",
"NO_MATCHES": "Nothing found",
"ALL_SOURCES": "All sources",
"HINT_SELECT": "select",
"HINT_OPEN": "open",
"HINT_CLOSE": "close"
}
},
"LOADING": "Loading",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nombre (Z-A)",
"NEWEST": "Fecha de adición (más recientes primero)",
"OLDEST": "Fecha de adición (más antiguas primero)",
"CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)"
"CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)",
"UPDATED": "Última actualización",
"CHANNELS": "Más canales",
"FAILING_FIRST": "Primero las que no responden"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Listas Stalker",
"ALL_PLAYLISTS": "Todas las listas",
"PLAYLIST_COUNT_ONE": "1 lista de reproducción",
"PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción"
"PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción",
"ROW": {
"TYPE_M3U_FILE": "M3U · archivo",
"ADDED": "añadido el {{date}}",
"UPDATED": "actualizado el {{date}}",
"UNREACHABLE": "No responde",
"UNREACHABLE_FOR": "No responde · {{duration}}",
"EXPIRED": "Caducada",
"EDIT": "Editar…",
"COPY_URL": "Copiar URL",
"OPEN": "Abrir",
"CHANNELS_ONE": "1 canal"
},
"STATUS": "Estado",
"STATUS_ANY": "Cualquiera",
"STATUS_OK": "Responden",
"STATUS_BAD": "No responden",
"NO_MATCHES": "Ninguna lista coincide con estos filtros",
"RESET_FILTERS": "Restablecer filtros",
"REMOVED": "Se eliminó «{{name}}»",
"REMOVED_SWITCHED": "Se eliminó «{{name}}» y se cambió a «{{next}}»",
"REMOVE_FAILED": "No se pudo eliminar «{{name}}»",
"ADD_SOURCE": "Añadir una fuente"
},
"CONTEXT": {
"CATEGORIES": "Categorías",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Mover la selección de la paleta de comandos",
"COMMAND_PALETTE_RUN": "Ejecutar el elemento seleccionado de la paleta de comandos",
"CLOSE_DIALOGS": "Cerrar diálogo o descartar superposición",
"DOWNLOADS_OPEN_ITEM": "Abrir en la biblioteca el elemento de descarga enfocado"
"DOWNLOADS_OPEN_ITEM": "Abrir en la biblioteca el elemento de descarga enfocado",
"OPEN_PLAYLIST_SWITCHER": "Cambiar de lista"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Añade una lista de reproducción y tu historial de visualización aparecerá aquí.",
"NO_ITEMS_TITLE": "Aún no has visto nada",
"NO_ITEMS_BODY": "Empieza a ver cualquier contenido de tu biblioteca y aparecerá aquí."
},
"SWITCHER": {
"SEARCH": "Buscar una lista…",
"RECENT": "Recientes",
"NO_MATCHES": "No se encontró nada",
"ALL_SOURCES": "Todas las fuentes",
"HINT_SELECT": "elegir",
"HINT_OPEN": "abrir",
"HINT_CLOSE": "cerrar"
}
},
"LOADING": "Cargando",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nom (Z-A)",
"NEWEST": "Date d'ajout (Plus récent)",
"OLDEST": "Date d'ajout (Plus ancien)",
"CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)"
"CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)",
"UPDATED": "Dernière mise à jour",
"CHANNELS": "Plus de chaînes",
"FAILING_FIRST": "Celles qui ne répondent pas d'abord"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, URL, texte)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Listes de lecture Stalker",
"ALL_PLAYLISTS": "Toutes les listes de lecture",
"PLAYLIST_COUNT_ONE": "1 liste de lecture",
"PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture"
"PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture",
"ROW": {
"TYPE_M3U_FILE": "M3U · fichier",
"ADDED": "ajouté le {{date}}",
"UPDATED": "mis à jour le {{date}}",
"UNREACHABLE": "Ne répond pas",
"UNREACHABLE_FOR": "Ne répond pas · {{duration}}",
"EXPIRED": "Expiré",
"EDIT": "Modifier…",
"COPY_URL": "Copier l’URL",
"OPEN": "Ouvrir",
"CHANNELS_ONE": "1 chaîne"
},
"STATUS": "État",
"STATUS_ANY": "Tous",
"STATUS_OK": "Répondent",
"STATUS_BAD": "Ne répondent pas",
"NO_MATCHES": "Aucune playlist ne correspond à ces filtres",
"RESET_FILTERS": "Réinitialiser les filtres",
"REMOVED": "« {{name}} » supprimée",
"REMOVED_SWITCHED": "« {{name}} » supprimée, passage à « {{next}} »",
"REMOVE_FAILED": "Impossible de supprimer « {{name}} »",
"ADD_SOURCE": "Ajouter une source"
},
"CONTEXT": {
"CATEGORIES": "Catégories",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Déplacer la sélection dans la palette de commandes",
"COMMAND_PALETTE_RUN": "Exécuter l’élément sélectionné dans la palette de commandes",
"CLOSE_DIALOGS": "Fermer une boîte de dialogue ou masquer une superposition",
"DOWNLOADS_OPEN_ITEM": "Ouvrir l’élément de téléchargement ciblé dans la bibliothèque"
"DOWNLOADS_OPEN_ITEM": "Ouvrir l’élément de téléchargement ciblé dans la bibliothèque",
"OPEN_PLAYLIST_SWITCHER": "Changer de playlist"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Ajoutez une playlist et votre historique de visionnage apparaîtra ici.",
"NO_ITEMS_TITLE": "Rien regardé pour le moment",
"NO_ITEMS_BODY": "Commencez à regarder un contenu de votre bibliothèque et il apparaîtra ici."
},
"SWITCHER": {
"SEARCH": "Trouver une playlist…",
"RECENT": "Récentes",
"NO_MATCHES": "Aucun résultat",
"ALL_SOURCES": "Toutes les sources",
"HINT_SELECT": "choisir",
"HINT_OPEN": "ouvrir",
"HINT_CLOSE": "fermer"
}
},
"LOADING": "Chargement",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Név szerint (Z–A)",
"NEWEST": "Hozzáadás dátuma (legújabb elöl)",
"OLDEST": "Hozzáadás dátuma (legrégebbi elöl)",
"CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)"
"CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)",
"UPDATED": "Legutóbbi frissítés",
"CHANNELS": "Legtöbb csatorna",
"FAILING_FIRST": "Nem válaszolók elöl"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (helyi fájl, URL vagy szöveg)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker-lejátszási listák",
"ALL_PLAYLISTS": "Összes lejátszási lista",
"PLAYLIST_COUNT_ONE": "1 lejátszási lista",
"PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista"
"PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista",
"ROW": {
"TYPE_M3U_FILE": "M3U · fájl",
"ADDED": "hozzáadva: {{date}}",
"UPDATED": "frissítve: {{date}}",
"UNREACHABLE": "Nem válaszol",
"UNREACHABLE_FOR": "Nem válaszol · {{duration}}",
"EXPIRED": "Lejárt",
"EDIT": "Szerkesztés…",
"COPY_URL": "URL másolása",
"OPEN": "Megnyitás",
"CHANNELS_ONE": "1 csatorna"
},
"STATUS": "Állapot",
"STATUS_ANY": "Bármelyik",
"STATUS_OK": "Válaszolnak",
"STATUS_BAD": "Nem válaszolnak",
"NO_MATCHES": "Egyik lejátszási lista sem felel meg ezeknek a szűrőknek",
"RESET_FILTERS": "Szűrők törlése",
"REMOVED": "„{{name}}” eltávolítva",
"REMOVED_SWITCHED": "„{{name}}” eltávolítva, váltás erre: „{{next}}”",
"REMOVE_FAILED": "„{{name}}” nem távolítható el",
"ADD_SOURCE": "Forrás hozzáadása"
},
"CONTEXT": {
"CATEGORIES": "Kategóriák",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Kijelölés mozgatása a parancspalettán",
"COMMAND_PALETTE_RUN": "A parancspalettán kijelölt művelet végrehajtása",
"CLOSE_DIALOGS": "Párbeszédpanel bezárása vagy fedőréteg eltüntetése",
"DOWNLOADS_OPEN_ITEM": "A fókuszban lévő letöltés megnyitása a médiatárban"
"DOWNLOADS_OPEN_ITEM": "A fókuszban lévő letöltés megnyitása a médiatárban",
"OPEN_PLAYLIST_SWITCHER": "Lejátszási lista váltása"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Adjon hozzá egy lejátszási listát, és megtekintési előzményei itt fognak megjelenni.",
"NO_ITEMS_TITLE": "Még nincs megtekintett tartalom",
"NO_ITEMS_BODY": "Indítson el bármit a médiatárából, és itt meg fog jelenni."
},
"SWITCHER": {
"SEARCH": "Lejátszási lista keresése…",
"RECENT": "Legutóbbiak",
"NO_MATCHES": "Nincs találat",
"ALL_SOURCES": "Összes forrás",
"HINT_SELECT": "kijelölés",
"HINT_OPEN": "megnyitás",
"HINT_CLOSE": "bezárás"
}
},
"LOADING": "Betöltés",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data aggiunta (prima più recenti)",
"OLDEST": "Data aggiunta (prima più vecchi)",
"CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)"
"CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)",
"UPDATED": "Ultimo aggiornamento",
"CHANNELS": "Più canali",
"FAILING_FIRST": "Prima quelle che non rispondono"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (locale, URL, testo)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Playlist Stalker",
"ALL_PLAYLISTS": "Tutte le playlist",
"PLAYLIST_COUNT_ONE": "1 playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} playlist"
"PLAYLIST_COUNT_OTHER": "{{count}} playlist",
"ROW": {
"TYPE_M3U_FILE": "M3U · file locale",
"ADDED": "aggiunto il {{date}}",
"UPDATED": "aggiornato il {{date}}",
"UNREACHABLE": "Non risponde",
"UNREACHABLE_FOR": "Non risponde · {{duration}}",
"EXPIRED": "Scaduto",
"EDIT": "Modifica…",
"COPY_URL": "Copia URL",
"OPEN": "Apri",
"CHANNELS_ONE": "1 canale"
},
"STATUS": "Stato",
"STATUS_ANY": "Qualsiasi",
"STATUS_OK": "Rispondono",
"STATUS_BAD": "Non rispondono",
"NO_MATCHES": "Nessuna playlist corrisponde a questi filtri",
"RESET_FILTERS": "Reimposta filtri",
"REMOVED": "«{{name}}» rimossa",
"REMOVED_SWITCHED": "«{{name}}» rimossa, passaggio a «{{next}}»",
"REMOVE_FAILED": "Impossibile rimuovere «{{name}}»",
"ADD_SOURCE": "Aggiungi una fonte"
},
"CONTEXT": {
"CATEGORIES": "Categorie",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Sposta selezione della palette comandi",
"COMMAND_PALETTE_RUN": "Esegui elemento selezionato della palette comandi",
"CLOSE_DIALOGS": "Chiudi dialogo o nascondi overlay",
"DOWNLOADS_OPEN_ITEM": "Apri nella libreria l’elemento download selezionato"
"DOWNLOADS_OPEN_ITEM": "Apri nella libreria l’elemento download selezionato",
"OPEN_PLAYLIST_SWITCHER": "Cambia playlist"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Aggiungi una playlist e la tua cronologia di visione apparirà qui.",
"NO_ITEMS_TITLE": "Ancora nessun contenuto guardato",
"NO_ITEMS_BODY": "Inizia a guardare qualsiasi contenuto della tua libreria e apparirà qui."
},
"SWITCHER": {
"SEARCH": "Trova una playlist…",
"RECENT": "Recenti",
"NO_MATCHES": "Nessun risultato",
"ALL_SOURCES": "Tutte le fonti",
"HINT_SELECT": "scegli",
"HINT_OPEN": "apri",
"HINT_CLOSE": "chiudi"
}
},
"LOADING": "Caricamento",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名前(Z-A)",
"NEWEST": "追加日(新しい順)",
"OLDEST": "追加日(古い順)",
"CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)"
"CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)",
"UPDATED": "最終更新",
"CHANNELS": "チャンネル数",
"FAILING_FIRST": "応答なしを先頭に"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(ローカル、URL、テキスト)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalkerプレイリスト",
"ALL_PLAYLISTS": "すべてのプレイリスト",
"PLAYLIST_COUNT_ONE": "1 件のプレイリスト",
"PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト"
"PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト",
"ROW": {
"TYPE_M3U_FILE": "M3U · ファイル",
"ADDED": "{{date}}に追加",
"UPDATED": "{{date}}に更新",
"UNREACHABLE": "応答なし",
"UNREACHABLE_FOR": "応答なし · {{duration}}",
"EXPIRED": "期限切れ",
"EDIT": "編集…",
"COPY_URL": "URLをコピー",
"OPEN": "開く",
"CHANNELS_ONE": "1 チャンネル"
},
"STATUS": "状態",
"STATUS_ANY": "すべて",
"STATUS_OK": "応答あり",
"STATUS_BAD": "応答なし",
"NO_MATCHES": "この条件に一致するプレイリストはありません",
"RESET_FILTERS": "フィルターをリセット",
"REMOVED": "「{{name}}」を削除しました",
"REMOVED_SWITCHED": "「{{name}}」を削除し、「{{next}}」に切り替えました",
"REMOVE_FAILED": "「{{name}}」を削除できませんでした",
"ADD_SOURCE": "ソースを追加"
},
"CONTEXT": {
"CATEGORIES": "カテゴリー",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "コマンドパレットの選択を移動",
"COMMAND_PALETTE_RUN": "選択中のコマンドパレット項目を実行",
"CLOSE_DIALOGS": "ダイアログを閉じる、またはオーバーレイを解除",
"DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く"
"DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く",
"OPEN_PLAYLIST_SWITCHER": "プレイリストを切り替え"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "プレイリストを追加すると、視聴履歴がここに表示されます。",
"NO_ITEMS_TITLE": "視聴履歴はまだありません",
"NO_ITEMS_BODY": "ライブラリ内のコンテンツを視聴し始めると、ここに表示されます。"
},
"SWITCHER": {
"SEARCH": "プレイリストを検索…",
"RECENT": "最近",
"NO_MATCHES": "見つかりませんでした",
"ALL_SOURCES": "すべてのソース",
"HINT_SELECT": "選択",
"HINT_OPEN": "開く",
"HINT_CLOSE": "閉じる"
}
},
"LOADING": "読み込み中",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "이름 (Z-A)",
"NEWEST": "추가 날짜 (최신순)",
"OLDEST": "추가 날짜 (오래된순)",
"CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)"
"CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)",
"UPDATED": "최근 업데이트",
"CHANNELS": "채널 많은 순",
"FAILING_FIRST": "응답 없는 항목 먼저"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (로컬, URL, 텍스트)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker 재생목록",
"ALL_PLAYLISTS": "모든 재생목록",
"PLAYLIST_COUNT_ONE": "1개 재생목록",
"PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록"
"PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록",
"ROW": {
"TYPE_M3U_FILE": "M3U · 파일",
"ADDED": "{{date}} 추가됨",
"UPDATED": "{{date}} 업데이트됨",
"UNREACHABLE": "응답 없음",
"UNREACHABLE_FOR": "응답 없음 · {{duration}}",
"EXPIRED": "만료됨",
"EDIT": "편집…",
"COPY_URL": "URL 복사",
"OPEN": "열기",
"CHANNELS_ONE": "채널 1개"
},
"STATUS": "상태",
"STATUS_ANY": "전체",
"STATUS_OK": "응답함",
"STATUS_BAD": "응답 없음",
"NO_MATCHES": "이 필터와 일치하는 재생목록이 없습니다",
"RESET_FILTERS": "필터 초기화",
"REMOVED": "'{{name}}' 삭제됨",
"REMOVED_SWITCHED": "'{{name}}' 삭제됨, '{{next}}'(으)로 전환됨",
"REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다",
"ADD_SOURCE": "소스 추가"
},
"CONTEXT": {
"CATEGORIES": "카테고리",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "명령 팔레트 선택 이동",
"COMMAND_PALETTE_RUN": "선택한 명령 팔레트 항목 실행",
"CLOSE_DIALOGS": "대화상자 닫기 또는 오버레이 해제",
"DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기"
"DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기",
"OPEN_PLAYLIST_SWITCHER": "재생목록 전환"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "플레이리스트를 추가하면 시청 기록이 여기에 표시됩니다.",
"NO_ITEMS_TITLE": "아직 시청 기록이 없습니다",
"NO_ITEMS_BODY": "라이브러리의 콘텐츠를 시청하기 시작하면 여기에 표시됩니다."
},
"SWITCHER": {
"SEARCH": "재생목록 찾기…",
"RECENT": "최근",
"NO_MATCHES": "찾은 항목 없음",
"ALL_SOURCES": "모든 소스",
"HINT_SELECT": "선택",
"HINT_OPEN": "열기",
"HINT_CLOSE": "닫기"
}
},
"LOADING": "로드 중",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Naam (Z-A)",
"NEWEST": "Datum toegevoegd (Nieuwste eerst)",
"OLDEST": "Datum toegevoegd (Oudste eerst)",
"CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)"
"CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)",
"UPDATED": "Laatst bijgewerkt",
"CHANNELS": "Meeste zenders",
"FAILING_FIRST": "Niet-reagerende eerst"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokaal, URL, tekst)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker-afspeellijsten",
"ALL_PLAYLISTS": "Alle afspeellijsten",
"PLAYLIST_COUNT_ONE": "1 afspeellijst",
"PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten"
"PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten",
"ROW": {
"TYPE_M3U_FILE": "M3U · bestand",
"ADDED": "toegevoegd {{date}}",
"UPDATED": "bijgewerkt {{date}}",
"UNREACHABLE": "Reageert niet",
"UNREACHABLE_FOR": "Reageert niet · {{duration}}",
"EXPIRED": "Verlopen",
"EDIT": "Bewerken…",
"COPY_URL": "URL kopiëren",
"OPEN": "Openen",
"CHANNELS_ONE": "1 zender"
},
"STATUS": "Bereikbaarheid",
"STATUS_ANY": "Alle",
"STATUS_OK": "Reageren",
"STATUS_BAD": "Reageren niet",
"NO_MATCHES": "Geen afspeellijsten voldoen aan deze filters",
"RESET_FILTERS": "Filters wissen",
"REMOVED": "‘{{name}}’ verwijderd",
"REMOVED_SWITCHED": "‘{{name}}’ verwijderd, overgeschakeld naar ‘{{next}}’",
"REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd",
"ADD_SOURCE": "Bron toevoegen"
},
"CONTEXT": {
"CATEGORIES": "Categorieën",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Selectie in opdrachtpalet verplaatsen",
"COMMAND_PALETTE_RUN": "Geselecteerd item in opdrachtpalet uitvoeren",
"CLOSE_DIALOGS": "Dialoogvenster sluiten of overlay verbergen",
"DOWNLOADS_OPEN_ITEM": "Gefocust downloaditem in de bibliotheek openen"
"DOWNLOADS_OPEN_ITEM": "Gefocust downloaditem in de bibliotheek openen",
"OPEN_PLAYLIST_SWITCHER": "Afspeellijst wisselen"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Voeg een afspeellijst toe en uw kijkgeschiedenis verschijnt hier.",
"NO_ITEMS_TITLE": "Nog niets bekeken",
"NO_ITEMS_BODY": "Begin iets uit uw bibliotheek te kijken en het verschijnt hier."
},
"SWITCHER": {
"SEARCH": "Afspeellijst zoeken…",
"RECENT": "Onlangs geopend",
"NO_MATCHES": "Niets gevonden",
"ALL_SOURCES": "Alle bronnen",
"HINT_SELECT": "kiezen",
"HINT_OPEN": "openen",
"HINT_CLOSE": "sluiten"
}
},
"LOADING": "Laden",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nazwa (Z-A)",
"NEWEST": "Data dodania (najnowsze pierwsze)",
"OLDEST": "Data dodania (najstarsze pierwsze)",
"CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)"
"CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)",
"UPDATED": "Ostatnia aktualizacja",
"CHANNELS": "Najwięcej kanałów",
"FAILING_FIRST": "Najpierw nieodpowiadające"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokalny, URL, tekst)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Listy odtwarzania Stalker",
"ALL_PLAYLISTS": "Wszystkie listy odtwarzania",
"PLAYLIST_COUNT_ONE": "1 lista odtwarzania",
"PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania"
"PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania",
"ROW": {
"TYPE_M3U_FILE": "M3U · plik",
"ADDED": "dodano {{date}}",
"UPDATED": "zaktualizowano {{date}}",
"UNREACHABLE": "Nie odpowiada",
"UNREACHABLE_FOR": "Nie odpowiada · {{duration}}",
"EXPIRED": "Wygasło",
"EDIT": "Edytuj…",
"COPY_URL": "Kopiuj URL",
"OPEN": "Otwórz",
"CHANNELS_ONE": "1 kanał"
},
"STATUS": "Stan",
"STATUS_ANY": "Dowolny",
"STATUS_OK": "Odpowiadają",
"STATUS_BAD": "Nie odpowiadają",
"NO_MATCHES": "Żadna playlista nie pasuje do tych filtrów",
"RESET_FILTERS": "Wyczyść filtry",
"REMOVED": "Usunięto „{{name}}”",
"REMOVED_SWITCHED": "Usunięto „{{name}}”, przełączono na „{{next}}”",
"REMOVE_FAILED": "Nie udało się usunąć „{{name}}”",
"ADD_SOURCE": "Dodaj źródło"
},
"CONTEXT": {
"CATEGORIES": "Kategorie",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Przenieś zaznaczenie w palecie poleceń",
"COMMAND_PALETTE_RUN": "Uruchom zaznaczoną pozycję palety poleceń",
"CLOSE_DIALOGS": "Zamknij okno dialogowe lub ukryj nakładkę",
"DOWNLOADS_OPEN_ITEM": "Otwórz aktywne pobranie w bibliotece"
"DOWNLOADS_OPEN_ITEM": "Otwórz aktywne pobranie w bibliotece",
"OPEN_PLAYLIST_SWITCHER": "Przełącz playlistę"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Dodaj listę odtwarzania, a historia oglądania pojawi się tutaj.",
"NO_ITEMS_TITLE": "Jeszcze nic nie oglądano",
"NO_ITEMS_BODY": "Zacznij oglądać cokolwiek ze swojej biblioteki, a pojawi się tutaj."
},
"SWITCHER": {
"SEARCH": "Znajdź playlistę…",
"RECENT": "Ostatnie",
"NO_MATCHES": "Nic nie znaleziono",
"ALL_SOURCES": "Wszystkie źródła",
"HINT_SELECT": "wybór",
"HINT_OPEN": "otwórz",
"HINT_CLOSE": "zamknij"
}
},
"LOADING": "Ładowanie",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data de adição (Mais recente primeiro)",
"OLDEST": "Data de adição (Mais antigo primeiro)",
"CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)"
"CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)",
"UPDATED": "Última atualização",
"CHANNELS": "Mais canais",
"FAILING_FIRST": "Primeiro as que não respondem"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Playlists Stalker",
"ALL_PLAYLISTS": "Todas as playlists",
"PLAYLIST_COUNT_ONE": "1 playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} playlists"
"PLAYLIST_COUNT_OTHER": "{{count}} playlists",
"ROW": {
"TYPE_M3U_FILE": "M3U · arquivo",
"ADDED": "adicionado em {{date}}",
"UPDATED": "atualizado em {{date}}",
"UNREACHABLE": "Não responde",
"UNREACHABLE_FOR": "Não responde · {{duration}}",
"EXPIRED": "Expirada",
"EDIT": "Editar…",
"COPY_URL": "Copiar URL",
"OPEN": "Abrir",
"CHANNELS_ONE": "1 canal"
},
"STATUS": "Estado",
"STATUS_ANY": "Qualquer",
"STATUS_OK": "Respondem",
"STATUS_BAD": "Não respondem",
"NO_MATCHES": "Nenhuma lista corresponde a estes filtros",
"RESET_FILTERS": "Repor filtros",
"REMOVED": "«{{name}}» removida",
"REMOVED_SWITCHED": "«{{name}}» removida, mudou para «{{next}}»",
"REMOVE_FAILED": "Não foi possível remover «{{name}}»",
"ADD_SOURCE": "Adicionar uma fonte"
},
"CONTEXT": {
"CATEGORIES": "Categorias",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Mover seleção da paleta de comandos",
"COMMAND_PALETTE_RUN": "Executar item selecionado da paleta de comandos",
"CLOSE_DIALOGS": "Fechar diálogo ou dispensar sobreposição",
"DOWNLOADS_OPEN_ITEM": "Abrir o download em foco na biblioteca"
"DOWNLOADS_OPEN_ITEM": "Abrir o download em foco na biblioteca",
"OPEN_PLAYLIST_SWITCHER": "Mudar de lista"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Adicione uma playlist e seu histórico de visualização aparecerá aqui.",
"NO_ITEMS_TITLE": "Nada assistido ainda",
"NO_ITEMS_BODY": "Comece a assistir qualquer item da sua biblioteca e ele aparecerá aqui."
},
"SWITCHER": {
"SEARCH": "Procurar uma lista…",
"RECENT": "Recentes",
"NO_MATCHES": "Nada encontrado",
"ALL_SOURCES": "Todas as fontes",
"HINT_SELECT": "escolher",
"HINT_OPEN": "abrir",
"HINT_CLOSE": "fechar"
}
},
"LOADING": "A carregar",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Имени (Я-А)",
"NEWEST": "Дате добавления (сначала новые)",
"OLDEST": "Дате добавления (сначала старые)",
"CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)"
"CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)",
"UPDATED": "Последнему обновлению",
"CHANNELS": "Числу каналов",
"FAILING_FIRST": "Сначала неактивные"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (локально, URL, текст)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker плейлисты",
"ALL_PLAYLISTS": "Все плейлисты",
"PLAYLIST_COUNT_ONE": "1 плейлист",
"PLAYLIST_COUNT_OTHER": "{{count}} плейлистов"
"PLAYLIST_COUNT_OTHER": "{{count}} плейлистов",
"ROW": {
"TYPE_M3U_FILE": "M3U · файл",
"ADDED": "добавлен {{date}}",
"UPDATED": "обновлён {{date}}",
"UNREACHABLE": "Не отвечает",
"UNREACHABLE_FOR": "Не отвечает · {{duration}}",
"EXPIRED": "Истёк",
"EDIT": "Изменить…",
"COPY_URL": "Копировать URL",
"OPEN": "Открыть",
"CHANNELS_ONE": "1 канал"
},
"STATUS": "Статус",
"STATUS_ANY": "Любой",
"STATUS_OK": "Отвечают",
"STATUS_BAD": "Не отвечают",
"NO_MATCHES": "Нет плейлистов с такими фильтрами",
"RESET_FILTERS": "Сбросить фильтры",
"REMOVED": "Удалён «{{name}}»",
"REMOVED_SWITCHED": "Удалён «{{name}}», открыт «{{next}}»",
"REMOVE_FAILED": "Не удалось удалить «{{name}}»",
"ADD_SOURCE": "Добавить источник"
},
"CONTEXT": {
"CATEGORIES": "Категории",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Переместить выбор в палитре команд",
"COMMAND_PALETTE_RUN": "Выполнить выбранный пункт палитры команд",
"CLOSE_DIALOGS": "Закрыть диалог или скрыть оверлей",
"DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке"
"DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке",
"OPEN_PLAYLIST_SWITCHER": "Переключить плейлист"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Добавьте плейлист, и история просмотра появится здесь.",
"NO_ITEMS_TITLE": "Пока ничего не просмотрено",
"NO_ITEMS_BODY": "Начните смотреть любой контент из библиотеки, и он появится здесь."
},
"SWITCHER": {
"SEARCH": "Найти плейлист…",
"RECENT": "Недавние",
"NO_MATCHES": "Ничего не найдено",
"ALL_SOURCES": "Все источники",
"HINT_SELECT": "выбор",
"HINT_OPEN": "открыть",
"HINT_CLOSE": "закрыть"
}
},
"LOADING": "Загрузка",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "İsim (Z-A)",
"NEWEST": "Eklenme tarihi (En yeni önce)",
"OLDEST": "Eklenme tarihi (En eski önce)",
"CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)"
"CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)",
"UPDATED": "Son güncelleme",
"CHANNELS": "En çok kanal",
"FAILING_FIRST": "Önce yanıt vermeyenler"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (yerel, url, metin)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker Oynatma Listeleri",
"ALL_PLAYLISTS": "Tüm Oynatma Listeleri",
"PLAYLIST_COUNT_ONE": "1 oynatma listesi",
"PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi"
"PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi",
"ROW": {
"TYPE_M3U_FILE": "M3U · dosya",
"ADDED": "{{date}} eklendi",
"UPDATED": "{{date}} güncellendi",
"UNREACHABLE": "Yanıt vermiyor",
"UNREACHABLE_FOR": "Yanıt vermiyor · {{duration}}",
"EXPIRED": "Süresi doldu",
"EDIT": "Düzenle…",
"COPY_URL": "URL'yi kopyala",
"OPEN": "Aç",
"CHANNELS_ONE": "1 kanal"
},
"STATUS": "Durum",
"STATUS_ANY": "Tümü",
"STATUS_OK": "Yanıt veriyor",
"STATUS_BAD": "Yanıt vermiyor",
"NO_MATCHES": "Bu filtrelere uyan oynatma listesi yok",
"RESET_FILTERS": "Filtreleri sıfırla",
"REMOVED": "“{{name}}” kaldırıldı",
"REMOVED_SWITCHED": "“{{name}}” kaldırıldı, “{{next}}” açıldı",
"REMOVE_FAILED": "“{{name}}” kaldırılamadı",
"ADD_SOURCE": "Kaynak ekle"
},
"CONTEXT": {
"CATEGORIES": "Kategoriler",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Komut paleti seçimini taşı",
"COMMAND_PALETTE_RUN": "Seçili komut paleti öğesini çalıştır",
"CLOSE_DIALOGS": "İletişim kutusunu kapat veya katmanı gizle",
"DOWNLOADS_OPEN_ITEM": "Odaktaki indirme öğesini kitaplıkta aç"
"DOWNLOADS_OPEN_ITEM": "Odaktaki indirme öğesini kitaplıkta aç",
"OPEN_PLAYLIST_SWITCHER": "Oynatma listesini değiştir"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Bir oynatma listesi ekleyin; izleme geçmişiniz burada görünecek.",
"NO_ITEMS_TITLE": "Henüz hiçbir şey izlenmedi",
"NO_ITEMS_BODY": "Kitaplığınızdaki herhangi bir şeyi izlemeye başlayın, burada görünecek."
},
"SWITCHER": {
"SEARCH": "Oynatma listesi bul…",
"RECENT": "Son açılanlar",
"NO_MATCHES": "Hiçbir şey bulunamadı",
"ALL_SOURCES": "Tüm kaynaklar",
"HINT_SELECT": "seç",
"HINT_OPEN": "aç",
"HINT_CLOSE": "kapat"
}
},
"LOADING": "Yükleniyor",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名称(Z-A)",
"NEWEST": "添加日期(最新优先)",
"OLDEST": "添加日期(最旧优先)",
"CUSTOM_ORDER": "自定义顺序(拖放)"
"CUSTOM_ORDER": "自定义顺序(拖放)",
"UPDATED": "最近更新",
"CHANNELS": "频道数量",
"FAILING_FIRST": "无响应的优先"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(本地、URL、文本)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker 播放列表",
"ALL_PLAYLISTS": "所有播放列表",
"PLAYLIST_COUNT_ONE": "1 个播放列表",
"PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表"
"PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表",
"ROW": {
"TYPE_M3U_FILE": "M3U · 文件",
"ADDED": "{{date}} 添加",
"UPDATED": "{{date}} 更新",
"UNREACHABLE": "无响应",
"UNREACHABLE_FOR": "无响应 · {{duration}}",
"EXPIRED": "已过期",
"EDIT": "编辑…",
"COPY_URL": "复制 URL",
"OPEN": "打开",
"CHANNELS_ONE": "1 个频道"
},
"STATUS": "状态",
"STATUS_ANY": "全部",
"STATUS_OK": "有响应",
"STATUS_BAD": "无响应",
"NO_MATCHES": "没有符合这些筛选条件的播放列表",
"RESET_FILTERS": "重置筛选",
"REMOVED": "已删除“{{name}}”",
"REMOVED_SWITCHED": "已删除“{{name}}”,已切换到“{{next}}”",
"REMOVE_FAILED": "无法删除“{{name}}”",
"ADD_SOURCE": "添加来源"
},
"CONTEXT": {
"CATEGORIES": "分类",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "移动命令面板选中项",
"COMMAND_PALETTE_RUN": "运行选中的命令面板项目",
"CLOSE_DIALOGS": "关闭对话框或取消覆盖层",
"DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项"
"DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项",
"OPEN_PLAYLIST_SWITCHER": "切换播放列表"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "添加播放列表后,您的观看历史会显示在这里。",
"NO_ITEMS_TITLE": "还没有观看记录",
"NO_ITEMS_BODY": "开始观看媒体库中的任意内容后,它会显示在这里。"
},
"SWITCHER": {
"SEARCH": "查找播放列表…",
"RECENT": "最近",
"NO_MATCHES": "未找到",
"ALL_SOURCES": "所有来源",
"HINT_SELECT": "选择",
"HINT_OPEN": "打开",
"HINT_CLOSE": "关闭"
}
},
"LOADING": "加载中",
+38 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名稱(Z-A)",
"NEWEST": "新增日期(最新優先)",
"OLDEST": "新增日期(最舊優先)",
"CUSTOM_ORDER": "自訂順序(拖放)"
"CUSTOM_ORDER": "自訂順序(拖放)",
"UPDATED": "最近更新",
"CHANNELS": "頻道數量",
"FAILING_FIRST": "無回應的優先"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(本機、網址、文字)",
@@ -1906,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker 播放清單",
"ALL_PLAYLISTS": "所有播放清單",
"PLAYLIST_COUNT_ONE": "1 個播放清單",
"PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單"
"PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單",
"ROW": {
"TYPE_M3U_FILE": "M3U · 檔案",
"ADDED": "{{date}} 新增",
"UPDATED": "{{date}} 更新",
"UNREACHABLE": "無回應",
"UNREACHABLE_FOR": "無回應 · {{duration}}",
"EXPIRED": "已過期",
"EDIT": "編輯…",
"COPY_URL": "複製 URL",
"OPEN": "開啟",
"CHANNELS_ONE": "1 個頻道"
},
"STATUS": "狀態",
"STATUS_ANY": "全部",
"STATUS_OK": "有回應",
"STATUS_BAD": "無回應",
"NO_MATCHES": "沒有符合這些篩選條件的播放清單",
"RESET_FILTERS": "重設篩選",
"REMOVED": "已移除「{{name}}」",
"REMOVED_SWITCHED": "已移除「{{name}}」,已切換到「{{next}}」",
"REMOVE_FAILED": "無法移除「{{name}}」",
"ADD_SOURCE": "新增來源"
},
"CONTEXT": {
"CATEGORIES": "類別",
@@ -1975,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "移動命令面板選取項",
"COMMAND_PALETTE_RUN": "執行選取的命令面板項目",
"CLOSE_DIALOGS": "關閉對話框或取消覆蓋層",
"DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目"
"DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目",
"OPEN_PLAYLIST_SWITCHER": "切換播放清單"
}
},
"GLOBAL_FAVORITES": {
@@ -1998,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "新增播放清單後,您的觀看歷史會顯示在這裡。",
"NO_ITEMS_TITLE": "還沒有觀看記錄",
"NO_ITEMS_BODY": "開始觀看媒體庫中的任意內容後,它會顯示在這裡。"
},
"SWITCHER": {
"SEARCH": "尋找播放清單…",
"RECENT": "最近",
"NO_MATCHES": "找不到",
"ALL_SOURCES": "所有來源",
"HINT_SELECT": "選擇",
"HINT_OPEN": "開啟",
"HINT_CLOSE": "關閉"
}
},
"LOADING": "載入中",
+33 -1
View File
@@ -65,6 +65,9 @@ $dark-theme: mat.define-theme(
// `@angular/material/<entry>` import and remove it with the last one:
// `pnpm run styles:material-theme-scope:validate` (CI) checks both
// directions, and `core` (ripples, options, pseudo-checkboxes) always stays.
// `fab` follows the templates instead: the button import carries its
// directives either way, so it is listed only while a template renders
// `mat-fab` or `mat-mini-fab` (its tokens alone were about 6 KB).
$material-components: (
core,
card,
@@ -81,7 +84,6 @@ $material-components: (
checkbox,
button,
icon-button,
fab,
snack-bar,
progress-spinner,
button-toggle,
@@ -219,6 +221,23 @@ html {
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
--app-rating-color: #7a4a00;
// Source lists (Sources page, playlist switcher) and their popovers.
--app-line-strong: rgba(0, 0, 0, 0.12);
--app-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
// A failing source's tag and destructive menu items.
--app-danger-text: #b3261e;
--app-danger-surface: color-mix(in srgb, #d12c2c 12%, transparent);
--app-neutral-tag-surface: rgba(0, 0, 0, 0.06);
// One selected-row look for side lists and the switcher's current row:
// the Settings navigation's soft tint, plus an outline in light theme
// (selection reads as outline + tint here, not a solid slab). Light
// text stays the heading colour: the blue on its own tint is 3.3:1.
--app-list-selected-bg: var(--app-selection-surface);
--app-list-selected-fg: var(--app-heading-color);
--app-list-selected-outline: var(--app-selection-border);
--app-popover-shadow: 0 24px 70px rgba(15, 20, 28, 0.18);
--app-popover-backdrop: rgba(20, 24, 32, 0.28);
.dark-theme {
@include component-colors($dark-theme);
// Only the color roles differ per theme: the elevation shadows are
@@ -324,6 +343,19 @@ html {
--app-cta-hover-bg: #ffffff;
--app-cta-meta-fg: #5a6274;
--app-rating-color: #ffcf5c;
// Source lists and popovers. The selected-row tokens are
// re-declared so they resolve against this context's selection
// colour, not the light values computed on `html`.
--app-line-strong: rgba(255, 255, 255, 0.1);
--app-danger-text: #ff8d9b;
--app-danger-surface: rgba(255, 91, 110, 0.14);
--app-neutral-tag-surface: rgba(255, 255, 255, 0.08);
--app-list-selected-bg: var(--app-selection-surface);
--app-list-selected-fg: var(--app-selection-color);
--app-list-selected-outline: transparent;
--app-popover-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
--app-popover-backdrop: rgba(4, 6, 10, 0.5);
}
}
+123 -19
View File
@@ -38,6 +38,12 @@ Use it when changing existing views or introducing new list-based UI in the work
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss`
- Shared list selection style:
`libs/ui/styles/_nav-list.scss`
- Source row (Sources page and playlist switcher):
`libs/playlist/shared/ui/src/lib/source-row/source-row.component.html`
- UI-kit buttons, menus and segmented control:
`libs/ui/styles/_app-controls.scss`
- Side lists (title, group labels, single-choice rows):
`libs/ui/styles/_side-list.scss`
- Theme tokens:
`apps/web/src/m3-theme.scss`
- Settings surfaces:
@@ -67,6 +73,15 @@ in `apps/web/src/m3-theme.scss`):
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
- `--app-success-color` — done/watched status (episode watched toggle)
- `--app-list-selected-bg` / `--app-list-selected-fg` /
`--app-list-selected-outline` — the chosen row of a side list and the
active source's row on Sources and in the switcher (see Selection Pattern)
- `--app-line-strong` — popover and menu borders, a step above
`--app-widget-header-border`
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag and
destructive menu items; `--app-neutral-tag-surface` for a neutral tag
- `--app-font-mono` — URLs and keyboard hints (JetBrains Mono, 400/500)
- `--app-popover-shadow` / `--app-popover-backdrop` — the switcher popover
Angular Material mixins and Material-component overrides may use the tokens
owned by that component. Outside a Material-owned component, prefer the
@@ -121,7 +136,10 @@ Everything else is lazy by these rules:
and the dark colours. Add an entry with a new `@angular/material/<entry>`
import and remove it with the last one;
`pnpm run styles:material-theme-scope:validate` (CI) checks both directions
(`core` always stays, `button` brings `icon-button` and `fab`).
(`core` always stays, `button` brings `icon-button`; `fab` is listed only
while a runtime template renders `mat-fab` or `mat-mini-fab`, since the
button import carries the directives either way and the fab tokens alone
are about 6 KB).
- **No animations module.** Nothing declares an Angular animation trigger and
Material 22 animates with CSS, deciding from `prefers-reduced-motion` itself,
so `app.config.ts` does not call `provideAnimations()`; the Electron
@@ -216,6 +234,13 @@ Use this pattern for:
Do not add extra badges, left rails, or second selection systems unless there is a strong reason.
Side lists styled with `_side-list.scss` (the Sources filters) and the
active source's row (Sources page and playlist switcher) use the Settings
navigation's variant
instead, through `--app-list-selected-*`: a soft tint with no lift or glow,
outlined in light theme. Its text is the heading colour in light theme (the
selection blue on its own tint is 3.3:1) and the selection colour in dark.
## Single-Choice Menus And Status Chips
A `mat-menu` that picks one value, such as a sort order or a rating
@@ -241,6 +266,15 @@ threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
- A menu with several groups, such as the catalog refine menu, wraps each
group in `role="group"`. The group is named by its section title, and
`role="separator"` sits between groups.
- Keep a menu short enough to open whole below its trigger in a 600px-tall
window. Material 22 sets no max-height on a menu panel: a taller menu is
pushed up over its own trigger, then clipped. Move a secondary group into
a submenu whose row shows the active value, as the refine menu does with
its rating thresholds.
- Material offsets a submenu by the `offsetTop` of the parent menu's first
direct `mat-menu-item`. When the earlier rows sit inside a `role="group"`,
that first direct item is the submenu trigger itself, and the submenu
opens off screen. Wrap such a trigger in `<div role="none">`.
A chip that shows the active state next to its menu trigger, such as the
catalog sort chip, shows its icon and a short value at every width, for
@@ -1360,27 +1394,97 @@ A visual change is not done until:
### Source row keyboard access
A Sources row (`app-playlist-item`) opens on a click anywhere on it, and it
also holds a drag handle, a health indicator and actions. Like
`app-content-card`, it opens from the keyboard through a dedicated element
that is a sibling of those controls: the title and meta block
`.playlist-content` (`role="button"`, `tabindex="0"`, named by the source
title, `aria-current` on the active source, `aria-disabled` while busy).
Enter and Space open the source (Space prevents the page scroll), and Tab
reaches each row before its actions. Never put `role="button"` or key
handlers on the whole row: the actions would sit inside a button, and their
Enter and Space would bubble into it. The row draws the ring for that
element, inset (`$offset: -2px`), because the list's scroller would cut a
ring drawn outside the full-width row. `sources-keyboard.e2e.ts` (Electron)
tabs to a row, checks its ring and opens the source with Enter.
A Sources row (`app-source-row`, regular size) opens on a click anywhere on
it, and it also holds a drag handle and its actions. Like `app-content-card`,
it opens from the keyboard through a dedicated element that is a sibling of
those controls: the title and meta block `.source-row__body`
(`role="button"`, `tabindex="0"`, named by the source's display name,
`aria-current` on the active source, `aria-disabled` while busy). Enter and
Space open the source (Space prevents the page scroll), and Tab reaches each
row before its actions. Never put `role="button"` or key handlers on the whole
row: the actions would sit inside a button, and their Enter and Space would
bubble into it. The row draws the ring for that element, inset
(`$offset: -2px`), because the list's scroller would cut a ring drawn outside
the full-width row. `R` on a focused row refreshes it, as its menu item shows.
`sources-keyboard.e2e.ts` (Electron) tabs to a row, checks its ring, opens
the source with Enter, and deletes and undoes a source keyboard-only.
### Sources page and playlist switcher
One `app-source-row` serves both lists: `size="regular"` on the Sources page
(56px, 36px tile, hover actions Refresh, Edit and ⋯; a failing row keeps
Retry in view; touch screens always show the actions) and `size="compact"`
in the switcher (52px, 32px tile, ⋯ only). In both the active source's row
is tinted with the selected-row token; the switcher adds a check. The page
header (title, Clean up, Sort) stays; only the list below it scrolls. The meta line is always type · channels · added · updated · URL, with
empty parts left out; only M3U playlists store a channel count. The helpers
in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`,
`source-list`) own that text, the filters and the counts.
- **No credentials on screen.** Names come from `sourceDisplayName`: a URL, a
URL-derived name such as `get.php?username=…&password=…` or any name holding
the source's password, MAC or URL secrets becomes the host. URLs come from
`sourceDisplayUrl`, built on `stripUrlCredentials` (beside it in
`source-display`, out of the eager logging barrel): no
userinfo, sensitive query or fragment parameters, or Xtream path
credentials; Xtream shows `scheme://host:port · username`. Copy URL copies
the full address on request.
- **Delete with Undo.** A single delete has no confirmation: the row leaves
every list at once (`SourceRemovalService.pendingIds`) and the delete runs
when the four-second Undo window ends. The window is the service's own
timer, not the toast's: another message replacing the toast (a refresh
finishing elsewhere) does not shorten it. The toast's close button commits
at once, and quitting within the four seconds keeps the source. Deleting the active source
switches to the next row; Undo switches back only if no other source was
chosen since. A desktop Xtream delete brings its row back as "Removing…"
with progress and Cancel on the Sources page; the switcher leaves the
source out until the delete ends. When the delete took the row and with
it the focus, the toast's Undo takes the focus. A delete from the
switcher closes the popover first, since its focus trap would keep the
keyboard from Undo: Undo takes the focus and returns it to the trigger
(`SourceRemovalOptions.returnFocusTo`). A source cannot be removed while
a refresh clears and refills it: every refresh (a Sources row, the
switcher, the Xtream flow, the startup auto-refresh), every Xtream import
and every delete holds the source in the root `SourceActivityService`,
which both lists, the removal service and a second refresh consult. The
Xtream route session takes its hold on the refresh's `NavigationEnd`, so
the catalog is never idle between the delete and the re-import. The Sources page shows
a switcher refresh as busy. Bulk cleanup keeps its confirmation dialog.
- **Side list.** Type and, on desktop, Status (Any / Responding / Not
responding) combine and persist (`SourcesViewStateService`); each group
counts within the other's choice. The page checks every source, not only
the rows a filter shows (`keepSourcesChecked`): a saved status filter
would otherwise hide the unchecked sources it is waiting for.
- **Switcher.** The trigger is a 40px secondary button; ⌘P / Ctrl+P opens it
(⌘K stays the command palette). The popover is a CDK connected overlay, so
row menus open above it, not inside its scrolling list. Focus stays in the
search field, which drives a `role="listbox"` through
`aria-activedescendant`: ↑/↓ move and wrap, Enter opens, Shift+F10 opens
the highlighted row's menu, Escape closes and returns focus to the
trigger. The compact row's ⋯ is pointer-only (`aria-hidden`, not
focusable), since an option may not contain a control. "Recent" lists the
three most recently opened sources once there are six or more. The search
field is the shared 36px search field (`--app-search-*`). The popover is a
height-capped column in which only the list shrinks and scrolls: every
other part is `flex: none`. Otherwise a long list (hundreds of sources)
squeezes them in proportion to its size, and the type filter collapses.
### Network source indicators (Electron)
The switcher and source rows use `SourceHealthIndicatorComponent`: green means
available, orange expired, red disabled/access refused, and neutral means
checking or unverified. Accessible tooltip text distinguishes refusal from a
confirmed disabled account and includes the check time. M3U indicators describe
the source URL only. Check again is a separate action, not a playlist refresh.
The status dot on a source tile reads `SourceStatusService`: green answers,
red does not, orange is an expired account, grey is not checked yet; local
files have no dot. On desktop the live `SourceHealthService` result wins; the
last conclusive result per source is kept across restarts
(`SourceHealthHistoryService`, no URL or credential stored, only a 32-bit
fingerprint of the connection), so a source that failed yesterday shows as
failing before it is checked again, with "Not responding · 3 days". An
edited address or account is a new connection: its kept result no longer
counts, and its history starts over. The dot's tooltip and accessible name give the
check's cause (`SourceStatus.reason`: access denied, account disabled,
timeout…, kept with the history) and check time; M3U checks say they
cover the playlist URL, not its channels. Retry and Check
again rerun the check; they are not a playlist refresh. The browser build
keeps the Xtream portal status only.
### Inactive-source cleanup dialog
+9
View File
@@ -1856,6 +1856,15 @@ shared data access. Electron checks Xtream account info, Stalker account/profile
facts through the existing session, and the first 64 KiB of M3U URL responses.
An M3U success describes the playlist URL, not every channel. Local files and
text imports have no network indicator. PWA retains its existing Xtream path.
Lists read one status per source through `SourceStatusService`, which falls
back to the last conclusive result kept by `SourceHealthHistoryService`
(localStorage, by playlist ID: state, check time, the start of a failing
run, the check's cause and a 32-bit fingerprint of the checked connection;
no URL or credential). A result kept for an edited address or account is
ignored, and the next check starts the history over. Deleting a source or
resetting the library clears its entry. The Sources page keeps every
library source checked while it is open (`keepSourcesChecked`), so a status
filter never hides the sources it is waiting for. See the [UI guidelines](iptvnator-ui-guidelines.md#network-source-indicators-electron).
Checks are demand-driven, never a startup readiness barrier: four at a time,
two per origin, shared between surfaces. Known results remain visible during
+38 -3
View File
@@ -174,9 +174,10 @@ navigation (Back, drawer toggle) changes.
ends: `perf:compositing` reads the same layer and mask counts on every
route as before the slides (#1895).
6. **Not covered.** A slide does not change what the route renders. Going
back from a portal to the dashboard still shows one frame with the new
dashboard below the old page before the old page leaves; that jump is a
routing issue, not a layout slide.
back from a portal to the dashboard used to lay the new dashboard out
below the old page before the old page left, a shift a slide cannot
hide. That was the route swap, fixed at its cause (see
[Route swaps](#route-swaps)).
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
@@ -207,6 +208,40 @@ When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
for the template unless the template contract itself intentionally changes.
### Route swaps
The router's activation runs in one task, outside change detection:
deactivation destroys the old routed view, then activation creates the new
one and inserts its host element into `main.workspace-content` at once.
`WorkspaceShellRouteStateService.currentUrl` is set on `NavigationEnd`, which
the router emits synchronously after activation, so the shell's state does
not lag the outlet. The shell re-renders (the context panel leaves, the
content frame slides) in the next change detection. With event coalescing
that runs in the next `requestAnimationFrame`, before that frame paints.
The app does not provide `provideAnimations()`. Nothing uses
`@angular/animations` triggers (Material animates with CSS), and that
package's renderer queues every DOM removal until the end of the next change
detection, while insertions apply at once. Leaving a portal section for the
dashboard therefore inserted the dashboard below the still-present portal
page, and the old page left only in the coalesced tick. In between, Chromium
laid out the document with both pages, the dashboard at y=720 in a 720 px
window, and scored the swap as one layout shift: 0.337 at 1280 px, 0.394 at
900 px, 0.723 at 420 px (measured with a `layout-shift` observer in the
`electron-performance` build). With the default DOM renderer the old page is
removed in the same microtask, before the new one is inserted, and the swap
records no shift. `app.config.spec.ts` keeps the animations renderer out, and
`workspace-route-swap.e2e.ts` asserts that no mutation checkpoint holds both
pages and that neither the dashboard nor the content pane shifts.
Destroy hooks therefore run with the view's DOM already detached; the
[UI guidelines](./iptvnator-ui-guidelines.md) ("No animations module") say
how hooks record geometry and focus instead.
Do not add `provideAnimations()` or `provideAnimationsAsync()` back; animate
with CSS. A leave animation (`animate.leave` included) delays its element's
removal in the same way, so a routed page must not carry one.
## Header Back
The header's leading slot is the workspace's one page-level Back. Pages do not
+3 -5
View File
@@ -61,7 +61,7 @@ must be ticked here.
## Eager components
65 production files, 66 components (`epg-progress-panel.component.ts` holds
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
two); two have since been deleted (the old Sources row and its health dot). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
(or setting OnPush) once its template state is signals, signal inputs or
explicitly marked. The guard spec compares the unticked entries with the
files whose component metadata still sets
@@ -127,7 +127,7 @@ picker), so each marks itself on the form's `events` through
- [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
- [x] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts`
### libs/playlist (14)
### libs/playlist (12)
- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
@@ -140,9 +140,7 @@ picker), so each marks itself on the form's `events` through
- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
@@ -173,7 +171,7 @@ the field a signal (or a `computed`), or writes it through one.
| --- | --- | --- | --- |
| [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
| [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [x] | `libs/portal/shared/data-access/src/lib/source-status.service.ts` `checkPortal` (replaced the old Sources row's `checkPortalStatus`) | the PWA Xtream portal status lands after `await`; it is written to a signal | playlist |
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist |
| [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
-7
View File
@@ -93,10 +93,3 @@ export const FavoritesActions = createActionGroup({
'Hydrate Favorites': props<{ channelIds: string[] }>(),
},
});
export const FilterActions = createActionGroup({
source: 'Filters',
events: {
'Set Selected Filters': props<{ selectedFilters: string[] }>(),
},
});
@@ -10,7 +10,6 @@ export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
selectedId: string;
allPlaylistsLoaded: boolean;
loadFailed: boolean;
selectedFilters: string[];
}
export const initialPlaylistMetaState: PlaylistMetaState =
@@ -18,7 +17,6 @@ export const initialPlaylistMetaState: PlaylistMetaState =
selectedId: '',
allPlaylistsLoaded: false,
loadFailed: false,
selectedFilters: ['m3u', 'xtream', 'stalker'],
});
export const {
@@ -1,18 +0,0 @@
import { on } from '@ngrx/store';
import { FilterActions } from '../actions';
import { PlaylistState } from '../state';
export const filterReducers = [
on(
FilterActions.setSelectedFilters,
(state, { selectedFilters }): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedFilters,
},
};
}
),
];
+1 -3
View File
@@ -3,7 +3,6 @@ import { initialState, PlaylistState } from '../state';
import { channelReducers } from './channel.reducers';
import { epgReducers } from './epg.reducers';
import { favoritesReducers } from './favorites.reducers';
import { filterReducers } from './filter.reducers';
import { playlistReducers } from './playlist.reducers';
export const playlistReducer = createReducer(
@@ -11,8 +10,7 @@ export const playlistReducer = createReducer(
...epgReducers,
...channelReducers,
...playlistReducers,
...favoritesReducers,
...filterReducers
...favoritesReducers
);
// Selectors
-5
View File
@@ -63,11 +63,6 @@ export const selectAllPlaylistsMeta = createSelector(
fromPlaylistMetaState.getAllPlaylistsMeta
);
export const selectActiveTypeFilters = createSelector(
selectPlaylistsMetaState,
(state) => state.selectedFilters
);
export const selectPlaylistEntity = (id: string) =>
createSelector(selectPlaylistEntities, (entities) => entities[id]);
+4
View File
@@ -1,5 +1,6 @@
export * from './lib/add-playlist-menu/playlist-type';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/playlist-switcher/playlist-switcher-open.service';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token';
export * from './lib/recent-playlists/recent-playlists.component';
@@ -8,3 +9,6 @@ export * from './lib/playlist-refresh-action.service';
export * from './lib/xtream-refresh-flow.service';
export * from './lib/source-health/source-cleanup-dialog.component';
export * from './lib/source-row/source-row.component';
export * from './lib/source-row/source-tile.component';
export * from './lib/source-removal/source-removal.service';
@@ -312,8 +312,15 @@ describe('PlaylistRefreshActionService', () => {
).toBe(true);
});
it('uses the browser URL refresh path when the refresh bridge is unavailable', () => {
it('uses the browser URL refresh path when the refresh bridge is unavailable', async () => {
runtime.supportsPlaylistRefresh = false;
let answer!: () => void;
dataService.sendIpcEvent.mockReturnValueOnce(
new Promise<void>((resolve) => {
answer = resolve;
})
);
const activity = TestBed.inject(SourceActivityService);
const playlist = createPlaylistMeta({
_id: 'playlist-url',
title: 'URL playlist',
@@ -333,6 +340,15 @@ describe('PlaylistRefreshActionService', () => {
userAgent: 'IPTVnator-Test/1.0',
});
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
// Busy, and held for removal and other refreshes, until it answers.
expect(service.isSourceBusy('playlist-url')).toBe(true);
expect(activity.isBusy('playlist-url')).toBe(true);
answer();
await Promise.resolve();
await Promise.resolve();
expect(service.isSourceBusy('playlist-url')).toBe(false);
expect(activity.isBusy('playlist-url')).toBe(false);
});
it('tracks a header M3U refresh by source until the operation settles', async () => {
@@ -349,13 +365,27 @@ describe('PlaylistRefreshActionService', () => {
resolve = r;
})
);
const activity = TestBed.inject(SourceActivityService);
service.refresh(playlist);
expect(service.isSourceBusy('busy')).toBe(true);
expect(service.isSourceBusy('other')).toBe(false);
// One refresh at a time: no other source is offered meanwhile.
const other = createPlaylistMeta({
_id: 'other',
serverUrl: undefined,
username: undefined,
password: undefined,
url: 'https://source.test/other',
});
expect(service.canRefresh(other)).toBe(false);
// The Sources page and the removal guard see it too.
expect(activity.isBusy('busy')).toBe(true);
resolve({ _id: 'busy', playlist: { items: [] } } as Playlist);
await Promise.resolve();
await Promise.resolve();
expect(service.isSourceBusy('busy')).toBe(false);
expect(activity.isBusy('busy')).toBe(false);
expect(service.canRefresh(other)).toBe(true);
});
it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => {
@@ -64,7 +64,13 @@ export class PlaylistRefreshActionService {
readonly refreshPreparation = this.refreshPreparationState.asReadonly();
canRefresh(playlist: PlaylistMeta | null): boolean {
if (!playlist || this.activity.isBusy(playlist._id)) {
// The same guards as refresh(): one refresh runs at a time, so an
// offer made meanwhile would be a silent no-op.
if (
!playlist ||
this.isRefreshing() ||
this.activity.isBusy(playlist._id)
) {
return false;
}
@@ -94,14 +100,32 @@ export class PlaylistRefreshActionService {
) {
void this.refreshM3u(playlist);
} else if (playlist.url) {
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: playlist._id,
title: playlist.title,
url: playlist.url,
...(playlist.userAgent
? { userAgent: playlist.userAgent }
: {}),
void this.refreshViaBackend(playlist, playlist.url);
}
}
/**
* The browser build refreshes through the backend, which reports its own
* success or failure; the source stays busy until it has answered.
*/
private async refreshViaBackend(
item: PlaylistMeta,
url: string
): Promise<void> {
this.m3uRefreshId.set(item._id);
this.isRefreshing.set(true);
const release = this.activity.begin([item._id]);
try {
await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
});
} finally {
this.m3uRefreshId.set(null);
this.isRefreshing.set(false);
release();
}
}
@@ -148,6 +172,8 @@ export class PlaylistRefreshActionService {
this.m3uRefreshId.set(item._id);
this.isRefreshing.set(true);
// Seen by the Sources page and the removal guard as well.
const release = this.activity.begin([item._id]);
if (isActiveM3uRoute) {
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: true })
@@ -214,6 +240,7 @@ export class PlaylistRefreshActionService {
}
this.m3uRefreshId.set(null);
this.isRefreshing.set(false);
release();
}
}
@@ -0,0 +1,108 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
buildSwitcherGroups,
moveHighlight,
readSwitcherType,
SWITCHER_TYPE_STORAGE_KEY,
writeSwitcherType,
} from './playlist-switcher-list';
const source = (id: string, fields: Partial<PlaylistMeta> = {}) =>
({
_id: id,
title: id,
count: 0,
importDate: '',
autoRefresh: false,
...fields,
}) as PlaylistMeta;
const many = [
source('a', { url: 'http://a.example/list.m3u' }),
source('b', {
serverUrl: 'http://b.example',
username: 'u',
password: 'p',
}),
source('c', { macAddress: 'm', portalUrl: 'http://c.example/c/' }),
source('d'),
source('e'),
source('f'),
];
describe('buildSwitcherGroups', () => {
it('lists recent sources first, newest first, and all of them below', () => {
const groups = buildSwitcherGroups(many, ['c', 'gone', 'a'], 'all', '');
expect(groups.recent.map((o) => o.playlist._id)).toEqual(['c', 'a']);
expect(groups.all).toHaveLength(6);
expect(groups.recent[0].id).not.toBe(
groups.all.find((o) => o.playlist._id === 'c')?.id
);
});
it('hides Recent while searching and for a short list', () => {
expect(buildSwitcherGroups(many, ['a'], 'all', 'b').recent).toEqual([]);
expect(
buildSwitcherGroups(many.slice(0, 3), ['a'], 'all', '').recent
).toEqual([]);
// A long library filtered down to one type is a short list too.
expect(buildSwitcherGroups(many, ['b'], 'xtream', '').recent).toEqual(
[]
);
});
it('filters by type and by name or host', () => {
expect(
buildSwitcherGroups(many, [], 'xtream', '').all.map(
(o) => o.playlist._id
)
).toEqual(['b']);
expect(
buildSwitcherGroups(many, [], 'all', 'c.example').all.map(
(o) => o.playlist._id
)
).toEqual(['c']);
});
});
describe('moveHighlight', () => {
it('wraps at both ends and starts from either end', () => {
expect(moveHighlight(-1, 1, 3)).toBe(0);
expect(moveHighlight(-1, -1, 3)).toBe(2);
expect(moveHighlight(2, 1, 3)).toBe(0);
expect(moveHighlight(0, -1, 3)).toBe(2);
expect(moveHighlight(1, 1, 0)).toBe(-1);
});
});
describe('switcher type memory', () => {
beforeEach(() => localStorage.clear());
it('remembers the chosen type', () => {
writeSwitcherType('stalker');
expect(readSwitcherType()).toBe('stalker');
});
it('migrates the old checkboxes to one type or All', () => {
localStorage.setItem(
'playlist-switcher:type-filters',
JSON.stringify({ m3u: false, stalker: false, xtream: true })
);
localStorage.setItem('playlist-switcher:search-query', 'old query');
expect(readSwitcherType()).toBe('xtream');
expect(localStorage.getItem(SWITCHER_TYPE_STORAGE_KEY)).toBe('xtream');
expect(
localStorage.getItem('playlist-switcher:type-filters')
).toBeNull();
expect(
localStorage.getItem('playlist-switcher:search-query')
).toBeNull();
localStorage.clear();
localStorage.setItem(
'playlist-switcher:type-filters',
JSON.stringify({ m3u: true, stalker: true, xtream: false })
);
expect(readSwitcherType()).toBe('all');
});
});
@@ -0,0 +1,116 @@
import {
matchesSourceQuery,
matchesSourceType,
SourcesTypeFilter,
} from '@iptvnator/playlist/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
export const SWITCHER_TYPE_STORAGE_KEY = 'playlist-switcher:type:v2';
/** The three checkboxes the segmented type filter replaced. */
const LEGACY_TYPE_STORAGE_KEY = 'playlist-switcher:type-filters';
/** The search is no longer kept between openings. */
const LEGACY_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
const TYPES: readonly SourcesTypeFilter[] = ['all', 'm3u', 'xtream', 'stalker'];
/** Below this many sources the "Recent" group would repeat the whole list. */
export const SWITCHER_RECENT_MIN_SOURCES = 6;
/**
* The remembered type. The old checkbox set maps to the one type it left
* on, or to "All" when it left several (the only states it could save).
*/
export function readSwitcherType(): SourcesTypeFilter {
try {
const stored = localStorage.getItem(SWITCHER_TYPE_STORAGE_KEY);
if (TYPES.includes(stored as SourcesTypeFilter)) {
return stored as SourcesTypeFilter;
}
const legacy = JSON.parse(
localStorage.getItem(LEGACY_TYPE_STORAGE_KEY) ?? 'null'
) as Record<string, boolean> | null;
localStorage.removeItem(LEGACY_QUERY_STORAGE_KEY);
if (legacy && typeof legacy === 'object') {
const on = (['m3u', 'xtream', 'stalker'] as const).filter(
(type) => legacy[type] !== false
);
localStorage.removeItem(LEGACY_TYPE_STORAGE_KEY);
const type = on.length === 1 ? on[0] : 'all';
writeSwitcherType(type);
return type;
}
} catch {
// Fall through to the default.
}
return 'all';
}
export function writeSwitcherType(type: SourcesTypeFilter): void {
try {
localStorage.setItem(SWITCHER_TYPE_STORAGE_KEY, type);
} catch {
// The choice still applies while the app runs.
}
}
export interface SwitcherOption {
/** Unique per group: a recent source is also listed under "All". */
id: string;
group: 'recent' | 'all';
playlist: PlaylistMeta;
}
export interface SwitcherGroups {
recent: SwitcherOption[];
all: SwitcherOption[];
}
/**
* The switcher's two groups: "Recent" (newest first, only without a search
* and when enough sources are shown to be worth it) and "All" in the given
* order, both narrowed by type and search.
*/
export function buildSwitcherGroups(
ordered: readonly PlaylistMeta[],
recentIds: readonly string[],
type: SourcesTypeFilter,
query: string
): SwitcherGroups {
const visible = ordered.filter(
(playlist) =>
matchesSourceType(playlist, type) &&
matchesSourceQuery(playlist, query)
);
const option =
(group: SwitcherOption['group']) =>
(playlist: PlaylistMeta): SwitcherOption => ({
id: `playlist-switcher-${group}-${playlist._id}`,
group,
playlist,
});
// Counted after the type filter: a short filtered list would show the
// same sources twice, and the keyboard would visit each twice.
const showRecent =
!query.trim() && visible.length >= SWITCHER_RECENT_MIN_SOURCES;
const byId = new Map(visible.map((playlist) => [playlist._id, playlist]));
const recent = showRecent
? recentIds
.map((id) => byId.get(id))
.filter((playlist): playlist is PlaylistMeta => !!playlist)
.map(option('recent'))
: [];
return { recent, all: visible.map(option('all')) };
}
/** The next highlight for ↑/↓, wrapping at both ends. */
export function moveHighlight(
current: number,
delta: 1 | -1,
count: number
): number {
if (count === 0) {
return -1;
}
if (current < 0) {
return delta > 0 ? 0 : count - 1;
}
return (current + delta + count) % count;
}
@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
/**
* Opens the toolbar playlist switcher from outside it: the ⌘P / Ctrl+P
* shortcut. The switcher subscribes while it is on screen (it is hidden on
* Settings), so a request with no switcher does nothing.
*/
@Injectable({ providedIn: 'root' })
export class PlaylistSwitcherOpenService {
private readonly requests = new Subject<void>();
readonly openRequests = this.requests.asObservable();
private listeners = 0;
/** Whether a switcher is on screen to take the request. */
get available(): boolean {
return this.listeners > 0;
}
request(): void {
this.requests.next();
}
/** Called by the switcher; the returned function unregisters it. */
register(): () => void {
this.listeners += 1;
return () => {
this.listeners -= 1;
};
}
}
@@ -0,0 +1,170 @@
<div
class="switcher"
role="dialog"
aria-modal="true"
aria-labelledby="playlist-switcher-title"
cdkTrapFocus
>
<header class="switcher__header">
<h2 id="playlist-switcher-title" class="switcher__title">
{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}
</h2>
<span class="switcher__count">{{ playlists().length }}</span>
<span class="switcher__spacer"></span>
<button
mat-icon-button
type="button"
class="switcher__action"
data-test-id="playlist-switcher-add"
(click)="addPlaylist()"
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="switcher__action"
(click)="openAllSources()"
[matTooltip]="'WORKSPACE.SWITCHER.ALL_SOURCES' | translate"
[attr.aria-label]="'WORKSPACE.SWITCHER.ALL_SOURCES' | translate"
>
<mat-icon>open_in_new</mat-icon>
</button>
</header>
<label class="switcher__search">
<mat-icon aria-hidden="true">search</mat-icon>
<input
#searchInput
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="true"
aria-keyshortcuts="Shift+F10"
[attr.aria-controls]="listId"
[attr.aria-activedescendant]="activeOptionId()"
[attr.aria-label]="'WORKSPACE.SWITCHER.SEARCH' | translate"
[placeholder]="'WORKSPACE.SWITCHER.SEARCH' | translate"
[value]="query()"
(input)="setQuery(searchInput.value)"
(keydown)="onSearchKeydown($event)"
/>
<kbd aria-hidden="true">↑↓ ↵</kbd>
</label>
@if (showTypeFilter()) {
<mat-button-toggle-group
class="switcher__types"
hideSingleSelectionIndicator
[value]="activeType()"
(change)="setType($event.value)"
[attr.aria-label]="'WORKSPACE.SOURCES.TYPE' | translate"
>
<mat-button-toggle value="all">{{
'WORKSPACE.SOURCES.ALL' | translate
}}</mat-button-toggle>
@for (kind of kinds; track kind) {
@if (typeCounts()[kind] > 0) {
<mat-button-toggle [value]="kind"
>{{ kindLabels[kind] }}
<span class="switcher__type-count">{{
typeCounts()[kind]
}}</span></mat-button-toggle
>
}
}
</mat-button-toggle-group>
}
<div
#list
class="switcher__list"
role="listbox"
[id]="listId"
[attr.aria-label]="'HOME.PLAYLISTS.MY_PLAYLISTS' | translate"
>
@if (groups().recent.length) {
<div role="group" aria-labelledby="playlist-switcher-recent">
<div id="playlist-switcher-recent" class="switcher__group">
{{ 'WORKSPACE.SWITCHER.RECENT' | translate }}
</div>
@for (option of groups().recent; track option.id) {
<ng-container
*ngTemplateOutlet="
row;
context: { $implicit: option, index: $index }
"
/>
}
</div>
}
<div role="group" aria-labelledby="playlist-switcher-all">
@if (groups().recent.length) {
<div id="playlist-switcher-all" class="switcher__group">
{{ 'WORKSPACE.SOURCES.ALL' | translate }}
</div>
}
@for (option of groups().all; track option.id) {
<ng-container
*ngTemplateOutlet="
row;
context: {
$implicit: option,
index: groups().recent.length + $index,
}
"
/>
}
</div>
</div>
@if (!options().length) {
<p class="switcher__empty" role="status">
{{ 'WORKSPACE.SWITCHER.NO_MATCHES' | translate }}
</p>
}
<footer class="switcher__footer">
<span class="switcher__hints" aria-hidden="true">
<kbd>↑↓</kbd>{{ 'WORKSPACE.SWITCHER.HINT_SELECT' | translate }}
<kbd>↵</kbd>{{ 'WORKSPACE.SWITCHER.HINT_OPEN' | translate }}
<kbd>esc</kbd>{{ 'WORKSPACE.SWITCHER.HINT_CLOSE' | translate }}
</span>
<span class="switcher__spacer"></span>
<button
mat-button
type="button"
class="switcher__all"
(click)="openAllSources()"
>
{{ 'WORKSPACE.SWITCHER.ALL_SOURCES' | translate }}
<mat-icon iconPositionEnd>arrow_forward</mat-icon>
</button>
</footer>
</div>
<ng-template #row let-option let-index="index">
<app-source-row
size="compact"
role="option"
[attr.id]="option.id"
[attr.aria-selected]="index === highlight()"
[attr.aria-current]="option.playlist._id === currentId() || null"
[attr.aria-disabled]="isRefreshing(option.playlist) || null"
[playlist]="option.playlist"
[current]="option.playlist._id === currentId()"
[highlighted]="index === highlight()"
[canRefresh]="canRefresh(option.playlist)"
[refreshing]="isRefreshing(option.playlist)"
[removable]="!isBusy(option.playlist)"
(activated)="select(option.playlist)"
(refreshRequested)="refresh(option.playlist)"
(playlistInfoRequested)="openInfo(option.playlist)"
(accountInfoRequested)="openAccountInfo(option.playlist)"
(removeRequested)="remove(option.playlist)"
(menuClosed)="onMenuClosed()"
/>
</ng-template>
@@ -0,0 +1,202 @@
@use '../../../../../../ui/styles/app-controls' as controls;
// 440px popover on the widget surface; only the list scrolls.
:host {
display: block;
width: min(440px, calc(100vw - 16px));
}
.switcher {
display: flex;
flex-direction: column;
max-height: var(--playlist-switcher-max-height, calc(100vh - 88px));
overflow: hidden;
border: 1px solid var(--app-line-strong);
border-radius: 14px;
background: var(--app-widget-bg);
box-shadow: var(--app-popover-shadow);
color: var(--app-heading-color);
}
// Only the list shrinks and scrolls. A column flex container shrinks its
// items in proportion to their size, so with a long list (hundreds of
// sources) the fixed parts lost nearly all their height too: the type
// filter, which clips its overflow, collapsed to 2px and the search field
// to its text line.
.switcher__header,
.switcher__search,
.switcher__types,
.switcher__empty,
.switcher__footer {
flex: none;
}
.switcher__header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 14px 10px;
}
.switcher__title {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.2px;
}
.switcher__count {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color);
}
.switcher__spacer {
flex: 1;
}
.switcher__action {
@include controls.ghost-icon-button(32px, 18px);
}
// The same search field as the header and the side panels: 36px, the
// shared search tokens, focus on the field (token border and halo) rather
// than as an outline on the input.
.switcher__search {
display: flex;
align-items: center;
gap: 8px;
height: 36px;
margin: 0 14px 10px;
padding: 0 10px;
box-sizing: border-box;
border: 1px solid var(--app-search-border, var(--app-line-strong));
border-radius: 10px;
background: var(--app-search-bg, var(--app-content-bg));
color: var(--app-muted-color);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--app-focus-ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--app-focus-ring) 12%, transparent);
}
.mat-icon {
flex: none;
width: 18px;
height: 18px;
font-size: 18px;
}
input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: none;
color: var(--app-heading-color);
font: inherit;
font-size: 0.84rem;
&::placeholder {
color: var(--app-muted-color);
}
}
kbd {
font-family: var(--app-font-mono);
font-size: 11px;
font-weight: 500;
}
}
.switcher__types {
@include controls.segmented-control(30px);
align-self: flex-start;
margin: 0 14px 8px;
}
.switcher__type-count {
margin-inline-start: 4px;
font-size: 11px;
color: var(--app-muted-color);
}
.switcher__list {
// Positioned, so options' offsetTop is relative to the list it scrolls.
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 4px 8px 8px;
border-top: 1px solid var(--app-widget-header-border);
scrollbar-width: thin;
app-source-row {
--source-row-base: var(--app-widget-bg);
}
}
.switcher__group {
padding: 10px 8px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--app-muted-color);
}
.switcher__empty {
margin: 0;
padding: 20px 16px;
font-size: 13px;
color: var(--app-body-color);
}
.switcher__footer {
display: flex;
align-items: center;
gap: 4px;
padding: 8px;
border-top: 1px solid var(--app-widget-header-border);
background: color-mix(in srgb, var(--app-shell-bg) 40%, transparent);
}
.switcher__hints {
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px;
font-family: var(--app-font-mono);
font-size: 11px;
font-weight: 400;
color: var(--app-muted-color);
kbd {
margin-inline-start: 4px;
padding: 0 4px;
border: 1px solid var(--app-line-strong);
border-radius: 3px;
font: inherit;
font-weight: 500;
color: var(--app-body-color);
}
kbd:first-child {
margin-inline-start: 0;
}
}
.switcher__all {
@include controls.ghost-button(sm);
}
// Phones: the hints need a keyboard; the link stays.
@media (max-width: 480px) {
.switcher__hints {
display: none;
}
}
@@ -0,0 +1,407 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import {
PlaylistContextFacade,
PlaylistRecentHistoryService,
} from '@iptvnator/playlist/shared/util';
import {
SortBy,
SortOptions,
SortOrder,
SortService,
SourceActivityService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
import { SourceRemovalService } from '../source-removal/source-removal.service';
import { XtreamRefreshFlowService } from '../xtream-refresh-flow.service';
import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component';
const source = (id: string, fields: Partial<PlaylistMeta> = {}) =>
({
_id: id,
title: id,
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
...fields,
}) as PlaylistMeta;
const ALPHA = source('alpha', { url: 'https://alpha.example/list.m3u' });
const BRAVO = source('bravo', {
serverUrl: 'http://bravo.example',
username: 'u',
password: 'secret-pass',
});
const CHARLIE = source('charlie', {
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://charlie.example/c/',
});
describe('PlaylistSwitcherPanelComponent', () => {
let fixture: ComponentFixture<PlaylistSwitcherPanelComponent>;
let component: PlaylistSwitcherPanelComponent;
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
let deleting: ReturnType<typeof signal<ReadonlySet<string>>>;
let selectPlaylist: jest.Mock;
let remove: jest.Mock;
let navigate: jest.Mock;
let xtreamRefreshing: ReturnType<typeof signal<ReadonlySet<string>>>;
const input = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'.switcher__search input'
) as HTMLInputElement;
const press = (key: string, init: KeyboardEventInit = {}) => {
input().dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, ...init })
);
fixture.detectChanges();
};
const type = (value: string) => {
input().value = value;
input().dispatchEvent(new Event('input'));
fixture.detectChanges();
};
const optionIds = () =>
Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll(
'[role="option"]'
)
).map((option) => option.id);
beforeEach(() => {
localStorage.clear();
playlists = signal([ALPHA, BRAVO, CHARLIE]);
pending = signal<ReadonlySet<string>>(new Set());
deleting = signal<ReadonlySet<string>>(new Set());
selectPlaylist = jest.fn();
remove = jest.fn();
navigate = jest.fn();
xtreamRefreshing = signal<ReadonlySet<string>>(new Set());
TestBed.configureTestingModule({
imports: [
PlaylistSwitcherPanelComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: PlaylistContextFacade,
useValue: {
playlists,
resolvedPlaylistId: signal('bravo'),
selectPlaylist,
},
},
{
provide: PlaylistRecentHistoryService,
useValue: { ids: signal(['charlie']) },
},
{
provide: SortService,
useValue: {
getSortOptions: () =>
new BehaviorSubject<SortOptions>({
by: SortBy.NAME,
order: SortOrder.ASC,
}),
sortPlaylists: new SortService().sortPlaylists,
},
},
{
provide: SourceStatusService,
useValue: {
statusOf: () => null,
tracks: () => false,
ensureChecked: jest.fn(),
recheck: jest.fn(),
},
},
{
provide: SourceRemovalService,
useValue: {
pendingIds: pending,
remove,
progress: {
isDeleting: (id: string) => deleting().has(id),
},
canTakeOver: (id: string) =>
!deleting().has(id) &&
!TestBed.inject(SourceActivityService).isBusy(id),
},
},
{
provide: PlaylistRefreshActionService,
useValue: {
canRefresh: () => true,
refresh: jest.fn(),
isSourceBusy: () => false,
},
},
{
provide: XtreamRefreshFlowService,
useValue: {
isRunning: (id: string) => xtreamRefreshing().has(id),
},
},
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: Router, useValue: { navigate } },
],
});
fixture = TestBed.createComponent(PlaylistSwitcherPanelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('lists every source in the Sources order as listbox options', () => {
expect(optionIds()).toEqual([
'playlist-switcher-all-alpha',
'playlist-switcher-all-bravo',
'playlist-switcher-all-charlie',
]);
const current = (fixture.nativeElement as HTMLElement).querySelector(
'#playlist-switcher-all-bravo'
);
expect(current?.getAttribute('aria-current')).toBe('true');
expect(
(fixture.nativeElement as HTMLElement).textContent
).not.toContain('secret-pass');
});
it('shows Recent above All once the list is long enough', () => {
playlists.set([
ALPHA,
BRAVO,
CHARLIE,
source('d'),
source('e'),
source('f'),
]);
fixture.detectChanges();
expect(optionIds()[0]).toBe('playlist-switcher-recent-charlie');
expect(optionIds()).toHaveLength(7);
});
it('moves the highlight with the arrow keys, wrapping, and opens it on Enter', () => {
press('ArrowDown');
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-alpha'
);
press('ArrowUp');
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-charlie'
);
press('ArrowDown');
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-alpha'
);
const closed = jest.fn();
component.closeRequested.subscribe(closed);
press('Enter');
expect(selectPlaylist).toHaveBeenCalledWith(ALPHA);
expect(closed).toHaveBeenCalled();
});
it('scrolls a new search back to its first match at the top', () => {
const list = (fixture.nativeElement as HTMLElement).querySelector(
'.switcher__list'
) as HTMLElement;
list.scrollTop = 120;
type('a');
expect(list.scrollTop).toBe(0);
list.scrollTop = 120;
component.setType('xtream');
expect(list.scrollTop).toBe(0);
});
it('moves through options whose IDs a selector would misread', () => {
const restored = { ...ALPHA, _id: 'odd:id.one', title: 'Aardvark' };
playlists.set([restored, BRAVO]);
fixture.detectChanges();
expect(() => press('ArrowDown')).not.toThrow();
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-odd:id.one'
);
});
it('filters by name or host as you type and highlights the first match', () => {
type('charlie.example');
expect(optionIds()).toEqual(['playlist-switcher-all-charlie']);
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-charlie'
);
type('nothing like it');
expect(optionIds()).toEqual([]);
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.switcher__empty'
)?.textContent
).toContain('WORKSPACE.SWITCHER.NO_MATCHES');
});
it('falls back to All when the remembered type has no sources left', () => {
component.setType('xtream');
fixture.detectChanges();
expect(optionIds()).toEqual(['playlist-switcher-all-bravo']);
// The last Xtream source is removed; two other types remain.
playlists.set([ALPHA, CHARLIE]);
fixture.detectChanges();
expect(component.activeType()).toBe('all');
expect(optionIds()).toEqual([
'playlist-switcher-all-alpha',
'playlist-switcher-all-charlie',
]);
// The choice is kept for when an Xtream source returns.
expect(localStorage.getItem('playlist-switcher:type:v2')).toBe(
'xtream'
);
});
it('filters by type with one segment and remembers it', () => {
component.setType('xtream');
fixture.detectChanges();
expect(optionIds()).toEqual(['playlist-switcher-all-bravo']);
expect(localStorage.getItem('playlist-switcher:type:v2')).toBe(
'xtream'
);
});
it('closes on Escape', () => {
const closed = jest.fn();
component.closeRequested.subscribe(closed);
press('Escape');
expect(closed).toHaveBeenCalled();
});
it('closes before a delete, so Undo can take the focus and give it back to the trigger', () => {
const trigger = document.createElement('button');
document.body.appendChild(trigger);
// The host closes the popover and focuses its trigger.
component.closeRequested.subscribe(() => trigger.focus());
component.remove(BRAVO);
expect(remove).toHaveBeenCalledWith(BRAVO, CHARLIE, {
returnFocusTo: trigger,
});
trigger.remove();
});
it('offers no Remove while a refresh clears and refills the source', async () => {
// Held by a refresh from any entry point, the Sources page included.
const release = TestBed.inject(SourceActivityService).begin(['bravo']);
fixture.detectChanges();
const menuRemove = async (id: string) => {
(fixture.nativeElement as HTMLElement)
.querySelector<HTMLElement>(
`#playlist-switcher-all-${id} .source-row__more`
)
?.click();
fixture.detectChanges();
await fixture.whenStable();
const items = Array.from(
document.querySelectorAll<HTMLButtonElement>(
'.cdk-overlay-container [mat-menu-item]'
)
);
const item = items[items.length - 1];
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape' })
);
return item;
};
expect((await menuRemove('bravo')).disabled).toBe(true);
expect((await menuRemove('alpha')).disabled).toBe(false);
// Still a source you can switch to.
(fixture.nativeElement as HTMLElement)
.querySelector<HTMLElement>(
'#playlist-switcher-all-bravo .source-row'
)
?.click();
expect(selectPlaylist).toHaveBeenCalled();
release();
});
it('does not open a source while its refresh clears the catalog', () => {
xtreamRefreshing.set(new Set(['alpha']));
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const alpha = element.querySelector('#playlist-switcher-all-alpha');
expect(alpha?.getAttribute('aria-disabled')).toBe('true');
alpha?.querySelector<HTMLElement>('.source-row')?.click();
// Enter on the highlighted option goes through the same method.
component.select(ALPHA);
expect(selectPlaylist).not.toHaveBeenCalled();
xtreamRefreshing.set(new Set());
fixture.detectChanges();
component.select(ALPHA);
expect(selectPlaylist).toHaveBeenCalledWith(ALPHA);
});
it('hands over past a source a refresh holds when removing the current one', () => {
// Bravo is current; Charlie would follow it but is refreshing.
const release = TestBed.inject(SourceActivityService).begin([
'charlie',
]);
component.remove(BRAVO);
expect(remove).toHaveBeenCalledWith(BRAVO, ALPHA, expect.anything());
release();
});
it('leaves out a source whose delete is pending or running', () => {
pending.set(new Set(['bravo']));
fixture.detectChanges();
expect(optionIds()).not.toContain('playlist-switcher-all-bravo');
pending.set(new Set());
deleting.set(new Set(['alpha']));
fixture.detectChanges();
expect(optionIds()).toEqual([
'playlist-switcher-all-bravo',
'playlist-switcher-all-charlie',
]);
});
it('keeps the highlight on its option, or in range, when the list changes', () => {
press('ArrowUp');
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-charlie'
);
// An earlier option leaves: the highlight follows charlie.
playlists.set([BRAVO, CHARLIE]);
fixture.detectChanges();
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-charlie'
);
// The highlighted last option leaves: Enter opens the new last one.
playlists.set([BRAVO]);
fixture.detectChanges();
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-bravo'
);
press('Enter');
expect(selectPlaylist).toHaveBeenCalledWith(BRAVO);
});
it('goes to the Sources page from the footer', () => {
(
(fixture.nativeElement as HTMLElement).querySelector(
'.switcher__all'
) as HTMLButtonElement
).click();
expect(navigate).toHaveBeenCalledWith(['/workspace', 'sources']);
});
});
@@ -0,0 +1,407 @@
import { A11yModule } from '@angular/cdk/a11y';
import { DOCUMENT, NgTemplateOutlet } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
linkedSignal,
output,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import {
SourceStatusService,
sourceStatusBucket,
} from '@iptvnator/portal/shared/data-access';
import {
countSourcesByType,
orderFailingFirst,
PlaylistContextFacade,
PlaylistRecentHistoryService,
replacementAfterRemoval,
SourceBucketOf,
sourceSortName,
SourcesTypeFilter,
} from '@iptvnator/playlist/shared/util';
import {
SortBy,
SortService,
SourceActivityService,
} from '@iptvnator/services';
import { PlaylistMeta, PlaylistSourceKind } from '@iptvnator/shared/interfaces';
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
import { SourceRemovalService } from '../source-removal/source-removal.service';
import { SourceRowComponent } from '../source-row/source-row.component';
import { XtreamRefreshFlowService } from '../xtream-refresh-flow.service';
import {
buildSwitcherGroups,
moveHighlight,
readSwitcherType,
SwitcherOption,
writeSwitcherType,
} from './playlist-switcher-list';
const KINDS: readonly PlaylistSourceKind[] = ['m3u', 'xtream', 'stalker'];
/**
* The switcher popover: search, a type segment, "Recent" and "All" groups
* of compact source rows, and a footer. Focus stays in the search field;
* ↑/↓ move the highlight (`aria-activedescendant`), Enter opens it,
* Shift+F10 opens its menu and Escape closes the popover.
*/
@Component({
selector: 'app-playlist-switcher-panel',
imports: [
A11yModule,
MatButton,
MatButtonToggleModule,
MatIcon,
MatIconButton,
MatTooltip,
NgTemplateOutlet,
SourceRowComponent,
TranslatePipe,
],
templateUrl: './playlist-switcher-panel.component.html',
styleUrl: './playlist-switcher-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaylistSwitcherPanelComponent {
private readonly activity = inject(SourceActivityService);
private readonly context = inject(PlaylistContextFacade);
private readonly document = inject(DOCUMENT);
private readonly dialog = inject(MatDialog);
private readonly recentHistory = inject(PlaylistRecentHistoryService);
private readonly refreshAction = inject(PlaylistRefreshActionService);
private readonly removal = inject(SourceRemovalService);
private readonly router = inject(Router);
private readonly sortService = inject(SortService);
private readonly statusService = inject(SourceStatusService);
private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService);
private readonly list = viewChild.required<ElementRef<HTMLElement>>('list');
private readonly searchInput =
viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
private readonly rows = viewChildren(SourceRowComponent);
private readonly rowElements = viewChildren(SourceRowComponent, {
read: ElementRef,
});
readonly closeRequested = output<void>();
private closing = false;
readonly playlistSelected = output<string>();
readonly addPlaylistRequested = output<void>();
readonly accountInfoRequested = output<PlaylistMeta>();
readonly listId = 'playlist-switcher-listbox';
readonly kinds = KINDS;
readonly kindLabels = { m3u: 'M3U', xtream: 'Xtream', stalker: 'Stalker' };
readonly currentId = this.context.resolvedPlaylistId;
readonly query = signal('');
readonly type = signal<SourcesTypeFilter>(readSwitcherType());
private readonly sortOptions = toSignal(this.sortService.getSortOptions(), {
requireSync: true,
});
private readonly bucketOf: SourceBucketOf = (playlist) => {
const status = this.statusService.statusOf(playlist);
return status ? sourceStatusBucket(status.reachability) : null;
};
/**
* Every source except one whose delete is pending or running: a running
* delete shows its progress and Cancel on the Sources page, and its
* source cannot be opened or removed again from here.
*/
readonly playlists = computed(() => {
const pending = this.removal.pendingIds();
return this.context
.playlists()
.filter(
(playlist) =>
!pending.has(playlist._id) &&
!this.removal.progress.isDeleting(playlist._id)
);
});
readonly typeCounts = computed(() =>
countSourcesByType(this.playlists(), 'all', this.bucketOf)
);
readonly showTypeFilter = computed(
() => KINDS.filter((kind) => this.typeCounts()[kind] > 0).length > 1
);
/**
* The type the list filters by: the remembered one while it has sources
* and its segment shows, else All. Removing the last source of the
* remembered type would otherwise leave an empty list with no segment
* selected. The remembered choice itself is kept for when it returns.
*/
readonly activeType = computed<SourcesTypeFilter>(() => {
const type = this.type();
return this.showTypeFilter() &&
(type === 'all' || this.typeCounts()[type] > 0)
? type
: 'all';
});
/** The Sources page order, so both lists agree. */
private readonly ordered = computed(() => {
const options = this.sortOptions();
const sorted = this.sortService.sortPlaylists(
this.playlists(),
options,
sourceSortName
);
return options.failingFirst && options.by !== SortBy.CUSTOM
? orderFailingFirst(sorted, this.bucketOf)
: sorted;
});
readonly groups = computed(() =>
buildSwitcherGroups(
this.ordered(),
this.recentHistory.ids(),
this.activeType(),
this.query()
)
);
readonly options = computed<SwitcherOption[]>(() => [
...this.groups().recent,
...this.groups().all,
]);
/**
* A search highlights its first match, so Enter opens it. When the list
* changes under the same search (a source added or removed), the
* highlight stays on its option, or on the same place, clamped.
*/
readonly highlight = linkedSignal<
{
query: string;
type: SourcesTypeFilter;
ids: readonly string[];
},
number
>({
source: () => ({
query: this.query(),
type: this.activeType(),
ids: this.options().map((option) => option.id),
}),
computation: (source, previous) => {
const sameSearch =
previous &&
previous.source.query === source.query &&
previous.source.type === source.type;
if (!sameSearch) {
return source.query.trim() ? 0 : -1;
}
const id = previous.source.ids[previous.value];
const kept = id === undefined ? -1 : source.ids.indexOf(id);
return kept !== -1
? kept
: Math.min(previous.value, source.ids.length - 1);
},
});
readonly activeOptionId = computed(
() => this.options()[this.highlight()]?.id ?? null
);
constructor() {
// Typing goes straight to the search. Not the focus trap's
// auto-capture: on close it would move focus back to wherever it was
// before ⌘P, and closing returns it to the trigger instead.
afterNextRender(() => this.searchInput().nativeElement.focus());
}
setQuery(value: string): void {
this.query.set(value);
this.scrollToTop();
}
setType(type: SourcesTypeFilter): void {
this.type.set(type);
writeSwitcherType(type);
this.scrollToTop();
}
/**
* A new search or type highlights the first match, at the top of the
* list: a list scrolled down for the previous one would hide it.
*/
private scrollToTop(): void {
this.list().nativeElement.scrollTop = 0;
}
onSearchKeydown(event: KeyboardEvent): void {
const count = this.options().length;
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
this.highlight.set(
moveHighlight(
this.highlight(),
event.key === 'ArrowDown' ? 1 : -1,
count
)
);
this.scrollHighlightIntoView();
break;
case 'Enter': {
const option = this.options()[this.highlight()];
if (option) {
event.preventDefault();
this.select(option.playlist);
}
break;
}
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close();
break;
case 'ContextMenu':
case 'F10':
if (event.key === 'ContextMenu' || event.shiftKey) {
event.preventDefault();
this.openHighlightedMenu();
}
break;
}
}
select(playlist: PlaylistMeta): void {
// Pointer and Enter alike: opening it would race the refresh.
if (this.isRefreshing(playlist)) {
return;
}
this.context.selectPlaylist(playlist);
this.playlistSelected.emit(playlist._id);
this.close();
}
canRefresh(playlist: PlaylistMeta): boolean {
return this.refreshAction.canRefresh(playlist);
}
/**
* A refresh is clearing and refilling the source: it cannot be opened
* until the refresh has parked the user data its import restores.
*/
isRefreshing(playlist: PlaylistMeta): boolean {
return (
this.refreshAction.isSourceBusy(playlist._id) ||
this.xtreamRefreshFlow.isRunning(playlist._id)
);
}
/** A source cannot be removed while a refresh clears and refills it. */
isBusy(playlist: PlaylistMeta): boolean {
return this.activity.isBusy(playlist._id);
}
refresh(playlist: PlaylistMeta): void {
this.close();
this.refreshAction.refresh(playlist);
}
openInfo(playlist: PlaylistMeta): void {
this.close();
this.dialog.open(PlaylistInfoComponent, { data: playlist });
}
openAccountInfo(playlist: PlaylistMeta): void {
this.close();
this.accountInfoRequested.emit(playlist);
}
/**
* Deletes with Undo; the active source hands over to its neighbour. The
* popover closes first: its focus trap would keep the keyboard away
* from Undo, which then takes the focus and returns it to the trigger.
*/
remove(playlist: PlaylistMeta): void {
const next = replacementAfterRemoval(
this.groups().all.map((option) => option.playlist),
playlist._id,
(row) => !this.removal.canTakeOver(row._id)
);
this.close();
// Closing put the focus back on the trigger.
const returnFocusTo = this.document.activeElement;
this.removal.remove(playlist, next, {
returnFocusTo:
returnFocusTo instanceof HTMLElement &&
returnFocusTo !== this.document.body
? returnFocusTo
: null,
});
}
/** A row menu gives the focus back to the search, unless closing. */
onMenuClosed(): void {
if (!this.closing) {
this.searchInput().nativeElement.focus();
}
}
/**
* Every action that leaves the popover closes it through here, so a row
* menu closing after its action does not pull the focus back into it.
*/
private close(): void {
this.closing = true;
this.closeRequested.emit();
}
addPlaylist(): void {
this.close();
this.addPlaylistRequested.emit();
}
openAllSources(): void {
this.close();
void this.router.navigate(['/workspace', 'sources']);
}
/** Shift+F10 / the menu key: the highlighted row's ⋯ menu. */
private openHighlightedMenu(): void {
const id = this.activeOptionId();
const index = this.rowElements().findIndex(
(element) => (element.nativeElement as HTMLElement).id === id
);
this.rows()[index]?.openMenu();
}
/** Keeps the highlighted option in the list's view, not the page's. */
private scrollHighlightIntoView(): void {
const id = this.activeOptionId();
const list = this.list().nativeElement;
// By exact ID, not a selector: a restored playlist ID may hold "."
// or ":", which a selector would misread.
const option = id
? this.rowElements()
.map((row) => row.nativeElement as HTMLElement)
.find((element) => element.id === id)
: undefined;
if (!option) {
return;
}
const top = option.offsetTop;
const bottom = top + option.offsetHeight;
if (top < list.scrollTop) {
list.scrollTop = top - 8;
} else if (bottom > list.scrollTop + list.clientHeight) {
list.scrollTop = bottom - list.clientHeight + 8;
}
}
}
@@ -1,293 +1,55 @@
<div
<button
#trigger
type="button"
class="playlist-switcher-trigger"
[class.is-open]="isMenuOpen()"
matRipple
[matRippleDisabled]="false"
[matMenuTriggerFor]="playlistMenu"
#menuTrigger="matMenuTrigger"
#triggerElement
(menuOpened)="onMenuOpened()"
(menuClosed)="onMenuClosed()"
data-test-id="playlist-switcher-trigger"
cdkOverlayOrigin
#origin="cdkOverlayOrigin"
aria-haspopup="dialog"
aria-keyshortcuts="Meta+P Control+P"
[attr.aria-expanded]="isOpen()"
[class.is-open]="isOpen()"
(click)="toggle()"
>
<div class="trigger-icon-container">
<mat-icon class="trigger-playlist-icon">playlist_play</mat-icon>
</div>
<div class="playlist-info">
<div class="name">{{ displayTitle() }}</div>
<div class="channels-count">{{ subtitle() }}</div>
</div>
@if (canRefreshActivePlaylist()) {
<button
type="button"
mat-icon-button
class="trigger-refresh-button"
[disabled]="isRefreshingActivePlaylist()"
(click)="onRefreshClick($event)"
(mousedown)="$event.stopPropagation()"
[matTooltip]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
>
<mat-icon
class="trigger-refresh-icon"
[class.is-spinning]="isRefreshingActivePlaylist()"
>sync</mat-icon
>
</button>
@if (activePlaylist(); as active) {
<app-source-tile
size="sm"
[playlist]="active"
[status]="activeStatus()"
/>
} @else {
<span class="trigger-placeholder-tile" aria-hidden="true">
<mat-icon>playlist_play</mat-icon>
</span>
}
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()">
expand_more
</mat-icon>
</div>
<span class="playlist-info">
<span class="name">{{ displayTitle() }}</span>
<span class="channels-count">{{ triggerSubtitle() }}</span>
</span>
<mat-icon class="dropdown-icon" [class.rotated]="isOpen()"
>expand_more</mat-icon
>
</button>
<mat-menu
#playlistMenu="matMenu"
panelClass="playlist-switcher-menu"
hasBackdrop
backdropClass="playlist-switcher-menu-backdrop"
overlapTrigger="false"
xPosition="after"
yPosition="below"
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayPositions]="positions"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayPush]="true"
[cdkConnectedOverlayViewportMargin]="8"
cdkConnectedOverlayBackdropClass="playlist-switcher-backdrop"
cdkConnectedOverlayPanelClass="playlist-switcher-pane"
(backdropClick)="close()"
(detach)="close()"
(overlayKeydown)="onOverlayKeydown($event)"
>
<div class="menu-header">
<mat-icon class="menu-header-icon">playlist_play</mat-icon>
<span class="menu-header-title">{{
'HOME.PLAYLISTS.MY_PLAYLISTS' | translate
}}</span>
@if (hasSearchToggle()) {
<button
mat-icon-button
type="button"
class="search-toggle-btn"
[class.is-active]="searchExpanded()"
(click)="toggleSearchField($event)"
[attr.aria-label]="
'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate
"
>
<mat-icon>search</mat-icon>
</button>
}
</div>
@if (showSearchField()) {
<div class="search-container" (click)="$event.stopPropagation()">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="setSearchQuery($event)"
/>
@if (searchQuery()) {
<button
mat-icon-button
type="button"
class="clear-btn"
[attr.aria-label]="'CLEAR_SEARCH' | translate"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
}
@if (showTypeFilters()) {
<div class="type-filters" (click)="$event.stopPropagation()">
@if (hasType('m3u')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('m3u')"
(click)="togglePlaylistTypeFilter('m3u', $event)"
>
M3U
</button>
}
@if (hasType('stalker')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('stalker')"
(click)="togglePlaylistTypeFilter('stalker', $event)"
>
Stalker
</button>
}
@if (hasType('xtream')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('xtream')"
(click)="togglePlaylistTypeFilter('xtream', $event)"
>
Xtream
</button>
}
</div>
}
<div class="menu-content">
@for (playlist of filteredPlaylists(); track playlist._id) {
<div
class="playlist-item"
[class.selected]="isSelected(playlist)"
(click)="selectPlaylist(playlist)"
>
<!-- @if (isSelected(playlist)) {
<mat-icon class="selected-indicator">check</mat-icon>
} @else {
<div class="selected-indicator-placeholder"></div>
} -->
<div class="playlist-icon-container">
<mat-icon class="playlist-type-icon">
{{ getPlaylistIcon(playlist) }}
</mat-icon>
@if (runtime.supportsSourceHealth) {
<app-source-health-indicator
[playlist]="playlist"
[enabled]="isMenuOpen()"
/>
} @else if (playlist.serverUrl) {
<span
class="status-dot"
[class]="getStatusClass(playlist._id)"
></span>
}
@if (
playlist.autoRefresh &&
!playlist.serverUrl &&
!playlist.macAddress
) {
<mat-icon class="auto-refresh-indicator"
>autorenew</mat-icon
>
}
</div>
<div class="playlist-details">
<span class="playlist-title">
{{ playlist.title || playlist.filename }}
</span>
<span class="playlist-meta">
{{ getPlaylistMetaLabel(playlist) }}
@if (playlist.updateDate) {
<span class="separator">·</span>
{{
playlist.updateDate
| date
: 'MMM d, HH:mm'
: undefined
: currentLocale()
}}
}
</span>
</div>
<button
type="button"
class="item-action-trigger"
mat-icon-button
[matMenuTriggerFor]="itemMenu"
[matMenuTriggerData]="{ playlist: playlist }"
(click)="$event.stopPropagation()"
[attr.aria-label]="
'HOME.PLAYLISTS.PLAYLIST_ACTIONS' | translate
"
>
<mat-icon>more_vert</mat-icon>
</button>
</div>
} @empty {
<div class="empty-state">
<mat-icon>playlist_add</mat-icon>
<span>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</span>
</div>
}
</div>
@if (showAddPlaylist() || showAccountInfo() || showPlaylistInfo()) {
<div class="context-actions-section">
@if (showPlaylistInfo()) {
<button
class="context-action-item"
(click)="requestPlaylistInfo()"
>
<mat-icon>info</mat-icon>
<span>{{
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
}}</span>
</button>
}
@if (showAccountInfo()) {
<button
class="context-action-item"
(click)="requestAccountInfo()"
>
<mat-icon>person</mat-icon>
<span>{{
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
}}</span>
</button>
}
@if (showAddPlaylist()) {
<button
class="context-action-item"
(click)="requestAddPlaylist()"
>
<mat-icon>add</mat-icon>
<span>{{
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
}}</span>
</button>
}
</div>
}
</mat-menu>
<mat-menu #itemMenu="matMenu" class="playlist-item-action-menu">
<ng-template matMenuContent let-playlist="playlist">
<button mat-menu-item (click)="openPlaylistInfoFor(playlist, $event)">
<mat-icon>info</mat-icon>
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
</button>
@if (hasAccountInfo(playlist)) {
<button
mat-menu-item
(click)="requestAccountInfoFor(playlist, $event)"
>
<mat-icon>person</mat-icon>
<span>{{ 'WORKSPACE.SHELL.ACCOUNT_INFO' | translate }}</span>
</button>
}
@if (runtime.supportsSourceHealth && sourceHealthType(playlist)) {
<button mat-menu-item (click)="recheckSource(playlist)">
<mat-icon>network_check</mat-icon
><span>{{ 'SOURCE_HEALTH.RECHECK' | translate }}</span>
</button>
}
@if (canRefreshPlaylist(playlist)) {
<button
mat-menu-item
(click)="refreshPlaylistFor(playlist, $event)"
>
<mat-icon>sync</mat-icon>
<span>{{
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
}}</span>
</button>
}
<mat-divider />
<button
mat-menu-item
class="danger"
(click)="removePlaylistFor(playlist, $event)"
>
<mat-icon>delete</mat-icon>
<span>{{ 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE' | translate }}</span>
</button>
</ng-template>
</mat-menu>
<app-playlist-switcher-panel
[style.--playlist-switcher-max-height]="maxHeight()"
(closeRequested)="close()"
(playlistSelected)="playlistSelected.emit($event)"
(addPlaylistRequested)="addPlaylistRequested.emit()"
(accountInfoRequested)="accountInfoForPlaylistRequested.emit($event)"
/>
</ng-template>
@@ -5,648 +5,134 @@
width: 100%;
}
// The trigger is a secondary button (UI kit): 40px, 10px radius, a 10%
// fill, the source's tile, two lines and a chevron that turns when open.
.playlist-switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
width: 100%;
height: 40px;
padding: 0 10px 0 6px;
box-sizing: border-box;
padding: 4px 8px 4px 4px;
border: 0;
border-radius: 10px;
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
color: var(--app-heading-color);
font: inherit;
text-align: start;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: color-mix(in srgb, var(--app-on-surface) 5%, transparent);
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 36%, transparent);
app-region: no-drag;
transition: background 0.12s ease;
&:hover {
background: color-mix(
in srgb,
var(--app-on-surface) 12%,
transparent
);
border-color: color-mix(
in srgb,
var(--mat-sys-outline-variant) 64%,
transparent
);
background: color-mix(in srgb, var(--app-on-surface) 16%, transparent);
}
// Open: one step up, with a 2px inset ring in the selection colour.
&.is-open {
background: color-mix(
in srgb,
var(--app-on-surface) 12%,
transparent
);
border-color: color-mix(
in srgb,
var(--mat-sys-outline-variant) 72%,
transparent
);
background: color-mix(in srgb, var(--app-on-surface) 14%, transparent);
box-shadow: inset 0 0 0 2px var(--app-selection-color);
}
&:active {
transform: scale(0.98);
&:focus-visible {
@include focus-ring.focus-ring-declarations;
}
app-source-tile {
--source-tile-bg: color-mix(
in srgb,
var(--app-on-surface) 10%,
var(--app-header-bg)
);
--source-tile-ring: var(--app-header-bg);
}
}
.trigger-icon-container {
width: 30px;
height: 30px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(
in srgb,
var(--app-on-surface) 8%,
transparent
);
flex-shrink: 0;
}
.trigger-placeholder-tile {
flex: none;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 7px;
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
color: var(--app-body-color);
.trigger-playlist-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
.mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
line-height: 14px;
}
}
.playlist-info {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
.name {
font-size: 0.95em;
font-weight: 600;
.name,
.channels-count {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
letter-spacing: 0.01em;
}
.name {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
}
.channels-count {
font-size: 0.74em;
font-size: 11px;
line-height: 1.3;
letter-spacing: 0.02em;
color: var(--mat-sys-on-surface-variant);
// A wrapping subtitle pushes the trigger past the 56px header row.
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// On a phone the header row has no width to spare, and the shell allows this
// trigger to shrink to 88px there. The decorative type icon goes so that the
// trigger's fixed chrome (refresh, chevron, gaps, padding) actually fits that
// floor with the name ellipsizing; the subtitle goes because the name is the
// only part worth the room.
@media (max-width: 640px) {
.trigger-icon-container {
display: none;
}
.playlist-info .channels-count {
display: none;
}
}
.trigger-refresh-button {
margin-left: auto;
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
padding: 0;
line-height: 32px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mat-sys-on-surface-variant);
transition:
color 0.15s ease,
background 0.15s ease;
&:hover:not(:disabled) {
color: var(--mat-sys-on-surface);
background: var(--mat-sys-surface-container);
}
::ng-deep .mat-mdc-button-touch-target {
width: 32px;
height: 32px;
}
::ng-deep .mat-mdc-button-persistent-ripple,
::ng-deep .mat-mdc-focus-indicator {
border-radius: 50%;
}
}
.trigger-refresh-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
margin: 0;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
&.is-spinning {
animation: switcher-refresh-spin 1s linear infinite;
}
}
@keyframes switcher-refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
color: var(--app-body-color);
}
}
.dropdown-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: 4px;
flex: none;
width: 12px;
height: 12px;
font-size: 12px;
line-height: 12px;
color: var(--app-body-color);
transition: transform 0.15s ease;
&.rotated {
transform: rotate(180deg);
}
}
::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel {
--mat-menu-container-color: var(--mat-sys-surface-container-high);
width: min(
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
min-width: min(
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
max-width: min(
var(--playlist-switcher-overlay-width, 400px),
calc(100vw - 24px)
) !important;
margin-top: -1px !important;
border-radius: 0 0 12px 12px !important;
border: 1px solid var(--mat-sys-outline) !important;
border-top: 0 !important;
overflow: hidden !important;
background: var(--mat-menu-container-color) !important;
.mat-mdc-menu-content {
padding: 0 !important;
background: var(--mat-menu-container-color) !important;
}
}
.menu-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 6px;
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--mat-menu-container-color);
.menu-header-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.7;
}
.menu-header-icon {
opacity: 0.7;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.search-toggle-btn {
width: 30px;
height: 30px;
line-height: 30px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.8;
padding: 0;
&:hover,
&.is-active {
opacity: 1;
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.search-container {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin: 0 8px 4px;
background: color-mix(
in srgb,
var(--app-on-surface) 6%,
transparent
);
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.search-container:focus-within {
border-color: var(--app-focus-ring);
background: var(--mat-sys-surface-container-highest);
}
.search-container .search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
.search-container .search-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.86em;
font-weight: 500;
line-height: 1.4;
color: inherit;
min-width: 0;
}
.search-container .search-input::placeholder {
color: var(--mat-sys-on-surface-variant);
}
.search-container .clear-btn {
width: 24px;
height: 24px;
line-height: 24px;
padding: 0;
}
.search-container .clear-btn mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.type-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 8px 8px;
margin-top: 6px;
}
.type-filter-chip {
height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 78%,
transparent
);
color: var(--mat-sys-on-surface-variant);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
&.selected {
border-color: var(--app-selection-border, var(--app-selection-color));
background: var(
--app-selection-surface-strong,
var(--app-selection-surface)
);
color: var(--app-selection-color, var(--mat-sys-primary));
/* box-shadow:
inset 0 0 0 1px
color-mix(in srgb, var(--app-selection-color) 18%, transparent),
0 0 0 1px
color-mix(in srgb, var(--app-selection-color) 12%, transparent),
0 0 10px var(--app-selection-glow, transparent); */
}
&:focus-visible {
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
.menu-content {
max-height: 360px;
overflow-y: auto;
padding: 4px 0;
background: color-mix(in srgb, #000 22%, transparent);
border-top: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-bottom: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
}
.playlist-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 4px 12px;
min-height: 44px;
transition: background-color 0.15s ease;
position: relative;
cursor: pointer;
&:not(:last-child) {
border-bottom: 1px solid
color-mix(in srgb, var(--app-on-surface) 8%, transparent);
}
}
.playlist-item.selected {
background: var(--app-selection-surface, rgba(139, 92, 246, 0.1));
&::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--app-selection-color, #a78bfa);
}
.playlist-title,
.playlist-meta {
color: var(--app-selection-color, #a78bfa);
}
}
.item-action-trigger {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
width: 32px;
height: 32px;
line-height: 32px;
flex-shrink: 0;
color: var(--mat-sys-on-surface-variant);padding: 0;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.playlist-item:hover .item-action-trigger,
.playlist-item.selected .item-action-trigger,
.item-action-trigger:focus-visible,
.item-action-trigger[aria-expanded='true'] {
opacity: 1;
pointer-events: auto;
}
::ng-deep .playlist-item-action-menu {
.danger,
.danger mat-icon {
color: var(--mat-sys-error, #f44336);
}
}
.playlist-item:hover:not(.selected) {
background: color-mix(
in srgb,
var(--app-on-surface) 10%,
transparent
);
}
.playlist-icon-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: color-mix(
in srgb,
var(--app-on-surface) 6%,
transparent
);
flex-shrink: 0;
}
.playlist-type-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
}
.status-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
right: -4px;
bottom: -4px;
border: 2px solid var(--mat-menu-container-color, #2a2a2e);
}
.status-active {
background-color: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
}
.status-inactive {
background-color: #f44336;
box-shadow: 0 0 6px rgba(244, 67, 54, 0.4);
}
.status-expired {
background-color: #ff9800;
box-shadow: 0 0 6px rgba(255, 152, 0, 0.4);
}
.status-unavailable {
background-color: #666666;
}
.status-checking {
background-color: #9e9e9e;
animation: switcher-status-checking 1.4s ease-in-out infinite;
}
@keyframes switcher-status-checking {
0%,
100% {
opacity: 0.4;
box-shadow: 0 0 0 0 rgba(158, 158, 158, 0);
}
50% {
opacity: 1;
box-shadow: 0 0 4px 1px rgba(158, 158, 158, 0.6);
}
}
@media (prefers-reduced-motion: reduce) {
.status-checking {
animation: none;
opacity: 0.7;
.playlist-switcher-trigger,
.dropdown-icon {
transition: none;
}
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 12px !important;
width: 14px !important;
height: 14px !important;
color: var(--mat-sys-primary);
background-color: var(--mat-menu-container-color, var(--mat-sys-surface));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.playlist-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.playlist-title {
font-size: 0.92em;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-meta {
font-size: 0.73em;
line-height: 1.25;
letter-spacing: 0.01em;
color: var(--mat-sys-on-surface-variant);
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.separator {
color: var(--mat-sys-on-surface-variant);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
}
span {
font-size: 0.85em;
}
}
.context-actions-section {
padding: 6px 8px 8px;
background: var(--mat-menu-container-color);
.context-action-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
font: inherit;
font-size: 0.86em;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
@media only screen and (max-width: 480px) {
.playlist-info .name {
font-size: 0.9em;
}
.playlist-icon-container {
// On a phone the header row has no width to spare and the trigger may
// shrink to 88px: the tile and the subtitle go, the name ellipsizes.
@media (max-width: 640px) {
.playlist-switcher-trigger app-source-tile,
.trigger-placeholder-tile,
.playlist-info .channels-count {
display: none;
}
}
// The popover's backdrop dims and slightly blurs the window behind it. The
// overlay container is outside this component, so these are global.
::ng-deep .playlist-switcher-backdrop {
background: var(--app-popover-backdrop);
backdrop-filter: blur(2px);
}
@media (prefers-reduced-transparency: reduce) {
::ng-deep .playlist-switcher-backdrop {
backdrop-filter: none;
}
}
@@ -1,111 +1,53 @@
import { signal } from '@angular/core';
import {
ComponentFixture,
TestBed,
fakeAsync,
flushMicrotasks,
tick,
} from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
ChangeDetectionStrategy,
Component,
output,
signal,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { TranslateModule } from '@ngx-translate/core';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { DialogService } from '@iptvnator/ui/components';
import {
PlaylistDeleteActionService,
PortalStatusService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service';
import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component';
import { PlaylistSwitcherComponent } from './playlist-switcher.component';
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
function createPlaylist(
overrides: Partial<PlaylistMeta> & { _id: string }
): PlaylistMeta {
return {
title: overrides.title ?? overrides.filename ?? overrides._id,
filename: overrides.filename,
count: overrides.count ?? 0,
importDate:
overrides.importDate ??
new Date('2026-04-05T10:00:00.000Z').toISOString(),
autoRefresh: overrides.autoRefresh ?? false,
...overrides,
} as PlaylistMeta;
@Component({
selector: 'app-playlist-switcher-panel',
template: '<input class="stub-search" />',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubPanelComponent {
readonly closeRequested = output<void>();
readonly playlistSelected = output<string>();
readonly addPlaylistRequested = output<void>();
readonly accountInfoRequested = output<PlaylistMeta>();
}
const URL_NAMED = {
_id: 'url-named',
title: 'get.php?username=joe&password=hunter2&type=m3u_plus',
url: 'http://biggie.test:8080/get.php?username=joe&password=hunter2&type=m3u_plus',
count: 3220,
importDate: '',
autoRefresh: false,
} as PlaylistMeta;
describe('PlaylistSwitcherComponent', () => {
const testWindow = window as unknown as { electron?: unknown };
const originalElectron = testWindow.electron;
let fixture: ComponentFixture<PlaylistSwitcherComponent>;
let component: PlaylistSwitcherComponent;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
let resolvedPlaylistIdSignal: ReturnType<typeof signal<string | null>>;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let playlistContext: {
playlists: typeof playlistsSignal;
allPlaylistsLoaded: ReturnType<typeof signal<boolean>>;
resolvedPlaylistId: typeof resolvedPlaylistIdSignal;
activePlaylist: typeof activePlaylistSignal;
selectPlaylist: jest.Mock;
};
let portalStatusService: {
checkPortalStatus: jest.Mock;
getCachedStatus: jest.Mock;
getStatusClass: jest.Mock;
};
let refreshActionService: {
canRefresh: jest.Mock;
refresh: jest.Mock;
};
let dialog: {
open: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let playlistDeleteAction: {
deletePlaylist: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: {
dispatch: jest.Mock;
};
let active: ReturnType<typeof signal<PlaylistMeta | null>>;
const m3uPlaylist = createPlaylist({
_id: 'm3u-1',
count: 24,
importDate: '2026-04-05T09:00:00.000Z',
title: 'Morning M3U',
url: 'http://example.test/m3u.m3u',
});
const stalkerPlaylist = createPlaylist({
_id: 'stalker-1',
importDate: '2026-04-05T11:00:00.000Z',
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://127.0.0.1:3210/portal.php',
title: 'Living Room Stalker',
});
const xtreamPlaylist = createPlaylist({
_id: 'xtream-1',
importDate: '2026-04-05T12:00:00.000Z',
password: 'pass1',
serverUrl: 'http://127.0.0.1:3211',
title: 'Cinema Xtream',
updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(),
username: 'user1',
});
const trigger = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="playlist-switcher-trigger"]'
) as HTMLButtonElement;
const panel = () => document.querySelector('app-playlist-switcher-panel');
async function createComponent(): Promise<void> {
await TestBed.configureTestingModule({
beforeEach(() => {
active = signal<PlaylistMeta | null>(URL_NAMED);
TestBed.configureTestingModule({
imports: [
PlaylistSwitcherComponent,
NoopAnimationsModule,
@@ -114,337 +56,98 @@ describe('PlaylistSwitcherComponent', () => {
providers: [
{
provide: PlaylistContextFacade,
useValue: playlistContext,
useValue: {
resolvedPlaylistId: signal('url-named'),
activePlaylist: active,
},
},
{
provide: PortalStatusService,
useValue: portalStatusService,
},
{
provide: PlaylistRefreshActionService,
useValue: refreshActionService,
},
{
provide: PlaylistDeleteActionService,
useValue: playlistDeleteAction,
},
{
provide: MatDialog,
useValue: dialog,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: Store,
useValue: store,
provide: SourceStatusService,
useValue: { statusOf: () => ({ reachability: 'ok' }) },
},
],
}).compileComponents();
});
TestBed.overrideComponent(PlaylistSwitcherComponent, {
remove: { imports: [PlaylistSwitcherPanelComponent] },
add: { imports: [StubPanelComponent] },
});
fixture = TestBed.createComponent(PlaylistSwitcherComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('currentTitle', '');
fixture.componentRef.setInput('subtitle', '3 playlists');
fixture.componentRef.setInput('subtitle', '3 sources');
fixture.detectChanges();
}
beforeEach(() => {
localStorage.clear();
playlistsSignal = signal([
m3uPlaylist,
stalkerPlaylist,
xtreamPlaylist,
]);
resolvedPlaylistIdSignal = signal<string | null>(xtreamPlaylist._id);
activePlaylistSignal = signal<PlaylistMeta | null>(xtreamPlaylist);
playlistContext = {
playlists: playlistsSignal,
allPlaylistsLoaded: signal(true),
resolvedPlaylistId: resolvedPlaylistIdSignal,
activePlaylist: activePlaylistSignal,
selectPlaylist: jest.fn((playlist: PlaylistMeta) => {
resolvedPlaylistIdSignal.set(playlist._id);
activePlaylistSignal.set(playlist);
}),
};
portalStatusService = {
checkPortalStatus: jest
.fn()
.mockResolvedValueOnce('active')
.mockResolvedValueOnce('inactive'),
getCachedStatus: jest.fn().mockReturnValue(null),
getStatusClass: jest.fn((status: string) => `status-${status}`),
};
refreshActionService = {
canRefresh: jest.fn((playlist: PlaylistMeta) =>
Boolean(playlist.serverUrl || playlist.url || playlist.filePath)
),
refresh: jest.fn(),
};
dialog = {
open: jest.fn(),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
playlistDeleteAction = {
deletePlaylist: jest.fn().mockResolvedValue(true),
};
snackBar = {
open: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
});
afterEach(() => {
localStorage.clear();
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: originalElectron,
});
jest.restoreAllMocks();
});
afterEach(() => fixture.destroy());
it('labels each playlist row with translated text', async () => {
await createComponent();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
HOME: { PLAYLISTS: { CHANNELS_COUNT: '{{count}} channels' } },
WORKSPACE: {
SHELL: {
STALKER_PORTAL: 'Stalker Portal',
XTREAM_CODE: 'Xtream Code',
},
},
});
translate.use('en');
expect(component.getPlaylistMetaLabel(m3uPlaylist)).toBe('24 channels');
expect(component.getPlaylistMetaLabel(stalkerPlaylist)).toBe(
'Stalker Portal'
it('names the active source by its host, never by a credential URL', () => {
expect(trigger().querySelector('.name')?.textContent?.trim()).toBe(
'biggie.test:8080'
);
expect(component.getPlaylistMetaLabel(xtreamPlaylist)).toBe(
'Xtream Code'
);
});
it('reads persisted search and type filters on startup and filters playlists accordingly', async () => {
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room');
localStorage.setItem(
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
JSON.stringify({
m3u: false,
stalker: true,
xtream: true,
})
);
await createComponent();
expect(component.searchQuery()).toBe('room');
expect(component.isTypeFilterSelected('m3u')).toBe(false);
expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]);
});
it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => {
await createComponent();
expect(
component.filteredPlaylists().map((playlist) => playlist._id)
).toEqual([xtreamPlaylist._id, stalkerPlaylist._id, m3uPlaylist._id]);
component.togglePlaylistTypeFilter('m3u');
component.togglePlaylistTypeFilter('stalker');
expect(component.playlistTypeFilters()).toEqual({
m3u: false,
stalker: false,
xtream: true,
});
component.togglePlaylistTypeFilter('xtream');
expect(component.playlistTypeFilters()).toEqual({
m3u: false,
stalker: false,
xtream: true,
});
trigger().querySelector('.channels-count')?.textContent
).toContain('M3U · HOME.PLAYLISTS.CHANNELS_COUNT');
expect(trigger().textContent).not.toMatch(/hunter2|password=/);
expect(trigger().querySelector('.source-tile__dot')).not.toBeNull();
expect(trigger().getAttribute('aria-keyshortcuts')).toBe(
'Meta+P Control+P'
);
});
it('selects a playlist, closes the menu, and emits the selected id', async () => {
await createComponent();
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
const selectedIds: string[] = [];
component.playlistSelected.subscribe((playlistId) => {
selectedIds.push(playlistId);
});
component.selectPlaylist(stalkerPlaylist);
it('shows the source count when no source is active', () => {
active.set(null);
fixture.detectChanges();
expect(closeMenuSpy).toHaveBeenCalledTimes(1);
expect(playlistContext.selectPlaylist).toHaveBeenCalledWith(
stalkerPlaylist
expect(trigger().querySelector('.channels-count')?.textContent).toBe(
'3 sources'
);
expect(selectedIds).toEqual([stalkerPlaylist._id]);
expect(component.isSelected(stalkerPlaylist)).toBe(true);
});
it('emits playlist and account info requests through the context actions', async () => {
await createComponent();
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
const playlistInfoSpy = jest.fn();
const accountInfoSpy = jest.fn();
component.playlistInfoRequested.subscribe(playlistInfoSpy);
component.accountInfoRequested.subscribe(accountInfoSpy);
component.requestPlaylistInfo();
component.requestAccountInfo();
expect(closeMenuSpy).toHaveBeenCalledTimes(2);
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
expect(accountInfoSpy).toHaveBeenCalledTimes(1);
});
it('renders playlist info, account info, and add playlist context actions in the menu', async () => {
await createComponent();
fixture.componentRef.setInput('showPlaylistInfo', true);
fixture.componentRef.setInput('showAccountInfo', true);
fixture.componentRef.setInput('showAddPlaylist', true);
it('opens the popover in an overlay and closes it on Escape, back on the trigger', () => {
trigger().click();
fixture.detectChanges();
expect(panel()).not.toBeNull();
expect(trigger().getAttribute('aria-expanded')).toBe('true');
expect(trigger().classList).toContain('is-open');
const playlistInfoSpy = jest.fn();
component.playlistInfoRequested.subscribe(playlistInfoSpy);
component.menuTrigger().openMenu();
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
fixture.detectChanges();
const actionButtons = Array.from(
document.querySelectorAll<HTMLButtonElement>(
'.context-actions-section .context-action-item'
)
);
const labels = actionButtons.map((button) =>
button.textContent?.trim()
);
expect(labels).toEqual([
expect.stringContaining('PLAYLIST_INFO'),
expect.stringContaining('ACCOUNT_INFO'),
expect.stringContaining('ADD_PLAYLIST'),
]);
actionButtons[0].click();
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
expect(panel()).toBeNull();
expect(document.activeElement).toBe(trigger());
});
it('offers per-playlist account info for portal playlists only', async () => {
await createComponent();
it('refits the open popover when the window shrinks', () => {
const height = Object.getOwnPropertyDescriptor(window, 'innerHeight');
const setHeight = (value: number) =>
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value,
});
try {
setHeight(900);
trigger().click();
fixture.detectChanges();
const opened = fixture.componentInstance.maxHeight();
const requestedPlaylists: PlaylistMeta[] = [];
component.accountInfoForPlaylistRequested.subscribe((playlist) =>
requestedPlaylists.push(playlist)
);
expect(component.hasAccountInfo(m3uPlaylist)).toBe(false);
expect(component.hasAccountInfo(stalkerPlaylist)).toBe(true);
expect(component.hasAccountInfo(xtreamPlaylist)).toBe(true);
component.requestAccountInfoFor(stalkerPlaylist);
expect(requestedPlaylists).toEqual([stalkerPlaylist]);
setHeight(600);
window.dispatchEvent(new Event('resize'));
expect(fixture.componentInstance.maxHeight()).not.toBe(opened);
expect(
parseInt(fixture.componentInstance.maxHeight() ?? '', 10)
).toBeLessThanOrEqual(600);
} finally {
if (height) Object.defineProperty(window, 'innerHeight', height);
}
});
it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => {
await createComponent();
const triggerElement = component.triggerElement().nativeElement;
jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({
bottom: 0,
height: 0,
left: 0,
right: 320,
toJSON: () => ({}),
top: 0,
width: 320,
x: 0,
y: 0,
});
component.onMenuOpened();
flushMicrotasks();
tick();
expect(component.isMenuOpen()).toBe(true);
expect(
document.documentElement.style.getPropertyValue(
'--playlist-switcher-overlay-width'
)
).toBe('400px');
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1);
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
xtreamPlaylist.serverUrl,
xtreamPlaylist.username,
xtreamPlaylist.password
);
expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe(
'active'
);
expect(component.getStatusClass(xtreamPlaylist._id)).toBe(
'status-active'
);
component.onMenuClosed();
expect(component.isMenuOpen()).toBe(false);
expect(
document.documentElement.style.getPropertyValue(
'--playlist-switcher-overlay-width'
)
).toBe('');
}));
it('falls back to the active playlist title when no explicit current title is provided', async () => {
await createComponent();
expect(component.displayTitle()).toBe('Cinema Xtream');
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
HOME: { PLAYLISTS: { SELECT_PLAYLIST: 'Pick a source' } },
});
translate.use('en');
resolvedPlaylistIdSignal.set(null);
activePlaylistSignal.set(null);
it('opens on a ⌘P / Ctrl+P request while it is on screen', () => {
const switcherOpen = TestBed.inject(PlaylistSwitcherOpenService);
expect(switcherOpen.available).toBe(true);
switcherOpen.request();
fixture.detectChanges();
expect(panel()).not.toBeNull();
expect(component.displayTitle()).toBe('Pick a source');
});
it('delegates playlist deletion and removes the source after confirmation', async () => {
await createComponent();
component.removePlaylistFor(xtreamPlaylist);
const confirm = dialogService.openConfirmDialog.mock.calls[0][0]
.onConfirm as () => Promise<void>;
await confirm();
expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(
xtreamPlaylist
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.playlistRemovalCommitted({
playlistId: xtreamPlaylist._id,
})
);
fixture.destroy();
expect(switcherOpen.available).toBe(false);
});
});
@@ -1,12 +1,8 @@
import { Injector } from '@angular/core';
import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { foldSearchText, sourceHealthType } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component';
import { DatePipe, DOCUMENT } from '@angular/common';
CdkConnectedOverlay,
CdkOverlayOrigin,
ConnectedPosition,
} from '@angular/cdk/overlay';
import {
ChangeDetectionStrategy,
Component,
@@ -19,562 +15,173 @@ import {
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatRippleModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService } from '@iptvnator/ui/components';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import {
PlaylistDeleteActionService,
PortalStatus,
PortalStatusService,
} from '@iptvnator/services';
import {
getPlaylistSourceIcon,
isPortalAccountPlaylist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
PlaylistContextFacade,
sourceDisplayName,
sourceMetaParts,
} from '@iptvnator/playlist/shared/util';
import { injectTranslationTick } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { SourceTileComponent } from '../source-row/source-tile.component';
import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service';
import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component';
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
const DEFAULT_PLAYLIST_TYPE_FILTERS: Record<PlaylistFilterType, boolean> = {
m3u: true,
stalker: true,
xtream: true,
};
/** Below the trigger's left edge, 8px down; above it when there is no room. */
const POSITIONS: ConnectedPosition[] = [
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 8,
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -8,
},
];
/** The popover keeps this far from the window's bottom edge. */
const VIEWPORT_MARGIN = 12;
/**
* The toolbar playlist switcher: a trigger showing the active source and a
* popover (`app-playlist-switcher-panel`) in a CDK overlay, so row menus
* open above it rather than inside its scrolling list. Opens on click and
* on ⌘P / Ctrl+P; Escape closes it and returns focus to the trigger.
*/
@Component({
selector: 'app-playlist-switcher',
imports: [
CdkConnectedOverlay,
CdkOverlayOrigin,
MatIcon,
PlaylistSwitcherPanelComponent,
SourceTileComponent,
],
templateUrl: './playlist-switcher.component.html',
styleUrls: ['./playlist-switcher.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SourceHealthIndicatorComponent,
DatePipe,
FormsModule,
MatDivider,
MatIcon,
MatIconButton,
MatInputModule,
MatMenuModule,
MatRippleModule,
MatTooltip,
TranslatePipe,
],
host: { '(window:resize)': 'onWindowResize()' },
})
export class PlaylistSwitcherComponent {
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly portalStatusService = inject(PortalStatusService);
private readonly statusService = inject(SourceStatusService);
private readonly translate = inject(TranslateService);
private readonly refreshAction = inject(PlaylistRefreshActionService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly languageTick = injectTranslationTick();
readonly runtime = inject(RuntimeCapabilitiesService);
private readonly healthInjector = inject(Injector);
readonly sourceHealthType = sourceHealthType;
recheckSource(playlist: PlaylistMeta): void {
void this.healthInjector.get(SourceHealthService).recheck(playlist);
}
private readonly trigger =
viewChild.required<ElementRef<HTMLButtonElement>>('trigger');
readonly currentTitle = input.required<string>();
/** Shown under the name while no source is active ("3 sources"). */
readonly subtitle = input<string>('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly canRefreshActivePlaylist = input(false);
readonly isRefreshingActivePlaylist = input(false);
readonly playlistSelected = output<string>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
readonly addPlaylistRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
onRefreshClick(event: Event): void {
event.stopPropagation();
event.preventDefault();
this.refreshPlaylistRequested.emit();
}
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
readonly triggerElement =
viewChild.required<ElementRef<HTMLElement>>('triggerElement');
readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isMenuOpen = signal(false);
readonly searchQuery = signal(this.readPersistedSearchQuery());
readonly playlistTypeFilters = signal(this.readPersistedTypeFilters());
readonly searchExpanded = signal(false);
private static readonly SEARCH_AUTO_VISIBLE_THRESHOLD = 5;
readonly playlists = this.playlistContext.playlists;
readonly allPlaylistsLoaded = this.playlistContext.allPlaylistsLoaded;
readonly positions = POSITIONS;
readonly isOpen = signal(false);
readonly maxHeight = signal<string | null>(null);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly activePlaylist = this.playlistContext.activePlaylist;
readonly filteredPlaylists = computed(() => {
const query = foldSearchText(this.searchQuery()).trim();
const filters = this.playlistTypeFilters();
const allPlaylists = this.playlists();
const filteredByType = allPlaylists.filter((playlist) => {
const playlistType = this.getPlaylistFilterType(playlist);
return filters[playlistType];
});
const filtered = query
? filteredByType.filter(
(playlist) =>
foldSearchText(playlist.title ?? '').includes(query) ||
foldSearchText(playlist.filename ?? '').includes(query)
)
: filteredByType;
return [...filtered].sort(
(a, b) =>
new Date(b.importDate ?? 0).getTime() -
new Date(a.importDate ?? 0).getTime()
);
});
/** The active source's name; never its URL or a credential. */
readonly displayTitle = computed(() => {
this.languageTick();
if (!this.activePlaylistId()) {
return this.translate.instant('HOME.PLAYLISTS.SELECT_PLAYLIST');
}
const active = this.activePlaylist();
return (
this.currentTitle() ||
this.activePlaylist()?.title ||
this.activePlaylist()?.filename ||
this.activePlaylist()?.url ||
this.activePlaylist()?.portalUrl ||
(active ? sourceDisplayName(active) : this.currentTitle()) ||
this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
);
});
readonly availablePlaylistTypes = computed(() => {
const types = new Set<PlaylistFilterType>();
for (const playlist of this.playlists()) {
types.add(this.getPlaylistFilterType(playlist));
}
return types;
});
readonly showTypeFilters = computed(
() => this.availablePlaylistTypes().size > 1
);
readonly hasSearchToggle = computed(
() =>
this.playlists().length <=
PlaylistSwitcherComponent.SEARCH_AUTO_VISIBLE_THRESHOLD
);
readonly showSearchField = computed(
() => !this.hasSearchToggle() || this.searchExpanded()
);
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
/**
* Tracks the in-flight check round so we can cancel pending writes when
* the menu closes (avoiding zombie writes from a slow portal landing
* after the user has moved on). Status caching itself lives in
* PortalStatusService so it's shared across components.
*/
private portalStatusAbortController: AbortController | null = null;
readonly currentLocale = computed(() => {
/** "Stalker", "M3U · 3,220 channels", or the header's source count. */
readonly triggerSubtitle = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
const active = this.activePlaylist();
if (!active) {
return this.subtitle();
}
return sourceMetaParts(
active,
{
translate: (key, params) => this.translate.instant(key, params),
locale: normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
),
now: Date.now(),
},
true
)
.filter((part) => part.kind === 'type' || part.kind === 'channels')
.map((part) => part.text)
.join(' · ');
});
readonly activeStatus = computed(() => {
const active = this.activePlaylist();
return active ? this.statusService.statusOf(active) : null;
});
constructor() {
this.destroyRef.onDestroy(() => {
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
this.cancelPortalStatusChecks();
const openService = inject(PlaylistSwitcherOpenService);
const unregister = openService.register();
const requests = openService.openRequests.subscribe(() => this.open());
inject(DestroyRef).onDestroy(() => {
unregister();
requests.unsubscribe();
});
}
onMenuOpened(): void {
this.isMenuOpen.set(true);
this.syncMenuOverlayWidthToTrigger();
if (this.showSearchField()) {
this.focusSearchField();
}
if (!this.runtime.supportsSourceHealth)
void this.checkPortalStatuses(this.playlists());
}
onMenuClosed(): void {
this.isMenuOpen.set(false);
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
this.cancelPortalStatusChecks();
if (this.hasSearchToggle()) {
this.searchExpanded.set(false);
toggle(): void {
if (this.isOpen()) {
this.close();
} else {
this.open();
}
}
toggleSearchField(event?: Event): void {
event?.stopPropagation();
const next = !this.searchExpanded();
this.searchExpanded.set(next);
if (next) {
this.focusSearchField();
open(): void {
this.fitToViewport();
this.isOpen.set(true);
}
/** A window resized while the popover is open refits it. */
onWindowResize(): void {
if (this.isOpen()) {
this.fitToViewport();
}
}
setSearchQuery(value: string): void {
this.searchQuery.set(value);
this.writePersistedSearchQuery(value);
/** Caps the popover to the room below the trigger; only its list scrolls. */
private fitToViewport(): void {
const bottom =
this.trigger().nativeElement.getBoundingClientRect().bottom;
this.maxHeight.set(
`${Math.max(240, window.innerHeight - bottom - 8 - VIEWPORT_MARGIN)}px`
);
}
togglePlaylistTypeFilter(type: PlaylistFilterType, event?: Event): void {
event?.stopPropagation();
const current = this.playlistTypeFilters();
const next = {
...current,
[type]: !current[type],
};
if (!next.m3u && !next.stalker && !next.xtream) {
/** Closes the popover; focus goes back to the trigger. */
close(): void {
if (!this.isOpen()) {
return;
}
this.playlistTypeFilters.set(next);
this.writePersistedTypeFilters(next);
this.isOpen.set(false);
this.trigger().nativeElement.focus();
}
isTypeFilterSelected(type: PlaylistFilterType): boolean {
return this.playlistTypeFilters()[type];
}
selectPlaylist(playlist: PlaylistMeta): void {
this.menuTrigger().closeMenu();
this.playlistContext.selectPlaylist(playlist);
this.playlistSelected.emit(playlist._id);
}
requestPlaylistInfo(): void {
this.menuTrigger().closeMenu();
this.playlistInfoRequested.emit();
}
requestAccountInfo(): void {
this.menuTrigger().closeMenu();
this.accountInfoRequested.emit();
}
requestAddPlaylist(): void {
this.menuTrigger().closeMenu();
this.addPlaylistRequested.emit();
}
hasType(type: PlaylistFilterType): boolean {
return this.availablePlaylistTypes().has(type);
}
canRefreshPlaylist(playlist: PlaylistMeta): boolean {
return this.refreshAction.canRefresh(playlist);
}
openPlaylistInfoFor(playlist: PlaylistMeta, event?: Event): void {
event?.stopPropagation();
this.menuTrigger().closeMenu();
this.dialog.open(PlaylistInfoComponent, { data: playlist });
}
hasAccountInfo(playlist: PlaylistMeta): boolean {
return isPortalAccountPlaylist(playlist);
}
requestAccountInfoFor(playlist: PlaylistMeta, event?: Event): void {
event?.stopPropagation();
this.menuTrigger().closeMenu();
this.accountInfoForPlaylistRequested.emit(playlist);
}
refreshPlaylistFor(playlist: PlaylistMeta, event?: Event): void {
event?.stopPropagation();
this.menuTrigger().closeMenu();
this.refreshAction.refresh(playlist);
}
removePlaylistFor(playlist: PlaylistMeta, event?: Event): void {
event?.stopPropagation();
this.menuTrigger().closeMenu();
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
tone: 'destructive',
onConfirm: () => this.removePlaylistConfirmed(playlist),
});
}
private async removePlaylistConfirmed(
playlist: PlaylistMeta
): Promise<void> {
const deleted =
await this.playlistDeleteAction.deletePlaylist(playlist);
if (!deleted) {
return;
}
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: playlist._id,
})
);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
undefined,
{ duration: 2000 }
);
}
getPlaylistIcon(playlist: PlaylistMeta): string {
return getPlaylistSourceIcon(playlist);
}
getPlaylistMetaLabel(playlist: PlaylistMeta): string {
if (playlist.macAddress) {
return this.translate.instant('WORKSPACE.SHELL.STALKER_PORTAL');
}
if (playlist.serverUrl) {
return this.translate.instant('WORKSPACE.SHELL.XTREAM_CODE');
}
return this.translate.instant('HOME.PLAYLISTS.CHANNELS_COUNT', {
count: playlist.count ?? 0,
});
}
getStatusClass(playlistId: string): string {
const status = this.portalStatuses().get(playlistId);
// No entry yet means we haven't checked (or are mid-flight without a
// 'checking' write). Render no status class instead of misleading the
// user with the red 'unavailable' dot.
if (!status) {
return '';
}
return this.portalStatusService.getStatusClass(status);
}
isSelected(playlist: PlaylistMeta): boolean {
return this.activePlaylistId() === playlist._id;
}
private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType {
if (playlist.macAddress) {
return 'stalker';
}
if (playlist.serverUrl) {
return 'xtream';
}
return 'm3u';
}
private async checkPortalStatuses(playlists: PlaylistMeta[]) {
// Cancel any prior in-flight round so its results can't overwrite the
// new round (e.g. user closes + reopens the menu rapidly).
this.cancelPortalStatusChecks();
const controller = new AbortController();
this.portalStatusAbortController = controller;
const xtreamPlaylists = playlists.filter(
(
playlist
): playlist is PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
} =>
Boolean(
playlist.serverUrl && playlist.username && playlist.password
)
);
if (xtreamPlaylists.length === 0) {
return;
}
// Hydrate from the shared service cache + mark uncached portals as
// 'checking' in a single signal write so the UI flips from blank →
// cached/checking dots in one render, not one per playlist.
const next = new Map(this.portalStatuses());
const toFetch: (PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
})[] = [];
for (const playlist of xtreamPlaylists) {
const cached = this.portalStatusService.getCachedStatus(
playlist.serverUrl,
playlist.username,
playlist.password
);
if (cached !== null) {
next.set(playlist._id, cached);
} else {
next.set(playlist._id, 'checking');
toFetch.push(playlist);
}
}
this.portalStatuses.set(next);
if (toFetch.length === 0) {
return;
}
// Stream results: each portal's dot updates as soon as ITS request
// resolves, independent of the slowest one. Replaces the prior
// Promise.all that blocked all dots on the tail latency.
await Promise.all(
toFetch.map(async (playlist) => {
let status: PortalStatus;
try {
status = await this.portalStatusService.checkPortalStatus(
playlist.serverUrl,
playlist.username,
playlist.password
);
} catch {
status = 'unavailable';
}
if (controller.signal.aborted) {
return;
}
this.portalStatuses.update((current) => {
const updated = new Map(current);
updated.set(playlist._id, status);
return updated;
});
})
);
}
private cancelPortalStatusChecks(): void {
if (this.portalStatusAbortController) {
this.portalStatusAbortController.abort();
this.portalStatusAbortController = null;
}
}
private syncMenuOverlayWidthToTrigger(): void {
const triggerWidth = Math.round(
this.triggerElement().nativeElement.getBoundingClientRect().width
);
const overlayWidth = Math.max(triggerWidth, 400);
this.document.documentElement.style.setProperty(
'--playlist-switcher-overlay-width',
`${overlayWidth}px`
);
}
private clearMenuOverlayWidth(): void {
this.document.documentElement.style.removeProperty(
'--playlist-switcher-overlay-width'
);
}
private focusSearchField(): void {
this.clearSearchFocusTimeout();
this.focusSearchTimeoutId = setTimeout(() => {
const input = this.searchInput()?.nativeElement;
if (!input) {
return;
}
input.focus();
input.select();
this.focusSearchTimeoutId = null;
}, 0);
}
private clearSearchFocusTimeout(): void {
if (this.focusSearchTimeoutId !== null) {
clearTimeout(this.focusSearchTimeoutId);
this.focusSearchTimeoutId = null;
}
}
private readPersistedSearchQuery(): string {
try {
return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) ?? '';
} catch {
return '';
}
}
private writePersistedSearchQuery(value: string): void {
try {
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, value);
} catch {
// Ignore storage write failures.
}
}
private readPersistedTypeFilters(): Record<PlaylistFilterType, boolean> {
try {
const raw = localStorage.getItem(PLAYLIST_TYPE_FILTER_STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
const parsed = JSON.parse(raw) as Partial<
Record<PlaylistFilterType, boolean>
>;
const next = {
m3u: parsed.m3u !== false,
stalker: parsed.stalker !== false,
xtream: parsed.xtream !== false,
};
if (!next.m3u && !next.stalker && !next.xtream) {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
return next;
} catch {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
}
private writePersistedTypeFilters(
filters: Record<PlaylistFilterType, boolean>
): void {
try {
localStorage.setItem(
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
JSON.stringify(filters)
);
} catch {
// Ignore storage write failures.
onOverlayKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
this.close();
}
}
}
@@ -82,23 +82,6 @@
</div>
</div>
}
@case ('no-results') {
<div class="empty-state no-results-state">
<div class="illustration-container small">
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="65" cy="65" r="35" class="search-circle" stroke-width="4"/>
<line x1="90" y1="90" x2="115" y2="115" class="search-handle" stroke-width="4" stroke-linecap="round"/>
<rect x="45" y="50" width="40" height="30" rx="3" class="empty-box" stroke-width="2" opacity="0.4"/>
<line x1="55" y1="65" x2="75" y2="65" class="empty-line" stroke-width="2" opacity="0.3"/>
</svg>
</div>
<div class="content">
<h3 class="title small">{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}</h3>
<p class="description">{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}</p>
</div>
</div>
}
@case ('no-data') {
<div class="empty-state no-data-state">
<div class="empty-icon-container">
@@ -66,40 +66,6 @@
}
}
.no-results-state {
padding: 40px 24px;
.illustration-container {
margin-bottom: 24px;
opacity: 0.5;
&.small {
animation: gentleFloat 1s ease-out;
}
}
.illustration {
width: 120px;
height: 120px;
color: var(--app-body-color);
.search-circle,
.search-handle {
stroke: currentColor;
opacity: 0.4;
}
.empty-box,
.empty-line {
stroke: currentColor;
}
}
.description {
margin-bottom: 0;
font-size: 0.875rem;
}
}
.no-data-state {
padding: 56px 24px;
@@ -11,7 +11,7 @@ import type { PlaylistType } from '../../add-playlist-menu/playlist-type';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
export type EmptyStateType =
'welcome-dashboard' | 'welcome-sources' | 'no-results' | 'no-data';
'welcome-dashboard' | 'welcome-sources' | 'no-data';
interface FeatureCard {
icon: string;
@@ -1,12 +1,4 @@
@media (max-width: 480px) {
.no-results-state {
padding: 30px 20px;
.illustration {
width: 100px;
height: 100px;
}
}
.no-data-state {
padding: 40px 20px;
@@ -19,10 +11,6 @@
}
@media (max-height: 700px) {
.no-results-state {
padding: 24px 20px;
.illustration-container { margin-bottom: 16px; }
}
.no-data-state {
padding: 32px 24px;
@@ -30,12 +18,3 @@
}
}
@media (max-height: 550px) {
.no-results-state {
padding: 16px 20px;
.illustration-container { margin-bottom: 12px; }
.illustration { width: 80px; height: 80px; }
.title { font-size: 1.125rem; }
.description { font-size: 0.8125rem; }
}
}
@@ -1,290 +0,0 @@
<div
class="playlist-item"
cdkDrag
[cdkDragDisabled]="!isDraggable() || isBusy()"
cdkDragPreviewContainer="parent"
[class.selected]="isSelected()"
[class.is-busy]="isBusy()"
(click)="onPlaylistClick()"
>
@if (isDraggable()) {
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
}
<div class="playlist-icon">
@if (runtime.supportsSourceHealth) {
<app-source-health-indicator
[playlist]="item"
[enabled]="!isBusy()"
/>
}
<!-- One provider icon per row, by the shared precedence: a restored
Stalker or Xtream playlist can also carry a URL. -->
<div class="icon-container">
@switch (sourceIconKey) {
@case ('stalker') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.STALKER_PORTAL' | translate
"
>{{ sourceIcons.stalker }}</mat-icon
>
}
@case ('xtream') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate
"
>{{ sourceIcons.xtream }}</mat-icon
>
}
@case ('m3u-url') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) +
' ' +
item.url
"
>{{ sourceIcons['m3u-url'] }}</mat-icon
>
}
@default {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate
"
>{{ sourceIcons['m3u-local'] }}</mat-icon
>
}
}
@if (showsPortalStatusDot) {
@let statusLabel =
'HOME.PLAYLISTS.PORTAL_STATUS'
| translate
: { status: (portalStatusLabelKey() | translate) };
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="statusLabel"
[attr.aria-label]="statusLabel"
></span>
} @else if (showsAutoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
"
>autorenew</mat-icon
>
}
</div>
</div>
<!-- The keyboard activation element is its own element so the drag
handle and the actions stay siblings: an interactive control nested
inside a role="button" is an invalid accessibility structure. A
pointer click anywhere on the row still opens it via the row. -->
<div
class="playlist-content"
role="button"
tabindex="0"
[attr.aria-label]="item.title || item.filename"
[attr.aria-current]="isSelected() || null"
[attr.aria-disabled]="isBusy() || null"
(keydown.enter)="onActivationKey($event)"
(keydown.space)="onActivationKey($event)"
>
<div class="playlist-title">
{{ item.title || item.filename }}
</div>
<div class="meta">
@if (!item.serverUrl && !item.portalUrl) {
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{
item.importDate
| date: undefined : undefined : currentLocale()
}}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{
item.updateDate
| date
: 'MMMM d, yyyy, HH:mm'
: undefined
: currentLocale()
}}
}
@if (item.updateState === 2) {
<mat-icon
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
| translate
"
[inline]="true"
>warning</mat-icon
>
}
} @else {
@if (item.macAddress) {
Stalker
} @else {
Xtream
}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{
item.importDate
| date: undefined : undefined : currentLocale()
}}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{
item.updateDate
| date
: 'MMMM d, yyyy, HH:mm'
: undefined
: currentLocale()
}}
}
}
</div>
@if (isBusy() && busyMessage()) {
<div class="busy-state">
<span class="busy-state__message">{{ busyMessage() }}</span>
@if (busyProgress() !== null) {
<span class="busy-state__value">{{ busyProgress() }}%</span>
}
</div>
}
@if (isBusy() && busyMessage() && busyProgress() !== null) {
<div class="busy-state__bar">
<mat-progress-bar
mode="determinate"
[value]="busyProgress() ?? 0"
/>
</div>
}
@if (isBusy() && busyMessage() && busyProgress() === null) {
<div class="busy-state__bar">
<mat-progress-bar mode="indeterminate" />
</div>
}
</div>
@if (showActions()) {
<div class="action-buttons">
@if (runtime.supportsSourceHealth && sourceHealthType(item)) {
<button
mat-icon-button
[disabled]="isBusy()"
(click)="$event.stopPropagation(); recheckSource()"
[matTooltip]="'SOURCE_HEALTH.RECHECK' | translate"
[attr.aria-label]="'SOURCE_HEALTH.RECHECK' | translate"
>
<mat-icon>network_check</mat-icon>
</button>
}
@if (canCancelBusyAction()) {
<button
mat-icon-button
class="cancel-btn app-destructive-button"
(click)="
$event.stopPropagation(); cancelBusyActionClicked.emit()
"
[matTooltip]="
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@if (item.url || (item.filePath && supportsPlaylistRefresh)) {
<button
mat-icon-button
class="refresh-btn"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
[attr.aria-label]="'HOME.PLAYLISTS.REFRESH' | translate"
>
@if (isRefreshing()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>sync</mat-icon>
}
</button>
}
@if (item.serverUrl && supportsXtreamSqliteDataSource) {
<button
mat-icon-button
class="refresh-btn"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
[attr.aria-label]="
'HOME.PLAYLISTS.REFRESH_XTREAM' | translate
"
>
@if (isRefreshing()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>sync</mat-icon>
}
</button>
}
<button
mat-icon-button
class="edit-btn"
(click)="
$event.stopPropagation(); editPlaylistClicked.emit(item)
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
[attr.aria-label]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
class="delete-btn"
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
[attr.aria-label]="'HOME.PLAYLISTS.REMOVE' | translate"
>
@if (isDeleting()) {
<mat-spinner
class="action-spinner"
diameter="18"
strokeWidth="3"
/>
} @else {
<mat-icon>delete</mat-icon>
}
</button>
</div>
}
</div>
@@ -1,290 +0,0 @@
@use '../../../../../../../ui/styles/focus-ring';
:host {
display: block;
width: 100%;
}
.playlist-item {
width: 100%;
min-height: 68px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px 8px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
cursor: pointer;
box-sizing: border-box;
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease,
color 0.15s ease;
&:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
&.selected {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
color: var(--app-selection-color);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.meta,
.upload-type-icon,
.drag-icon,
.action-buttons button,
.action-buttons mat-icon {
color: inherit;
}
.status-dot {
border-color: color-mix(
in srgb,
var(--app-selection-surface-strong) 85%,
transparent
);
}
}
&.is-busy {
cursor: progress;
}
}
.playlist-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.playlist-content {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
// The row draws this element's focus ring.
outline: none;
}
// The keyboard ring belongs to the whole row, the unit that opens. It sits
// inside the row: the list's scroller would cut one drawn outside it.
.playlist-item:has(> .playlist-content:focus-visible) {
@include focus-ring.focus-ring-declarations($offset: -2px);
}
.playlist-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.96rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
}
.meta {
font-size: 0.82rem;
color: var(--mat-sys-on-surface-variant);
opacity: 0.84;
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.busy-state {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 0.7rem;
color: var(--app-selection-border, #8aa4c8);
margin-top: 2px;
}
.busy-state__message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.busy-state__value {
opacity: 0.8;
}
.busy-state__bar {
margin-top: 4px;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 8px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.25),
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
opacity: 0.9;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.drag-icon {
flex-shrink: 0;
cursor: move;
opacity: 0.46;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}
.action-buttons {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
margin-inline-start: auto;
button {
width: 34px;
height: 34px;
border-radius: 10px;
color: inherit;
opacity: 0.76;
transition:
opacity 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
transform: translateY(-1px);
}
mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
&:disabled {
transform: none;
opacity: 0.38;
}
}
}
// The action buttons inherit the row color, including the selected row's,
// which masks the .app-destructive-button token on the cancel action.
.playlist-item .action-buttons .cancel-btn,
.playlist-item.selected .action-buttons .cancel-btn {
color: var(--mat-sys-error);
}
.action-spinner {
display: block;
margin: 0 auto;
}
.icon-container {
position: relative;
display: inline-flex;
.upload-type-icon {
opacity: 0.75;
transition: opacity 0.15s ease;
}
.playlist-item:hover & .upload-type-icon {
opacity: 0.95;
}
}
.status-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
right: 0;
bottom: 0;
border: 2px solid var(--mat-sys-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.15s ease;
}
.status-active {
background-color: #4caf50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
}
.status-inactive {
background-color: #f44336;
box-shadow: 0 0 8px rgba(244, 67, 54, 0.4);
}
.status-expired {
background-color: #ff9800;
box-shadow: 0 0 8px rgba(255, 152, 0, 0.4);
}
.status-unavailable {
background-color: var(--mat-sys-on-surface-variant);
opacity: 0.5;
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #4caf50;
background-color: var(--mat-sys-surface);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.15s ease;
.playlist-item:hover & {
color: #66bb6a;
transform: rotate(90deg);
}
}
@media only screen and (max-width: 480px) {
.upload-type-icon,
.icon-container,
.action-buttons .refresh-btn,
.action-buttons .edit-btn,
.action-buttons .delete-btn {
display: none;
}
}
@@ -1,408 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import {
PortalStatusService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { PlaylistItemComponent } from './playlist-item.component';
describe('PlaylistItemComponent', () => {
let component: PlaylistItemComponent;
let fixture: ComponentFixture<PlaylistItemComponent>;
let runtime: {
supportsPlaylistRefresh: boolean;
supportsXtreamSqliteDataSource: boolean;
};
beforeEach(waitForAsync(() => {
runtime = {
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
};
TestBed.configureTestingModule({
imports: [
PlaylistItemComponent,
MockModule(MatIconModule),
MockModule(MatListModule),
MockModule(MatTooltipModule),
TranslateModule.forRoot(),
],
providers: [
{
provide: PortalStatusService,
useValue: {
checkPortalStatus: jest
.fn()
.mockResolvedValue('active'),
getStatusClass: jest.fn(() => 'status-active'),
getStatusIcon: jest.fn(() => 'check_circle'),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Playlist',
_id: '1',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
};
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('shows busy UI and suppresses playlist clicks while deleting', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
fixture.componentRef.setInput('isDeleting', true);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const deleteButton = nativeElement.querySelector(
'.delete-btn'
) as HTMLButtonElement;
const editButton = nativeElement.querySelector(
'.edit-btn'
) as HTMLButtonElement;
expect(component.isBusy()).toBe(true);
expect(deleteButton.disabled).toBe(true);
expect(editButton.disabled).toBe(true);
expect(nativeElement.querySelector('.action-spinner')).not.toBeNull();
component.onPlaylistClick();
expect(emitSpy).not.toHaveBeenCalled();
});
describe('keyboard activation', () => {
const row = () => fixture.nativeElement as HTMLElement;
const activation = () =>
row().querySelector('[role="button"]') as HTMLElement;
const keydown = (target: HTMLElement, key: string) => {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
return event;
};
it('is a Tab stop named after the source that Enter and Space open', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
const surface = activation();
expect(surface).not.toBeNull();
expect(surface.getAttribute('tabindex')).toBe('0');
expect(surface.getAttribute('aria-label')).toBe('Playlist');
const enter = keydown(surface, 'Enter');
const space = keydown(surface, ' ');
expect(emitSpy).toHaveBeenNthCalledWith(1, '1');
expect(emitSpy).toHaveBeenNthCalledWith(2, '1');
expect(enter.defaultPrevented).toBe(true);
// Space must not scroll the page.
expect(space.defaultPrevented).toBe(true);
});
it('keeps the drag handle and actions outside the activation element so their keys never open the source', () => {
fixture.componentRef.setInput('isDraggable', true);
fixture.detectChanges();
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
const editSpy = jest.spyOn(component.editPlaylistClicked, 'emit');
const controls = Array.from(
row().querySelectorAll<HTMLElement>(
'.action-buttons button, .drag-icon'
)
);
expect(activation()).not.toBeNull();
expect(controls.length).toBeGreaterThan(1);
// No interactive control nested inside a role="button".
for (const control of controls) {
expect(control.closest('[role="button"]')).toBeNull();
}
const editButton = row().querySelector(
'.edit-btn'
) as HTMLButtonElement;
keydown(editButton, 'Enter');
const space = keydown(editButton, ' ');
editButton.click();
// The button's own activation edits; the row must neither open
// the source nor swallow the Space the button relies on.
expect(emitSpy).not.toHaveBeenCalled();
expect(space.defaultPrevented).toBe(false);
expect(editSpy).toHaveBeenCalledTimes(1);
});
it('opens once for a pointer click anywhere on the row', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
activation().click();
(row().querySelector('.playlist-item') as HTMLElement).click();
(row().querySelector('.playlist-icon') as HTMLElement).click();
expect(emitSpy).toHaveBeenCalledTimes(3);
});
it('announces the selected source and ignores keys while busy', () => {
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
expect(activation().hasAttribute('aria-current')).toBe(false);
expect(activation().hasAttribute('aria-disabled')).toBe(false);
fixture.componentRef.setInput('isSelected', true);
fixture.componentRef.setInput('isRefreshing', true);
fixture.detectChanges();
expect(activation().getAttribute('aria-current')).toBe('true');
expect(activation().getAttribute('aria-disabled')).toBe('true');
// Still a Tab stop, so focus is not lost when a refresh starts.
expect(activation().getAttribute('tabindex')).toBe('0');
const space = keydown(activation(), ' ');
keydown(activation(), 'Enter');
expect(emitSpy).not.toHaveBeenCalled();
expect(space.defaultPrevented).toBe(true);
});
it('falls back to the file name for the accessible name', () => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
fixture.componentInstance.item = {
title: '',
filename: 'channels.m3u',
_id: 'file-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
};
fixture.detectChanges();
expect(activation().getAttribute('aria-label')).toBe(
'channels.m3u'
);
});
});
it('renders a refresh action for file-backed M3U playlists', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = true;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Local Source',
_id: 'local-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
filePath: '/tmp/local-source.m3u',
};
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
expect(nativeElement.querySelector('.refresh-btn')).not.toBeNull();
});
it('hides file-backed M3U refresh without the refresh bridge', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Local Source',
_id: 'local-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
filePath: '/tmp/local-source.m3u',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).toBeNull();
});
it('keeps URL-backed M3U refresh visible without the refresh bridge', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Remote Source',
_id: 'remote-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
url: 'https://example.com/playlist.m3u',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).not.toBeNull();
});
it('renders the Xtream refresh action only when the SQLite data source is available', () => {
fixture.destroy();
runtime.supportsXtreamSqliteDataSource = true;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Xtream Source',
_id: 'xtream-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
serverUrl: 'https://example.com',
username: 'demo',
password: 'secret',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).not.toBeNull();
fixture.destroy();
runtime.supportsXtreamSqliteDataSource = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Xtream Source',
_id: 'xtream-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
serverUrl: 'https://example.com',
username: 'demo',
password: 'secret',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).toBeNull();
});
// OnPush: the status arrives after an await outside any template event,
// so it must reach the view without a zone-triggered tick.
it('renders the portal status that resolves after the first render', async () => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Xtream Source',
_id: 'xtream-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
serverUrl: 'https://example.com',
username: 'demo',
password: 'secret',
};
const translate = TestBed.inject(TranslateService);
// Not English text, so the label must come from the keys.
translate.setTranslation('en', {
HOME: {
PLAYLISTS: {
PORTAL_STATUS: 'Portalstatus: {{status}}',
PORTAL_STATUS_ACTIVE: 'aktiv',
},
},
});
translate.use('en');
// The fixture renders on its own: a forced detectChanges() after the
// await would hide a status that does not schedule a render.
fixture.autoDetectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
const statusDot = (fixture.nativeElement as HTMLElement).querySelector(
'.status-dot'
);
expect(statusDot?.getAttribute('aria-label')).toBe(
'Portalstatus: aktiv'
);
});
it('renders cancel and progress UI for long-running playlist actions', () => {
fixture.componentRef.setInput('isDeleting', true);
fixture.componentRef.setInput(
'busyMessage',
'Removing cached content...'
);
fixture.componentRef.setInput('busyProgress', 42);
fixture.componentRef.setInput('canCancelBusyAction', true);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
expect(
nativeElement.querySelector('.busy-state__message')?.textContent
).toContain('Removing cached content...');
expect(
nativeElement.querySelector('.busy-state__value')?.textContent
).toContain('42%');
expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull();
});
it.each([
[
{ macAddress: '00:1A:79:00:00:01', url: 'http://portal.test' },
'cast',
true,
],
[{ macAddress: '00:1A:79:00:00:01' }, 'cast', false],
[{ serverUrl: 'http://xtream.test' }, 'cloud', false],
[{ url: 'http://list.test/playlist.m3u' }, 'link', true],
[{}, 'description', true],
])(
'shows one provider icon for %o (%s) and keeps the auto-refresh badge: %s',
(source, icon, autoRefreshBadge) => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
fixture.componentInstance.item = {
title: 'Source',
_id: 'source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: true,
...source,
};
fixture.detectChanges();
const row = fixture.nativeElement as HTMLElement;
const icons = Array.from(
row.querySelectorAll('.upload-type-icon'),
(element) => element.textContent?.trim()
);
expect(icons).toEqual([icon]);
expect(row.querySelector('.auto-refresh-indicator') !== null).toBe(
autoRefreshBadge
);
}
);
});
@@ -1,173 +0,0 @@
import { Injector } from '@angular/core';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import {
PlaylistSourceIconKey,
resolvePlaylistSourceIconKey,
SOURCE_TYPE_ICONS,
sourceHealthType,
} from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../../source-health/source-health-indicator.component';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common';
import {
Component,
Input,
OnInit,
computed,
inject,
input,
output,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
PortalStatus,
PortalStatusService,
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
/** Translation keys for the status word in the portal status tooltip. */
const PORTAL_STATUS_LABEL_KEYS: Record<PortalStatus, string> = {
active: 'HOME.PLAYLISTS.PORTAL_STATUS_ACTIVE',
inactive: 'HOME.PLAYLISTS.PORTAL_STATUS_INACTIVE',
expired: 'HOME.PLAYLISTS.PORTAL_STATUS_EXPIRED',
unavailable: 'HOME.PLAYLISTS.PORTAL_STATUS_UNAVAILABLE',
checking: 'HOME.PLAYLISTS.PORTAL_STATUS_CHECKING',
};
@Component({
selector: 'app-playlist-item',
templateUrl: './playlist-item.component.html',
styleUrls: ['./playlist-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SourceHealthIndicatorComponent,
DatePipe,
DragDropModule,
MatIconButton,
MatIcon,
MatProgressBarModule,
MatProgressSpinnerModule,
MatTooltip,
TranslatePipe,
],
})
export class PlaylistItemComponent implements OnInit {
@Input() item!: PlaylistMeta;
readonly showActions = input(true);
readonly isDraggable = input(false);
readonly isSelected = input(false);
readonly isRefreshing = input(false);
readonly isDeleting = input(false);
readonly busyMessage = input('');
readonly busyProgress = input<number | null>(null);
readonly canCancelBusyAction = input(false);
readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting());
readonly editPlaylistClicked = output<PlaylistMeta>();
readonly playlistClicked = output<string>();
readonly refreshClicked = output<PlaylistMeta>();
readonly removeClicked = output<string>();
readonly cancelBusyActionClicked = output<void>();
readonly portalStatus = signal<PortalStatus>('unavailable');
readonly portalStatusLabelKey = computed(
() => PORTAL_STATUS_LABEL_KEYS[this.portalStatus()]
);
private readonly portalStatusService = inject(PortalStatusService);
readonly runtime = inject(RuntimeCapabilitiesService);
readonly sourceIcons = SOURCE_TYPE_ICONS;
get sourceIconKey(): PlaylistSourceIconKey {
return resolvePlaylistSourceIconKey(this.item);
}
/** Without source health, Xtream rows badge the portal status instead. */
get showsPortalStatusDot(): boolean {
return (
this.sourceIconKey === 'xtream' &&
!this.runtime.supportsSourceHealth
);
}
/**
* Auto-refresh re-fetches a URL or a local file, so any row with a URL
* keeps the badge, whichever provider icon it shows.
*/
get showsAutoRefresh(): boolean {
return (
!!this.item.autoRefresh &&
(!!this.item.url || this.sourceIconKey === 'm3u-local')
);
}
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
readonly supportsPlaylistRefresh = this.runtime.supportsPlaylistRefresh;
readonly supportsXtreamSqliteDataSource =
this.runtime.supportsXtreamSqliteDataSource;
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
private readonly healthInjector = inject(Injector);
readonly sourceHealthType = sourceHealthType;
recheckSource(): void {
void this.healthInjector.get(SourceHealthService).recheck(this.item);
}
async ngOnInit() {
if (!this.runtime.supportsSourceHealth) await this.checkPortalStatus();
}
private async checkPortalStatus() {
if (this.item.serverUrl && this.item.username && this.item.password) {
this.portalStatus.set(
await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
)
);
}
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.portalStatus());
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.portalStatus());
}
onPlaylistClick(): void {
if (this.isBusy()) {
return;
}
this.playlistClicked.emit(this.item._id);
}
/**
* Enter/Space open the source like a click on the row; Space also
* prevents the page scroll. The drag handle and the actions are siblings
* of the activation element, never descendants, so their keys cannot
* reach this handler.
*/
onActivationKey(event: Event): void {
event.preventDefault();
this.onPlaylistClick();
}
}
@@ -1,45 +1,53 @@
@if (playlistsData(); as data) {
@if (data.playlists.length === 0) {
@if (data.totalCount === 0) {
<!-- Welcome state: no playlists at all -->
<app-empty-state
[type]="'welcome-sources'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- No results state: search/filter returned nothing -->
<app-empty-state [type]="'no-results'" />
}
} @else {
<div
class="playlists-list"
cdkDropList
[cdkDropListDisabled]="!isCustomSortActive()"
(cdkDropListDropped)="drop($event, data.playlists)"
>
@for (
item of data.playlists;
track item._id;
let last = $last
) {
<app-playlist-item
[item]="item"
[isSelected]="activePlaylistId() === item._id"
[showActions]="!sidebarMode()"
[isDraggable]="isCustomSortActive()"
[isRefreshing]="isRefreshPending(item._id)"
[isDeleting]="isDeletePending(item._id)"
[busyMessage]="getBusyMessage(item)"
[busyProgress]="getBusyProgress(item._id)"
[canCancelBusyAction]="canCancelBusyOperation(item)"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked(item)"
(cancelBusyActionClicked)="cancelBusyOperation(item)"
/>
}
</div>
@if (playlists().length === 0) {
@if (allPlaylistsLoaded()) {
<app-empty-state
[type]="'welcome-sources'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
}
} @else if (visiblePlaylists().length === 0) {
<div class="sources-empty no-results-state" role="status">
<p class="sources-empty__message">
{{ 'WORKSPACE.SOURCES.NO_MATCHES' | translate }}
</p>
@if (hasFilters()) {
<button
mat-button
type="button"
class="sources-empty__reset"
(click)="resetFilters()"
>
<mat-icon>filter_alt_off</mat-icon>
{{ 'WORKSPACE.SOURCES.RESET_FILTERS' | translate }}
</button>
}
</div>
} @else {
<div
class="sources-list"
cdkDropList
[cdkDropListDisabled]="!isCustomSort()"
(cdkDropListDropped)="drop($event)"
>
@for (item of visiblePlaylists(); track item._id) {
<app-source-row
[playlist]="item"
[current]="activePlaylistId() === item._id"
[draggable]="isCustomSort()"
[canRefresh]="canRefresh(item)"
[refreshing]="isRefreshing(item)"
[removable]="isRemovable(item)"
[deleting]="removal.progress.isDeleting(item._id)"
[busyMessage]="busyMessage(item)"
[busyProgress]="busyProgress(item)"
[canCancelBusy]="canCancelBusy(item)"
(activated)="open(item)"
(refreshRequested)="refresh.refresh(item)"
(editRequested)="edit(item)"
(removeRequested)="remove(item)"
(cancelBusyRequested)="cancelBusy(item)"
/>
}
</div>
}
@@ -1,7 +1,8 @@
@use '../../../../../../ui/styles/app-controls' as controls;
:host {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
min-height: 0;
@@ -15,35 +16,40 @@
min-height: 0;
}
.playlists-list {
padding: 0 6px;
.sources-list {
display: flex;
flex-direction: column;
width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
#skeleton-container {
padding-top: 10px;
width: 100%;
.skeleton-item {
display: flex;
padding: 0 20px;
height: 72px;
.text {
flex: 1;
align-self: center;
padding-left: 6px;
flex-direction: column;
display: flex;
}
}
// A 1px line between rows, hidden around the hovered row so its fill
// reads as one shape.
app-source-row + app-source-row {
border-top: 1px solid var(--app-widget-header-border);
}
@media only screen and (max-width: 480px) {
.favorites-icon {
padding-left: 0 !important;
display: none;
}
app-source-row:hover,
app-source-row:hover + app-source-row,
app-source-row:focus-within,
app-source-row:focus-within + app-source-row {
border-top-color: transparent;
}
.sources-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 64px 16px;
text-align: center;
}
.sources-empty__message {
margin: 0;
font-size: 13px;
color: var(--app-body-color);
}
.sources-empty__reset {
@include controls.ghost-button(sm);
}
File diff suppressed because it is too large. Load diff
@@ -6,97 +6,86 @@ import {
import {
ChangeDetectionStrategy,
Component,
effect,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatIcon } from '@angular/material/icon';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { TranslatePipe } from '@ngx-translate/core';
import {
PlaylistActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DialogService } from '@iptvnator/ui/components';
import {
DatabaseService,
DataService,
DbOperationEvent,
isDbAbortError,
PlaylistDeleteActionService,
PlaylistRefreshService,
keepSourcesChecked,
SourceStatusService,
sourceStatusBucket,
} from '@iptvnator/portal/shared/data-access';
import {
filterSources,
orderFailingFirst,
PlaylistContextFacade,
replacementAfterRemoval,
SourceBucketOf,
sourceSortName,
SourcesViewStateService,
} from '@iptvnator/playlist/shared/util';
import {
RuntimeCapabilitiesService,
SortBy,
SortService,
SourceActivityService,
} from '@iptvnator/services';
import {
foldSearchText,
PLAYLIST_UPDATE,
PlaylistMeta,
PlaylistRefreshEvent,
} from '@iptvnator/shared/interfaces';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import {
XtreamRefreshFlowService,
type XtreamRefreshProgressReporter,
} from '../xtream-refresh-flow.service';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import type { PlaylistType } from '../add-playlist-menu/playlist-type';
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
import { SourceRemovalService } from '../source-removal/source-removal.service';
import { SourceRowComponent } from '../source-row/source-row.component';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
type PlaylistBusyOperation = {
current?: number;
operation: string;
operationId?: string;
phase?: string;
status: DbOperationEvent['status'];
total?: number;
};
import { SourceListRefreshService } from './source-list-refresh.service';
/**
* The Sources page list: every source that passes the side list's type and
* status filters and the header search, in the chosen sort order, as
* `app-source-row`s. Sources being deleted (Undo pending) are left out.
*/
@Component({
selector: 'app-recent-playlists',
templateUrl: './recent-playlists.component.html',
styleUrls: ['./recent-playlists.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [SourceListRefreshService],
imports: [
DragDropModule,
EmptyStateComponent,
MatInputModule,
PlaylistItemComponent,
MatButton,
MatIcon,
SourceRowComponent,
TranslatePipe,
],
})
export class RecentPlaylistsComponent {
private readonly databaseService = inject(DatabaseService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly dataService = inject(DataService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly sortService = inject(SortService);
private readonly store = inject(Store);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly translate = inject(TranslateService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService);
private readonly statusService = inject(SourceStatusService);
private readonly viewState = inject(SourcesViewStateService);
readonly removal = inject(SourceRemovalService);
readonly refresh = inject(SourceListRefreshService);
private readonly refreshAction = inject(PlaylistRefreshActionService);
private readonly activity = inject(SourceActivityService);
readonly sidebarMode = input(false);
readonly searchQueryInput = input<string>('');
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<PlaylistType | undefined>();
@@ -105,115 +94,96 @@ export class RecentPlaylistsComponent {
return this.runtime.isElectron;
}
get supportsPlaylistRefresh(): boolean {
return this.runtime.supportsPlaylistRefresh;
}
get supportsXtreamSqliteDataSource(): boolean {
return this.runtime.supportsXtreamSqliteDataSource;
}
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
private readonly allPlaylists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{ requireSync: true }
private readonly sortOptions = toSignal(this.sortService.getSortOptions(), {
requireSync: true,
});
readonly isCustomSort = computed(
() => this.sortOptions().by === SortBy.CUSTOM
);
readonly isCustomSortActive = () =>
this.currentSortOptions().by === SortBy.CUSTOM;
readonly searchQuery = new BehaviorSubject<string>('');
readonly pendingDeletionIds = signal<Set<string>>(new Set());
readonly pendingRefreshIds = signal<Set<string>>(new Set());
readonly busyOperations = signal<Map<string, PlaylistBusyOperation>>(
new Map()
);
constructor() {
// Update searchQuery when input changes
effect(() => {
this.searchQuery.next(this.searchQueryInput());
});
}
readonly playlistsData$ = combineLatest([
this.store.select(selectAllPlaylistsMeta),
this.searchQuery,
this.store.select(selectActiveTypeFilters),
this.sortService.getSortOptions(),
]).pipe(
map(([playlists, searchQuery, filters, sortOptions]) => {
const filteredPlaylists = playlists
.filter((item) => {
const isStalkerFilter =
item.macAddress && filters.includes('stalker');
const isXtreamFilter =
item.username &&
item.password &&
item.serverUrl &&
filters.includes('xtream');
const isM3uFilter =
!item.username &&
!item.password &&
!item.serverUrl &&
!item.macAddress &&
filters.includes('m3u');
return (
(isStalkerFilter && filters.includes('stalker')) ||
(isXtreamFilter && filters.includes('xtream')) ||
(isM3uFilter && filters.includes('m3u'))
);
})
.filter((item) =>
foldSearchText(item.title || '').includes(
foldSearchText(searchQuery)
)
);
// Apply sorting using the SortService
const sortedPlaylists = this.sortService.sortPlaylists(
filteredPlaylists,
sortOptions
);
return {
playlists: sortedPlaylists,
totalCount: playlists.length,
};
})
);
readonly playlistsData = toSignal(this.playlistsData$, {
initialValue: {
playlists: [] as PlaylistMeta[],
totalCount: 0,
},
readonly hasFilters = computed(() => {
const filter = this.viewState.filter();
return filter.type !== 'all' || filter.status !== 'all';
});
/**
* Opens the details dialog with the information about the provided playlist
* @param data selected playlist
*/
openInfoDialog(data: PlaylistMeta): void {
this.dialog.open(PlaylistInfoComponent, {
data,
});
/** Every source except those whose delete is pending. */
readonly playlists = computed(() => {
const pending = this.removal.pendingIds();
return this.allPlaylists().filter(
(playlist) => !pending.has(playlist._id)
);
});
readonly visiblePlaylists = computed(() => {
const bucketOf: SourceBucketOf = (playlist) => {
const status = this.statusService.statusOf(playlist);
return status ? sourceStatusBucket(status.reachability) : null;
};
const options = this.sortOptions();
const filter = this.viewState.filter();
const matching = filterSources(
this.playlists(),
this.statusService.supportsStatusFilter
? filter
: { ...filter, status: 'all' },
this.searchQueryInput(),
bucketOf
);
// A running delete keeps its row, whatever the filters: its
// progress and Cancel live there (one started from the switcher
// has nowhere else to show).
const filtered = this.playlists().filter(
(playlist) =>
matching.includes(playlist) ||
this.removal.progress.isDeleting(playlist._id)
);
const sorted = this.sortService.sortPlaylists(
filtered,
options,
sourceSortName
);
return options.failingFirst && options.by !== SortBy.CUSTOM
? orderFailingFirst(sorted, bucketOf)
: sorted;
});
constructor() {
// A status filter hides the sources it has no result for, so the
// checks follow the library rather than the rows on screen.
keepSourcesChecked(() => this.playlists());
}
resetFilters(): void {
this.viewState.reset();
}
/**
* Drop event handler - applies the new sort order to the playlists array
* @param event drop event
* Applies a drag in custom order to the whole library: the shown rows
* trade places among the slots they occupy, and sources a filter hides
* keep theirs, so every position stays unique.
*/
drop(event: CdkDragDrop<PlaylistMeta[]>, playlists: PlaylistMeta[]): void {
moveItemInArray(playlists, event.previousIndex, event.currentIndex);
drop(event: CdkDragDrop<PlaylistMeta[]>): void {
const visible = [...this.visiblePlaylists()];
moveItemInArray(visible, event.previousIndex, event.currentIndex);
const visibleIds = new Set(visible.map((item) => item._id));
const ordered = this.sortService.sortPlaylists(
this.allPlaylists(),
this.sortOptions(),
sourceSortName
);
let shown = 0;
const merged = ordered.map((item) =>
visibleIds.has(item._id) ? visible[shown++] : item
);
this.store.dispatch(
PlaylistActions.updatePlaylistPositions({
positionUpdates: playlists.map((item, index) => ({
positionUpdates: merged.map((item, index) => ({
id: item._id,
changes: { position: index },
})),
@@ -221,475 +191,100 @@ export class RecentPlaylistsComponent {
);
}
onAddPlaylist(type?: PlaylistType) {
open(playlist: PlaylistMeta): void {
if (playlist.serverUrl) {
void this.router.navigate(['/workspace', 'xtreams', playlist._id]);
} else if (playlist.macAddress) {
void this.router.navigate(['/workspace', 'stalker', playlist._id]);
} else {
void this.router.navigate([
'/workspace',
'playlists',
playlist._id,
]);
this.playlistClicked.emit(playlist._id);
}
}
edit(playlist: PlaylistMeta): void {
this.dialog.open(PlaylistInfoComponent, { data: playlist });
}
/** Deletes with Undo; the active source hands over to its neighbour. */
remove(playlist: PlaylistMeta): void {
if (this.refresh.isRefreshing(playlist._id)) {
return;
}
const next = replacementAfterRemoval(
this.visiblePlaylists(),
playlist._id,
(row) => !this.removal.canTakeOver(row._id)
);
this.removal.remove(playlist, next);
}
/** Refreshing from this page, or from the playlist switcher. */
isRefreshing(item: PlaylistMeta): boolean {
return (
this.refresh.isRefreshing(item._id) ||
this.refreshAction.isSourceBusy(item._id)
);
}
/**
* Refresh is offered while it can run, and kept (with its spinner)
* while this source refreshes. Held by anything else (the startup
* auto-refresh, an import, a delete), refresh() would refuse it.
*/
canRefresh(item: PlaylistMeta): boolean {
return (
this.refresh.canRefresh(item) &&
(this.isRefreshing(item) || !this.activity.isBusy(item._id))
);
}
/** Nothing else (a refresh, the startup auto-refresh) holds it. */
isRemovable(item: PlaylistMeta): boolean {
return !this.activity.isBusy(item._id);
}
busyMessage(item: PlaylistMeta): string {
return (
this.removal.progress.message(item._id) ||
this.refresh.message(item)
);
}
busyProgress(item: PlaylistMeta): number | null {
return this.removal.progress.isDeleting(item._id)
? this.removal.progress.progress(item._id)
: this.refresh.progress(item._id);
}
canCancelBusy(item: PlaylistMeta): boolean {
return (
this.removal.progress.canCancel(item._id) ||
this.refresh.canCancel(item)
);
}
cancelBusy(item: PlaylistMeta): void {
if (this.removal.progress.isDeleting(item._id)) {
void this.removal.progress.cancel(item._id);
} else {
void this.refresh.cancel(item);
}
}
onAddPlaylist(type?: PlaylistType): void {
this.addPlaylistClicked.emit(type);
}
getPlaylist(playlistMeta: PlaylistMeta): void {
if (playlistMeta.serverUrl) {
this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]);
} else if (playlistMeta.macAddress) {
this.router.navigate(['/workspace', 'stalker', playlistMeta._id]);
} else {
this.router.navigate(['/workspace', 'playlists', playlistMeta._id]);
this.playlistClicked.emit(playlistMeta._id);
}
}
/**
* Triggers on remove click
* @param playlistId playlist id to remove
*/
removeClicked(item: PlaylistMeta): void {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
tone: 'destructive',
onConfirm: () => {
this.removePlaylist(item);
},
});
}
/**
* Removes the provided playlist from the database
* @param playlistId playlist id to remove
*/
async removePlaylist(item: PlaylistMeta) {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.setPendingDeletion(item._id, true);
try {
const deleted = await this.playlistDeleteAction.deletePlaylist(
item,
{
onEvent: (event) =>
this.updateBusyOperation(item._id, event),
}
);
if (deleted) {
if (item.serverUrl) {
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
() =>
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: item._id,
})
),
() => ({ items: 1 })
);
} else {
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: item._id,
})
);
}
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
),
undefined,
{
duration: 2000,
}
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingDeletion(item._id, false);
}
}
/**
* Sends an IPC event with the playlist details to the main process to trigger the refresh operation
* @param item playlist to update
*/
refreshPlaylist(item: PlaylistMeta) {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
if (item.serverUrl && this.supportsXtreamSqliteDataSource) {
// For Xtream playlists, delete and re-import
this.refreshXtreamPlaylist(item);
} else if (
this.supportsPlaylistRefresh &&
(item.url || item.filePath)
) {
void this.refreshM3uPlaylist(item);
} else if (item.url) {
// Browser/PWA URL refresh uses the PWA data service path.
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
});
}
}
/**
* Refresh Xtream playlist by deleting all data and re-importing from remote
* @param item Xtream playlist to refresh
*/
refreshXtreamPlaylist(item: PlaylistMeta): void {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.xtreamRefreshFlow.confirmAndRefresh(
item,
this.xtreamRefreshReporter
);
}
/**
* Reports the shared destructive refresh into this page's per-row busy
* indicators, which are keyed by playlist and shared with deletion — hence
* the same `updateBusyOperation`/`clearBusyOperation` pair used there.
*/
private readonly xtreamRefreshReporter: XtreamRefreshProgressReporter = {
isBusy: (playlistId) =>
this.isDeletePending(playlistId) ||
this.isRefreshPending(playlistId),
begin: ({ playlistId }) => this.setPendingRefresh(playlistId, true),
report: ({ playlistId }, event) =>
this.updateBusyOperation(playlistId, event),
end: ({ playlistId }) => {
this.clearBusyOperation(playlistId);
this.setPendingRefresh(playlistId, false);
},
};
private async refreshM3uPlaylist(item: PlaylistMeta): Promise<void> {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.setPendingRefresh(item._id, true);
const operationId =
this.databaseService.createOperationId('playlist-refresh');
try {
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist(
{
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
...(item.userAgent
? { userAgent: item.userAgent }
: {}),
filePath: item.filePath,
},
{
onEvent: (event) =>
this.updateBusyOperation(
item._id,
this.toPlaylistRefreshBusyEvent(event)
),
}
);
this.updateBusyOperation(item._id, {
operationId,
operation: 'playlist-refresh',
playlistId: item._id,
phase: 'saving',
status: 'progress',
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
this.clearBusyOperation(item._id);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getPlaylistRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingRefresh(item._id, false);
}
}
isDeletePending(playlistId: string): boolean {
return this.pendingDeletionIds().has(playlistId);
return this.removal.isPending(playlistId);
}
isRefreshPending(playlistId: string): boolean {
return this.pendingRefreshIds().has(playlistId);
}
getBusyMessage(item: PlaylistMeta): string {
const operation = this.busyOperations().get(item._id);
if (!operation) {
return '';
}
switch (operation.operation) {
case 'delete-playlist':
return this.translateDeletePhase(operation.phase);
case 'delete-xtream-content':
return this.translateRefreshPhase(operation.phase);
case 'playlist-refresh':
return this.translatePlaylistRefreshPhase(operation.phase);
default:
return '';
}
}
getBusyProgress(playlistId: string): number | null {
const operation = this.busyOperations().get(playlistId);
if (
!operation ||
operation.current == null ||
operation.total == null ||
operation.total <= 0
) {
return null;
}
return Math.min(
100,
Math.round((operation.current / operation.total) * 100)
);
}
canCancelBusyOperation(item: PlaylistMeta): boolean {
const operation = this.busyOperations().get(item._id);
if (!operation?.operationId) {
return false;
}
if (operation.operation === 'playlist-refresh') {
return true;
}
return Boolean(item.serverUrl);
}
async cancelBusyOperation(item: PlaylistMeta): Promise<void> {
const operation = this.busyOperations().get(item._id);
if (!operation?.operationId) {
return;
}
if (operation.operation === 'playlist-refresh') {
await this.playlistRefreshService.cancelRefresh(
operation.operationId
);
return;
}
await this.databaseService.cancelOperation(operation.operationId);
}
private setPendingDeletion(playlistId: string, isPending: boolean): void {
this.pendingDeletionIds.update((current) => {
const next = new Set(current);
if (isPending) {
next.add(playlistId);
} else {
next.delete(playlistId);
}
return next;
});
}
private setPendingRefresh(playlistId: string, isPending: boolean): void {
this.pendingRefreshIds.update((current) => {
const next = new Set(current);
if (isPending) {
next.add(playlistId);
} else {
next.delete(playlistId);
}
return next;
});
}
private updateBusyOperation(
playlistId: string,
event: DbOperationEvent
): void {
this.busyOperations.update((current) => {
const next = new Map(current);
if (
event.status === 'completed' ||
event.status === 'cancelled' ||
event.status === 'error'
) {
next.delete(playlistId);
return next;
}
next.set(playlistId, {
operation: event.operation,
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
status: event.status,
});
return next;
});
}
private clearBusyOperation(playlistId: string): void {
this.busyOperations.update((current) => {
const next = new Map(current);
next.delete(playlistId);
return next;
});
}
private toPlaylistRefreshBusyEvent(
event: PlaylistRefreshEvent
): DbOperationEvent {
return {
operationId: event.operationId,
operation: 'playlist-refresh',
playlistId: event.playlistId,
phase: event.phase,
status: event.status,
error: event.error,
};
}
private translateDeletePhase(phase?: string): string {
switch (phase) {
case 'deleting-favorites':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES'
);
case 'deleting-recently-viewed':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT'
);
case 'deleting-playback-positions':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS'
);
case 'deleting-downloads':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS'
);
case 'deleting-content':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
);
case 'deleting-categories':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES'
);
case 'deleting-playlist':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST'
);
default:
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
);
}
}
private translateRefreshPhase(phase?: string): string {
switch (phase) {
case 'collecting-user-data':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
);
case 'deleting-content':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT'
);
case 'deleting-categories':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES'
);
default:
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
);
}
}
private translatePlaylistRefreshPhase(phase?: string): string {
switch (phase) {
case 'fetching':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_FETCHING'
);
case 'reading-file':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_READING_FILE'
);
case 'parsing':
return this.translate.instant('HOME.PLAYLISTS.REFRESH_PARSING');
case 'saving':
return this.translate.instant('HOME.PLAYLISTS.REFRESH_SAVING');
default:
return this.translate.instant('HOME.PLAYLISTS.REFRESH');
}
}
private getPlaylistRefreshErrorMessage(
error: unknown,
item: PlaylistMeta
): string {
if (item.filePath) {
const message = String(
(error as { message?: string })?.message ?? error
);
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR'
);
}
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
return this.refresh.isRefreshing(playlistId);
}
}
@@ -0,0 +1,639 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PlaylistActions,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of } from 'rxjs';
import { DialogService } from '@iptvnator/ui/components';
import {
DatabaseService,
DataService,
DbOperationEvent,
PlaybackPositionService,
PlaylistDeleteActionService,
PlaylistRefreshService,
RuntimeCapabilitiesService,
SortBy,
SortOrder,
SortService,
SourceActivityService,
} from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
PLAYLIST_UPDATE,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { SourceListRefreshService } from './source-list-refresh.service';
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
function setPerformanceHook(
hook: ((event: RendererPerformancePhaseEvent) => void) | null
): void {
const target = globalThis as unknown as Record<symbol, unknown>;
if (hook === null) {
delete target[performanceHookSymbol];
} else {
target[performanceHookSymbol] = hook;
}
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createAbortError(): Error {
const error = new Error('Cancelled');
error.name = 'AbortError';
return error;
}
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Xtream Playlist',
count: 0,
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
...overrides,
} as PlaylistMeta;
}
describe('SourceListRefreshService', () => {
let component: SourceListRefreshService;
let databaseService: {
cancelOperation: jest.Mock;
createOperationId: jest.Mock;
deletePlaylist: jest.Mock;
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let dataService: {
sendIpcEvent: jest.Mock;
};
let playbackPositionService: {
getAllPlaybackPositions: jest.Mock;
};
let playlistDeleteAction: {
deletePlaylist: jest.Mock;
};
let playlistRefreshService: {
cancelRefresh: jest.Mock;
refreshPlaylist: jest.Mock;
};
let runtime: {
isElectron: boolean;
supportsPlaylistRefresh: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let router: {
navigate: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: MockStore;
beforeEach(async () => {
databaseService = {
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
deletePlaylist: jest.fn(),
deleteXtreamPlaylistContent: jest.fn(),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
dataService = {
sendIpcEvent: jest.fn(),
};
playbackPositionService = {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
};
playlistDeleteAction = {
deletePlaylist: jest.fn().mockResolvedValue(true),
};
playlistRefreshService = {
cancelRefresh: jest.fn().mockResolvedValue(undefined),
refreshPlaylist: jest.fn().mockResolvedValue({
id: 'playlist-1',
items: [],
}),
};
runtime = {
isElectron: true,
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
};
router = {
navigate: jest.fn(),
};
snackBar = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
providers: [
SourceListRefreshService,
provideMockStore({
selectors: [
{ selector: selectPlaylistsLoadingFlag, value: true },
{
selector: selectAllPlaylistsMeta,
value: [],
},
],
}),
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: DataService,
useValue: dataService,
},
{
provide: PlaybackPositionService,
useValue: playbackPositionService,
},
{
provide: PlaylistDeleteActionService,
useValue: playlistDeleteAction,
},
{
provide: PlaylistRefreshService,
useValue: playlistRefreshService,
},
{
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
{
provide: PlaylistContextFacade,
useValue: {
resolvedPlaylistId: signal<string | null>(null),
},
},
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: Router,
useValue: router,
},
{
provide: SortService,
useValue: {
getSortOptions: jest.fn(() =>
of({
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
})
),
sortPlaylists: jest.fn(
(playlists: PlaylistMeta[]) => playlists
),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
}).compileComponents();
component = TestBed.inject(SourceListRefreshService);
store = TestBed.inject(MockStore);
jest.spyOn(store, 'dispatch');
});
afterEach(() => {
setPerformanceHook(null);
jest.restoreAllMocks();
localStorage.clear();
});
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-1' });
const refresh = createDeferred<{
success: boolean;
favorites: Array<{ xtreamId: number; contentType: string }>;
recentlyViewed: Array<{
xtreamId: number;
contentType: string;
viewedAt: string;
}>;
hiddenCategories: Array<{
xtreamId: number;
categoryType: string;
}>;
}>();
let confirmPromise: Promise<void> | undefined;
// The shared flow awaits the connectivity-guard reset before it starts
// deleting, so the delete is several ticks away and counting them is
// fragile. The mock fires onEvent synchronously and the component
// applies it synchronously, so resolving this deferred inside the mock
// is a deterministic "the busy row has been updated" signal.
const workerEventDelivered = createDeferred<void>();
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
databaseService.deleteXtreamPlaylistContent.mockImplementation(
(
_playlistId: string,
options?: {
onEvent?: (event: DbOperationEvent) => void;
operationId?: string;
}
) => {
options?.onEvent?.({
operation: 'delete-xtream-content',
operationId: 'xtream-refresh-op',
status: 'progress',
phase: 'collecting-user-data',
current: 1,
total: 4,
});
workerEventDelivered.resolve();
return refresh.promise;
}
);
component.refresh(item);
await workerEventDelivered.promise;
expect(component.isRefreshing(item._id)).toBe(true);
expect(component.message(item)).toBe(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
);
expect(component.progress(item._id)).toBe(25);
expect(component.canCancel(item)).toBe(true);
await component.cancel(item);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'xtream-refresh-op'
);
refresh.reject(createAbortError());
await confirmPromise;
expect(component.isRefreshing(item._id)).toBe(false);
expect(component.progress(item._id)).toBeNull();
expect(router.navigate).not.toHaveBeenCalled();
});
it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
let confirmPromise: Promise<void> | undefined;
const executionOrder: string[] = [];
const performanceEvents: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => {
performanceEvents.push(event);
executionOrder.push(event.boundary);
});
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712145600000);
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (
this: Storage,
key: string,
value: string
) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
(store.dispatch as jest.Mock).mockImplementation((action: unknown) => {
executionOrder.push('dispatch');
return action;
});
router.navigate.mockImplementation((commands: unknown[]) => {
executionOrder.push('navigate');
return Promise.resolve(Boolean(commands));
});
databaseService.deleteXtreamPlaylistContent.mockResolvedValue({
success: true,
favorites: [
{ xtreamId: 101, contentType: 'live' },
{ xtreamId: 202, contentType: 'movie' },
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
});
component.refresh(item);
await confirmPromise;
expect(dialogService.openConfirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
width: '400px',
})
);
expect(
databaseService.updateXtreamPlaylistDetails
).toHaveBeenCalledWith({
id: item._id,
updateDate: 1712145600000,
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712145600000 },
})
);
expect(setItemSpy).toHaveBeenCalledWith(
`xtream-restore-${item._id}`,
expect.any(String)
);
const persistedRestoreState = JSON.parse(
setItemSpy.mock.calls.find(
([key]) => key === `xtream-restore-${item._id}`
)?.[1] ?? 'null'
);
expect(persistedRestoreState).toEqual({
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
favorites: [
{ xtreamId: 101, contentType: 'live' },
{ xtreamId: 202, contentType: 'movie' },
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
playbackPositions: [],
});
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
expect(executionOrder).toEqual([
'setItem',
'start',
'dispatch',
'end',
'navigate',
]);
expect(performanceEvents).toHaveLength(2);
expect(performanceEvents[0]?.phaseId).toBe(
performanceEvents[1]?.phaseId
);
expect(
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
metadata,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
metadata: { items: 1 },
outcome: undefined,
phase: 'store.xtream-refresh-meta',
},
{
boundary: 'end',
metadata: { items: 1 },
outcome: 'success',
phase: 'store.xtream-refresh-meta',
},
]);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
});
it('uses the legacy IPC refresh flow for non-Xtream playlists', async () => {
runtime.supportsPlaylistRefresh = false;
let answer!: () => void;
dataService.sendIpcEvent.mockReturnValueOnce(
new Promise<void>((resolve) => {
answer = resolve;
})
);
const activity = TestBed.inject(SourceActivityService);
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: undefined,
url: 'https://example.com/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
component.refresh(item);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
});
// Busy, and held for removal and other refreshes, until it answers.
expect(component.isRefreshing(item._id)).toBe(true);
expect(activity.isBusy(item._id)).toBe(true);
component.refresh(item);
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
answer();
await Promise.resolve();
await Promise.resolve();
expect(component.isRefreshing(item._id)).toBe(false);
expect(activity.isBusy(item._id)).toBe(false);
});
it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => {
runtime.supportsPlaylistRefresh = false;
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
});
component.refresh(item);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
it('holds a row refresh in the shared registry, and leaves a held source alone', async () => {
const activity = TestBed.inject(SourceActivityService);
const item = createPlaylistMeta({
_id: 'playlist-m3u',
serverUrl: undefined,
username: undefined,
password: undefined,
url: 'https://source.test/list.m3u',
});
let resolve!: (value: unknown) => void;
playlistRefreshService.refreshPlaylist.mockReturnValueOnce(
new Promise((r) => {
resolve = r;
})
);
component.refresh(item);
// The switcher and the removal guard see the row's refresh.
expect(activity.isBusy(item._id)).toBe(true);
resolve({ id: item._id, items: [] });
await Promise.resolve();
await Promise.resolve();
expect(activity.isBusy(item._id)).toBe(false);
// A refresh or delete started elsewhere holds it: no second run.
const release = activity.begin([item._id]);
component.refresh(item);
expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledTimes(1);
release();
});
it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => {
runtime.supportsPlaylistRefresh = false;
const lateComponent = TestBed.inject(SourceListRefreshService);
runtime.supportsPlaylistRefresh = true;
const item = createPlaylistMeta({
_id: 'playlist-m3u-2',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
lateComponent.refresh(item);
expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith(
{
operationId: 'playlist-refresh-op',
playlistId: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
filePath: item.filePath,
},
{
onEvent: expect.any(Function),
}
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
await Promise.resolve();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylist({
playlist: expect.objectContaining({
_id: item._id,
}),
playlistId: item._id,
refreshEpg: true,
operationId: 'playlist-refresh-op',
})
);
});
it('clears the connectivity guard before deleting the cached Xtream catalog', async () => {
// Second, independent implementation of the destructive refresh (the
// Workspace sources page). Same consequence as the shared action: the
// catalog is already gone by the time an open guard fast-fails the
// re-import bootstrap.
const item = {
_id: 'xtream-guard',
title: 'Guarded Xtream',
serverUrl: 'http://panel.example:8080',
} as PlaylistMeta;
const order: string[] = [];
let confirmPromise: Promise<void> | undefined;
dataService.sendIpcEvent.mockImplementation((event: string) => {
order.push(`ipc:${event}`);
return Promise.resolve({ success: true });
});
databaseService.deleteXtreamPlaylistContent.mockImplementation(() => {
order.push('deleteXtreamPlaylistContent');
return Promise.resolve({
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
sourcePins: [],
});
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
component.refresh(item);
await confirmPromise;
expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`);
expect(order).toContain('deleteXtreamPlaylistContent');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: item.serverUrl }
);
});
});
@@ -0,0 +1,326 @@
import { Injectable, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
DatabaseService,
DataService,
DbOperationEvent,
isDbAbortError,
PlaylistRefreshService,
RuntimeCapabilitiesService,
SourceActivityService,
} from '@iptvnator/services';
import {
PLAYLIST_UPDATE,
PlaylistMeta,
PlaylistRefreshEvent,
} from '@iptvnator/shared/interfaces';
import {
XtreamRefreshFlowService,
type XtreamRefreshProgressReporter,
} from '../xtream-refresh-flow.service';
interface BusyOperation {
current?: number;
operation: string;
operationId?: string;
phase?: string;
status: DbOperationEvent['status'];
total?: number;
}
const REFRESH_PHASE_KEYS: Record<string, string> = {
fetching: 'HOME.PLAYLISTS.REFRESH_FETCHING',
'reading-file': 'HOME.PLAYLISTS.REFRESH_READING_FILE',
parsing: 'HOME.PLAYLISTS.REFRESH_PARSING',
saving: 'HOME.PLAYLISTS.REFRESH_SAVING',
};
const XTREAM_PHASE_KEYS: Record<string, string> = {
'collecting-user-data':
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA',
'deleting-content': 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT',
'deleting-categories':
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES',
};
/**
* The Sources page's per-row refresh: which rows refresh, their phase and
* progress, and cancelling. M3U refreshes in place, Xtream re-imports
* through the shared confirmation flow, and the browser build sends the
* URL refresh event. Provided by the list component.
*/
@Injectable()
export class SourceListRefreshService {
private readonly databaseService = inject(DatabaseService);
private readonly dataService = inject(DataService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly runtime = inject(RuntimeCapabilitiesService);
// Root registry: the switcher, the removal guard and cleanup consult it,
// so a row refresh must not be visible only to this page.
private readonly activity = inject(SourceActivityService);
private readonly releases = new Map<string, () => void>();
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService);
private readonly logger = createLogger('SourceListRefresh');
private readonly refreshingIds = signal<ReadonlySet<string>>(new Set());
private readonly operations = signal<ReadonlyMap<string, BusyOperation>>(
new Map()
);
isRefreshing(id: string): boolean {
return this.refreshingIds().has(id);
}
canRefresh(item: PlaylistMeta): boolean {
if (item.serverUrl) {
return this.runtime.supportsXtreamSqliteDataSource;
}
return (
!!item.url ||
(!!item.filePath && this.runtime.supportsPlaylistRefresh)
);
}
refresh(item: PlaylistMeta): void {
if (this.isRefreshing(item._id) || this.activity.isBusy(item._id)) {
return;
}
if (item.serverUrl && this.runtime.supportsXtreamSqliteDataSource) {
this.xtreamRefreshFlow.confirmAndRefresh(item, this.xtreamReporter);
} else if (
this.runtime.supportsPlaylistRefresh &&
(item.url || item.filePath)
) {
void this.refreshM3u(item);
} else if (item.url) {
void this.refreshViaBackend(item, item.url);
}
}
message(item: PlaylistMeta): string {
const operation = this.operations().get(item._id);
if (!operation) {
return '';
}
if (operation.operation === 'playlist-refresh') {
return this.translate.instant(
REFRESH_PHASE_KEYS[operation.phase ?? ''] ??
'HOME.PLAYLISTS.REFRESH'
);
}
if (operation.operation === 'delete-xtream-content') {
return this.translate.instant(
XTREAM_PHASE_KEYS[operation.phase ?? ''] ??
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
);
}
return '';
}
progress(id: string): number | null {
const operation = this.operations().get(id);
if (
!operation ||
operation.current == null ||
operation.total == null ||
operation.total <= 0
) {
return null;
}
return Math.min(
100,
Math.round((operation.current / operation.total) * 100)
);
}
canCancel(item: PlaylistMeta): boolean {
const operation = this.operations().get(item._id);
if (!operation?.operationId) {
return false;
}
return (
operation.operation === 'playlist-refresh' ||
Boolean(item.serverUrl)
);
}
async cancel(item: PlaylistMeta): Promise<void> {
const operation = this.operations().get(item._id);
if (!operation?.operationId) {
return;
}
if (operation.operation === 'playlist-refresh') {
await this.playlistRefreshService.cancelRefresh(
operation.operationId
);
return;
}
await this.databaseService.cancelOperation(operation.operationId);
}
/** The shared Xtream re-import reports into this list's row state. */
private readonly xtreamReporter: XtreamRefreshProgressReporter = {
isBusy: (playlistId) =>
this.isRefreshing(playlistId) || this.activity.isBusy(playlistId),
begin: ({ playlistId }) => this.setRefreshing(playlistId, true),
report: ({ playlistId }, event) => this.track(playlistId, event),
end: ({ playlistId }) => {
this.clear(playlistId);
this.setRefreshing(playlistId, false);
},
};
private async refreshM3u(item: PlaylistMeta): Promise<void> {
this.setRefreshing(item._id, true);
const operationId =
this.databaseService.createOperationId('playlist-refresh');
try {
const refreshed = await this.playlistRefreshService.refreshPlaylist(
{
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
filePath: item.filePath,
},
{
onEvent: (event) =>
this.track(item._id, this.toBusyEvent(event)),
}
);
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: { ...refreshed, _id: item._id },
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
// Redacted: the error can carry the playlist URL.
this.logger.error('Error refreshing playlist:', error);
this.snackBar.open(
this.refreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.clear(item._id);
this.setRefreshing(item._id, false);
}
}
/**
* The browser build refreshes through the backend; the row stays busy,
* and held in the shared registry, until the backend answers.
*/
private async refreshViaBackend(
item: PlaylistMeta,
url: string
): Promise<void> {
this.setRefreshing(item._id, true);
try {
await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
});
} catch (error) {
this.logger.error('Error refreshing playlist:', error);
} finally {
this.setRefreshing(item._id, false);
}
}
private refreshErrorMessage(error: unknown, item: PlaylistMeta): string {
if (item.filePath) {
const message = String(
(error as { message?: string })?.message ?? error
);
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR'
);
}
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
}
private toBusyEvent(event: PlaylistRefreshEvent): DbOperationEvent {
return {
operationId: event.operationId,
operation: 'playlist-refresh',
playlistId: event.playlistId,
phase: event.phase,
status: event.status,
error: event.error,
};
}
private track(id: string, event: DbOperationEvent): void {
this.operations.update((current) => {
const next = new Map(current);
if (['completed', 'cancelled', 'error'].includes(event.status)) {
next.delete(id);
} else {
next.set(id, {
operation: event.operation,
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
status: event.status,
});
}
return next;
});
}
private clear(id: string): void {
this.operations.update((current) => {
const next = new Map(current);
next.delete(id);
return next;
});
}
private setRefreshing(id: string, refreshing: boolean): void {
if (refreshing && !this.releases.has(id)) {
this.releases.set(id, this.activity.begin([id]));
} else if (!refreshing) {
this.releases.get(id)?.();
this.releases.delete(id);
}
this.refreshingIds.update((current) => {
const next = new Set(current);
if (refreshing) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}
}
@@ -1,65 +0,0 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { SourceHealthSnapshot } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from './source-health-indicator.component';
describe('SourceHealthIndicatorComponent', () => {
it.each(['en', 'zhtw'])(
'updates the dot and localized time without restarting its request (%s)',
(language) => {
const snapshot = signal<SourceHealthSnapshot | undefined>(
undefined
);
const check = jest.fn().mockImplementation(() => {
snapshot(); // The real coordinator reads its signal-backed cache.
return Promise.resolve();
});
TestBed.configureTestingModule({
imports: [
SourceHealthIndicatorComponent,
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSourceHealth: true },
},
{
provide: SourceHealthService,
useValue: { get: snapshot, check },
},
],
});
TestBed.inject(TranslateService).use(language);
const fixture = TestBed.createComponent(
SourceHealthIndicatorComponent
);
fixture.componentRef.setInput('playlist', {
_id: 'a',
url: 'https://source.test/list',
});
fixture.detectChanges();
const requestSignal = check.mock.calls[0][1].signal as AbortSignal;
snapshot.set({
state: 'active',
reason: 'available',
confirmedInactive: false,
checkedAt: Date.now(),
});
fixture.detectChanges();
expect(fixture.componentInstance.description()).toContain(' · ');
expect(check).toHaveBeenCalledTimes(1);
expect(requestSignal.aborted).toBe(false);
expect(
fixture.nativeElement.querySelector('.health-dot').dataset.state
).toBe('active');
fixture.destroy();
expect(requestSignal.aborted).toBe(true);
}
);
});
@@ -1,112 +0,0 @@
import {
Component,
Injector,
computed,
effect,
inject,
input,
untracked,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-source-health-indicator',
imports: [MatTooltip],
template: `@if (supported()) {
<span
class="health-dot"
[attr.data-state]="snapshot()?.state ?? 'unknown'"
[matTooltip]="description()"
[attr.aria-label]="description()"
role="img"
></span>
}`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: inline-flex;
vertical-align: middle;
}
.health-dot {
display: block;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--mat-sys-on-surface-variant);
border: 2px solid var(--mat-sys-surface);
}
[data-state='active'] {
background: #4caf50;
}
[data-state='expired'] {
background: #ff9800;
}
[data-state='inactive'] {
background: #f44336;
}
[data-state='checking'] {
opacity: 0.45;
}
`,
],
})
export class SourceHealthIndicatorComponent {
readonly playlist = input.required<PlaylistMeta>();
readonly enabled = input(true);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly injector = inject(Injector);
private readonly translate = inject(TranslateService);
private readonly language = injectTranslationTick();
readonly supported = computed(
() =>
this.runtime.supportsSourceHealth &&
!!sourceHealthType(this.playlist())
);
readonly snapshot = computed(() =>
this.supported()
? this.injector.get(SourceHealthService).get(this.playlist())
: undefined
);
readonly description = computed(() => {
this.language();
const snapshot = this.snapshot();
const reason =
snapshot?.state === 'checking'
? 'checking'
: (snapshot?.reason ?? 'unknown');
const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`);
const time = snapshot?.checkedAt
? new Date(snapshot.checkedAt).toLocaleTimeString(
normalizeDateLocale(this.translate.currentLang || 'en')
)
: '';
const scope =
sourceHealthType(this.playlist()) === 'm3u'
? this.translate.instant('SOURCE_HEALTH.M3U_SCOPE')
: '';
return [label, scope, time].filter(Boolean).join(' · ');
});
constructor() {
effect((onCleanup) => {
const p = this.playlist();
if (!this.enabled() || !this.supported()) return;
const controller = new AbortController();
untracked(() => {
void this.injector
.get(SourceHealthService)
.check(p, { signal: controller.signal });
});
onCleanup(() => controller.abort());
});
}
}
@@ -0,0 +1,115 @@
import { Injectable, inject, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { DatabaseService, DbOperationEvent } from '@iptvnator/services';
interface DeleteOperation {
operationId?: string;
phase?: string;
current?: number;
total?: number;
}
const PHASE_KEYS: Record<string, string> = {
'deleting-favorites': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES',
'deleting-recently-viewed': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT',
'deleting-playback-positions':
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS',
'deleting-downloads': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS',
'deleting-content': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT',
'deleting-categories': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES',
'deleting-playlist': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST',
};
/**
* Progress of a committed delete that reports it: an Xtream catalog in the
* desktop database can take a while, so its row comes back as "Removing…"
* with progress and a Cancel action that keeps the source.
*/
@Injectable({ providedIn: 'root' })
export class SourceDeleteProgressService {
private readonly database = inject(DatabaseService);
private readonly translate = inject(TranslateService);
private readonly operations = signal<ReadonlyMap<string, DeleteOperation>>(
new Map()
);
private readonly cancelled = new Set<string>();
isDeleting(id: string): boolean {
return this.operations().has(id);
}
message(id: string): string {
const operation = this.operations().get(id);
if (!operation) {
return '';
}
return this.translate.instant(
PHASE_KEYS[operation.phase ?? ''] ??
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
);
}
progress(id: string): number | null {
const operation = this.operations().get(id);
if (!operation?.total || operation.current == null) {
return null;
}
return Math.min(
100,
Math.round((operation.current / operation.total) * 100)
);
}
canCancel(id: string): boolean {
return !!this.operations().get(id)?.operationId;
}
async cancel(id: string): Promise<void> {
const operationId = this.operations().get(id)?.operationId;
if (operationId) {
this.cancelled.add(id);
await this.database.cancelOperation(operationId);
}
}
/** True once, after a cancelled delete: the caller skips its error. */
takeCancelled(id: string): boolean {
return this.cancelled.delete(id);
}
/**
* Follows a running delete. A terminal event ends what Cancel can stop,
* but the row stays busy: the delete still cleans up (parental lock,
* stored state) before its source leaves the store, so the caller clears
* it once the whole delete has settled.
*/
track(id: string, event: DbOperationEvent): void {
const terminal = ['completed', 'cancelled', 'error'].includes(
event.status
);
this.operations.update((current) =>
new Map(current).set(
id,
terminal
? { ...current.get(id), operationId: undefined }
: {
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
}
)
);
}
clear(id: string): void {
if (!this.operations().has(id)) {
return;
}
this.operations.update((current) => {
const next = new Map(current);
next.delete(id);
return next;
});
}
}
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more