mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
Merge remote-tracking branch 'origin/master' into perf/zoneless-flip
# Conflicts: # tools/performance/journey-baselines.json
This commit is contained in:
commit
8e807242db
191 files changed
+15429
-6865
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "جارٍ التحميل",
|
||||
|
||||
@@ -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": "كيتشارجا",
|
||||
|
||||
@@ -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": "Загрузка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Φόρτωση",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "読み込み中",
|
||||
|
||||
@@ -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": "로드 중",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Загрузка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "加载中",
|
||||
|
||||
@@ -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": "載入中",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+170
@@ -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>
|
||||
+202
@@ -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;
|
||||
}
|
||||
}
|
||||
+407
@@ -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']);
|
||||
});
|
||||
});
|
||||
+407
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-288
@@ -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>
|
||||
+82
-596
@@ -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;
|
||||
}
|
||||
}
|
||||
+103
-400
@@ -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);
|
||||
});
|
||||
});
|
||||
+118
-511
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-17
@@ -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">
|
||||
|
||||
-34
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
-21
@@ -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; }
|
||||
}
|
||||
}
|
||||
-290
@@ -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>
|
||||
-290
@@ -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;
|
||||
}
|
||||
}
|
||||
-408
@@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
-173
@@ -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);
|
||||
}
|
||||
+291
-668
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
-65
@@ -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
Reference in new issue
Block a user