Merge remote-tracking branch 'origin/master' into perf/j3-dom-mutations-baseline

# Conflicts:
#	.github/workflows/ci.yml
This commit is contained in:
4gray committed 2026-10-14 21:23:32 +02:00
commit a8505e0f91
279 files changed
+19836 -7874

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
type: fix
area: a11y
---
The radio player's play and pause button now shows the keyboard focus ring
when you tab to it, like every other control, and the app loads a little less
style code at startup.
+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: backup
---
Backups made in the browser and self-hosted versions now include Xtream favorites, recently viewed items and resume positions. Restoring such a backup brings them back right away, under whichever playlist the portal is imported as.
@@ -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,7 @@
---
type: perf
area: performance
---
The app loads less code at startup: the text field component and its form
helpers now load with the screens that show a form instead of with the app.
@@ -0,0 +1,8 @@
---
type: perf
area: performance
---
The app does less background work and starts with less code: screens now
update only when something they show changes, instead of after every timer,
network reply or click anywhere in the app.
+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: sources
---
Playlists added from a URL no longer show the login or password from that URL in the Sources list or the toolbar: they show the server's host instead.
@@ -0,0 +1,7 @@
---
type: feature
area: sources
highlight: Redesigned Sources and playlist switcher
---
Sources and the playlist switcher share a new, compact row with a status dot. Filter Sources by type and by whether sources respond, delete with Undo, and open the switcher with ⌘P / Ctrl+P to search, filter by type and switch from the keyboard.
@@ -0,0 +1,6 @@
---
type: fix
area: stalker
---
Stalker series details now keep their clickable genre and country chips after TMDB fills in the details, so you can jump to Discover from them as you can for movies.
+8
View File
@@ -0,0 +1,8 @@
---
type: fix
area: ui
---
Buttons that delete or discard data now show as a red tint with red text
instead of a solid red block, in both themes. The option lists of the Settings
dropdowns use the same compact, bordered panel as the app's menus.
@@ -0,0 +1,9 @@
---
type: perf
area: web
---
The app loads about 12% less code before it shows its first screen: dialog,
tooltip and progress-bar code, the animation engine and the theme colours of
unused components now load only when something needs them, so every launch
has less to download and parse.
@@ -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.
+8 -4
View File
@@ -1,10 +1,11 @@
# Every Dependabot PR triggers the full pipeline (~15 jobs), so version # Every Dependabot PR triggers the full pipeline (~15 jobs), so version
# updates are batched weekly. Nx minor+patch updates use a dedicated group so # updates are batched weekly. Nx minor+patch updates use a dedicated group so
# official packages move together; Nx majors are manual coordinated migrations. # official packages move together; Nx majors are manual coordinated migrations.
# Native packaging, parser, and version-locked playback dependencies stay in # Electron majors are held while Linux armv7l packages ship: Electron 44
# standalone PRs; other minor+patch updates are grouped per ecosystem. Security # dropped the linux-armv7l binary. Native packaging, parser, and
# updates are separate and are not limited by this schedule; CI enforces complete # version-locked playback dependencies stay in standalone PRs; other
# Nx lockstep. # minor+patch updates are grouped per ecosystem. Security updates are separate
# and are not limited by this schedule; CI enforces complete Nx lockstep.
version: 2 version: 2
updates: updates:
- package-ecosystem: npm - package-ecosystem: npm
@@ -21,6 +22,9 @@ updates:
- dependency-name: '@nx/*' - dependency-name: '@nx/*'
update-types: update-types:
- version-update:semver-major - version-update:semver-major
- dependency-name: electron
update-types:
- version-update:semver-major
groups: groups:
nx-version-updates: nx-version-updates:
applies-to: version-updates applies-to: version-updates
+14
View File
@@ -348,9 +348,12 @@ jobs:
--only launch/main.modulesRegisteredBeforeWindow --only launch/main.modulesRegisteredBeforeWindow
--only launch/renderer.layoutShiftScore --only launch/renderer.layoutShiftScore
--only launch/renderer.layoutShiftScoreSettled --only launch/renderer.layoutShiftScoreSettled
--only launch/renderer.cdTicksToFirstCard
--only launch/renderer.cdTicksIdle30s
--only open-source/main.mockHttpRequestsToSettled --only open-source/main.mockHttpRequestsToSettled
--only open-source/renderer.ipcCallsToFirstPage --only open-source/renderer.ipcCallsToFirstPage
--only open-source/renderer.layoutShiftScore --only open-source/renderer.layoutShiftScore
--only open-source/renderer.cdTicksToFirstPage
--only playback/renderer.domMutationsToPlaying --only playback/renderer.domMutationsToPlaying
--only playback/renderer.httpRequestsToPlaying --only playback/renderer.httpRequestsToPlaying
--only playback/renderer.layoutShiftScore --only playback/renderer.layoutShiftScore
@@ -431,6 +434,17 @@ jobs:
- name: Validate icon-only button names - name: Validate icon-only button names
run: pnpm run a11y:icon-buttons:validate run: pnpm run a11y:icon-buttons:validate
# Form-field defaults are provided per lazy component: the root
# token import would load Material form-field and @angular/forms
# on startup (tools/nx/check-form-field-defaults.mjs).
- name: Validate form-field defaults
run: pnpm run ui:form-field-defaults:validate
# m3-theme.scss themes only the Material components the app
# imports; a new import without a theme entry renders unthemed.
- name: Validate the Material theme scope
run: pnpm run styles:material-theme-scope:validate
# Nx rejects a non-parallel task with continuous dependencies, and # Nx rejects a non-parallel task with continuous dependencies, and
# the Playwright plugin infers serve dependencies only when CI is # the Playwright plugin infers serve dependencies only when CI is
# unset, so this checks both the local and the CI inference. # unset, so this checks both the local and the CI inference.
@@ -28,6 +28,9 @@ import { applyTheme, expectTextContrast } from './theme-contrast';
// Single-choice menus (catalog refine, live channels, categories, sources) // Single-choice menus (catalog refine, live channels, categories, sources)
// are radio groups: every row reserves the same leading check slot, so the // 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. // 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; 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(); 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> { async function openRefineMenu(page: Page): Promise<Locator> {
await page.locator('app-category-content-view .refine-action').click(); await page.locator('app-category-content-view .refine-action').click();
const menu = openMenuPanel(page); const menu = refineMenuPanel(page);
await expect(menu).toBeVisible(); await expect(menu).toBeVisible();
await settleAnimations(menu); await settleAnimations(menu);
return 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> { async function settleAnimations(menu: Locator): Promise<void> {
await menu.evaluate((element) => await menu.evaluate((element) =>
Promise.all( Promise.all(
@@ -210,12 +261,8 @@ test.describe('Electron catalog sort menus', () => {
// A rating threshold shows the second chip next to the sort one. // A rating threshold shows the second chip next to the sort one.
let menu = await openRefineMenu(page); let menu = await openRefineMenu(page);
await menu const thresholds = await openRatingSubmenu(page);
.getByRole('group') await thresholds.getByRole('menuitemradio').nth(5).click();
.nth(1)
.getByRole('menuitemradio')
.nth(5)
.click();
await expect(menu).toBeHidden(); await expect(menu).toBeHidden();
for (const locale of locales) { for (const locale of locales) {
@@ -252,16 +299,33 @@ test.describe('Electron catalog sort menus', () => {
menu = await openRefineMenu(page); menu = await openRefineMenu(page);
const groups = menu.getByRole('group'); 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(0), 5, `${locale} sort`);
await expectRadioGroup(groups.nth(1), 5, `${locale} rating`); // The thresholds row starts its label on the sort labels'
const lefts = (await radioRows(menu)).map( // edge and names the active threshold.
(row) => row.labelLeft 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( expect(
[...new Set(lefts)], [...new Set(lefts)],
`${locale}: both groups` `${locale}: sort and rating rows`
).toHaveLength(1); ).toHaveLength(1);
const ratings = await openRatingSubmenu(page);
await expectRadioGroup(
ratings.getByRole('group'),
5,
`${locale} rating`
);
await closeMenu(page, ratings);
await closeMenu(page, menu); await closeMenu(page, menu);
} }
} finally { } 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 ({ test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
dataDir, dataDir,
request, request,
@@ -545,8 +545,15 @@ async function expectCategoryCentered(row: Locator): Promise<void> {
} }
async function refreshFromWorkspaceHeader(page: Page): 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 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(); .click();
const dialog = page.locator('mat-dialog-container'); const dialog = page.locator('mat-dialog-container');
@@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl(
playlistUrl playlistUrl
); );
if (userAgent !== undefined) { 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.getByRole('button', { name: /Add playlist/i }).click();
await dialog.waitFor({ state: 'detached' }); await dialog.waitFor({ state: 'detached' });
@@ -1187,7 +1190,10 @@ export async function createMutableTextServer(
return; return;
} }
if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) { if (
options.requiredUserAgent &&
req.headers['user-agent'] !== options.requiredUserAgent
) {
res.writeHead(403); res.writeHead(403);
res.end('User-Agent required'); res.end('User-Agent required');
return; return;
@@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact(
} }
export function sourceRowByTitle(page: Page, title: string): Locator { 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), hasText: flexibleTextPattern(title),
}); });
} }
export async function getVisibleSourceTitles(page: Page): Promise<string[]> { export async function getVisibleSourceTitles(page: Page): Promise<string[]> {
return page.locator('app-playlist-item').evaluateAll((elements) => return page
elements .locator('app-workspace-sources app-source-row')
.map((element) => { .evaluateAll((elements) =>
const titleElement = element.querySelector('.playlist-title'); elements
return titleElement?.textContent?.trim() ?? ''; .map((element) => {
}) const titleElement =
.filter((title) => title.length > 0) element.querySelector('.source-row__name');
); return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
} }
export async function selectSourceTypeFilter( export async function selectSourceTypeFilter(
@@ -1497,8 +1506,10 @@ export async function selectSourceSort(
sortLabel: sortLabel:
| 'Date added (Newest first)' | 'Date added (Newest first)'
| 'Date added (Oldest first)' | 'Date added (Oldest first)'
| 'Last updated'
| 'Name (A-Z)' | 'Name (A-Z)'
| 'Name (Z-A)' | 'Name (Z-A)'
| 'Most channels'
| 'Custom order' | 'Custom order'
): Promise<void> { ): Promise<void> {
const sortTrigger = page.locator('app-workspace-sources .sort-trigger'); const sortTrigger = page.locator('app-workspace-sources .sort-trigger');
@@ -1523,10 +1534,10 @@ export async function dragSourceBefore(
sourceTitle: string, sourceTitle: string,
targetTitle: string targetTitle: string
): Promise<void> { ): Promise<void> {
const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon'); const source = sourceRowByTitle(page, sourceTitle).locator(
const target = sourceRowByTitle(page, targetTitle).locator( '.source-row__handle'
'.playlist-item'
); );
const target = sourceRowByTitle(page, targetTitle).locator('.source-row');
await expect(source.first()).toBeVisible(); await expect(source.first()).toBeVisible();
await expect(target.first()).toBeVisible(); await expect(target.first()).toBeVisible();
@@ -1560,15 +1571,18 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title).first(); const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible(); 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(); const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
// Material applies its initial focus after the opening transition. // Material applies its initial focus after the opening transition.
await dialog.evaluate(async (element) => { await dialog.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) => await Promise.all(
animation.finished.catch(() => undefined) element
)); .getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined))
);
}); });
return dialog; 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> { export async function deleteSource(page: Page, title: string): Promise<void> {
const row = sourceRowByTitle(page, title).first(); const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await row.locator('.delete-btn').click(); await row.hover();
await confirmDialog(page, 'Remove playlist'); 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( export async function refreshSource(
@@ -1697,7 +1723,8 @@ export async function refreshSource(
const row = sourceRowByTitle(page, title).first(); const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await row.locator('.refresh-btn').click(); await row.hover();
await row.locator('.source-row__refresh').click();
if (confirm) { if (confirm) {
await confirmDialog(page, 'Refresh playlist'); await confirmDialog(page, 'Refresh playlist');
@@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle(
const row = sourceRowByTitle(page, title).first(); const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await expect(row.locator('.busy-state__message')).toHaveCount(0, { await expect(row.locator('.source-row__busy')).toHaveCount(0, {
timeout: 60000, timeout: 60000,
}); });
await expect(row.locator('.action-spinner')).toHaveCount(0, { await expect(row.locator('mat-spinner')).toHaveCount(0, {
timeout: 60000, timeout: 60000,
}); });
} }
@@ -331,6 +331,7 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as
) as { ) as {
WORKSPACE: { SHELL: { RAIL_LIVE: string } }; WORKSPACE: { SHELL: { RAIL_LIVE: string } };
EPG: { EPG: {
PREVIOUS_DAY: string;
PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string }; PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string };
TIMELINE: { WATCH_FROM_START: string }; TIMELINE: { WATCH_FROM_START: string };
}; };
@@ -365,6 +366,15 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as
.first() .first()
.click(); .click();
const guide = app.mainWindow.locator('app-epg-list-view'); const guide = app.mainWindow.locator('app-epg-list-view');
await expect(guide.locator('[data-when="now"]').first()).toBeVisible();
// The mock's 30-minute slots run relative to now, so in the first half
// hour after UTC midnight today holds only the programme on air: step
// back a day for a finished one.
if ((await guide.locator('[data-when="past"]').count()) === 0) {
await guide
.getByRole('button', { name: fr.EPG.PREVIOUS_DAY })
.click();
}
await expect(guide.locator('[data-when="past"]').first()).toBeVisible(); await expect(guide.locator('[data-when="past"]').first()).toBeVisible();
// The window cannot shrink below its desktop minimum, so emulate a // The window cannot shrink below its desktop minimum, so emulate a
@@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => {
}); });
await openSources(app.mainWindow); await openSources(app.mainWindow);
await expect( await expect(
app.mainWindow.locator('app-playlist-item') app.mainWindow.locator('app-workspace-sources app-source-row')
).toHaveCount(2, { timeout: 30000 }); ).toHaveCount(2, { timeout: 30000 });
} finally { } finally {
await closeElectronApp(app); await closeElectronApp(app);
@@ -435,6 +435,7 @@ async function openMoviesWithRatingFilter(page: Page): Promise<void> {
timeout: 20_000, timeout: 20_000,
}); });
await view.locator('.refine-action').click(); await view.locator('.refine-action').click();
await page.locator('.refine-rating-trigger').click();
await page await page
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ }) .getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
.click(); .click();
@@ -550,19 +551,21 @@ test.describe('Keyboard focus ring', () => {
// Sources: the type filters, then the list's row actions. // Sources: the type filters, then the list's row actions.
await openSources(page); await openSources(page);
await expect( await expect(
page.locator('app-playlist-item').first() page.locator('app-workspace-sources app-source-row').first()
).toBeVisible(); ).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( await walk(
'sources list', 'sources list',
page.locator('app-workspace-shell'), page.locator('app-workspace-shell'),
page page
.locator('app-workspace-sources-filters-panel button') .locator('app-workspace-sources-filters-panel button')
.first(), .first(),
12 16
); );
expect( expect(
report['sources list (dark)'].map((stop) => stop.label) 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"`) // The live EPG list: its rows are Tab stops (`role="button"`)
// with their own inset ring, then the row's info button. // with their own inset ring, then the row's info button.
@@ -0,0 +1,267 @@
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
expect,
launchElectronApp,
openSettings,
openSettingsSection,
openSources,
test,
} from './electron-test-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromRenderer,
} from './renderer-reload.support';
// ---------------------------------------------------------------------------
// app.config.ts does not provide the Angular animations module: nothing
// declares an animation trigger, and Material 22 animates its overlays with
// CSS, reading prefers-reduced-motion itself. These checks keep the overlays'
// enter and exit motion, and the close paths that wait for it, working
// without the module, and off under reduced motion.
// ---------------------------------------------------------------------------
interface MotionEvent {
type: string;
name: string;
/** The class attribute of the element the event fired on. */
target: string;
}
type MotionWindow = Window & { __motionEvents?: MotionEvent[] };
/** Records every CSS animation and transition event of the document. */
async function recordMotion(page: Page): Promise<void> {
await page.evaluate(() => {
const events: MotionEvent[] = [];
(window as MotionWindow).__motionEvents = events;
for (const type of [
'animationstart',
'animationend',
'transitionrun',
'transitionend',
]) {
document.addEventListener(
type,
(event) => {
const target = event.target;
events.push({
type,
name:
(event as AnimationEvent).animationName ??
(event as TransitionEvent).propertyName,
target:
target instanceof Element
? (target.getAttribute('class') ?? '')
: '',
});
},
true
);
}
});
}
async function motionEvents(page: Page): Promise<MotionEvent[]> {
return page.evaluate(() => (window as MotionWindow).__motionEvents ?? []);
}
/**
* Waits for an event of that type and name, on an element wearing
* `targetClass` when given, among the events recorded from `since` on.
*/
async function expectMotion(
page: Page,
type: string,
name: string,
{ targetClass, since = 0 }: { targetClass?: string; since?: number } = {}
): Promise<void> {
await expect
.poll(
async () =>
(await motionEvents(page))
.slice(since)
.some(
(event) =>
event.type === type &&
event.name === name &&
(targetClass === undefined ||
event.target.split(/\s+/).includes(targetClass))
),
`${type} ${name}${targetClass ? ` on .${targetClass}` : ''}`
)
.toBe(true);
}
async function recordedCount(page: Page): Promise<number> {
return (await motionEvents(page)).length;
}
/** Opens the Clear EPG data confirmation from the EPG settings section. */
async function openClearEpgDialog(page: Page) {
await openSettingsSection(page, 'epg');
await page
.getByRole('button', { name: 'Clear EPG data', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
return dialog;
}
test.describe('Material motion without the animations module', () => {
test('@theme @electron overlays animate in and out and close after their exit motion', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page);
await openSources(page);
await recordMotion(page);
// Tooltip: keyframes on show and on hide.
const settingsLink = page.locator('a[href$="/workspace/settings"]');
await settingsLink.hover();
// Scoped to the one tooltip: the rail link hovered on the way
// to Sources may still be fading out.
const tooltip = page.locator('.mat-mdc-tooltip', {
hasText: 'Settings',
});
await expect(tooltip).toBeVisible();
await expect(tooltip).not.toHaveClass(/_mat-animation-noopable/);
await expectMotion(page, 'animationstart', 'mat-mdc-tooltip-show');
await expectMotion(page, 'animationend', 'mat-mdc-tooltip-show');
await page.mouse.move(640, 360);
await expectMotion(page, 'animationstart', 'mat-mdc-tooltip-hide');
await expect(tooltip).toHaveCount(0);
// Menu: the sources sort menu.
await page.locator('.sort-trigger').click();
const menu = page.locator('.mat-mdc-menu-panel');
await expect(menu).toBeVisible();
await expect(menu).not.toHaveClass(
/mat-menu-panel-animations-disabled/
);
await expectMotion(page, 'animationstart', '_mat-menu-enter');
// Dismissed only once the enter animation is over: a swap while
// it still runs cancels it, and Material treats the cancelled
// enter as the exit's end and detaches the panel at once.
await expectMotion(page, 'animationend', '_mat-menu-enter');
await page.keyboard.press('Escape');
await expectMotion(page, 'animationstart', '_mat-menu-exit');
await expect(menu).toHaveCount(0);
// Select: the language list, opened and dismissed unchanged.
await openSettings(page);
await openSettingsSection(page, 'general');
await page.getByTestId('select-language').click();
const panel = page.locator('.mat-mdc-select-panel');
await expect(panel).toBeVisible();
await expect(panel).toHaveClass(
/mat-select-panel-animations-enabled/
);
await expectMotion(page, 'animationstart', '_mat-select-enter');
await expectMotion(page, 'animationend', '_mat-select-enter');
await page.keyboard.press('Escape');
await expectMotion(page, 'animationstart', '_mat-select-exit');
await expect(panel).toHaveCount(0);
// Dialog: Material drives it with transitions whose duration it
// writes on the container; the surface scales in. Confirming
// closes it after the exit transition and shows a snack-bar.
const beforeDialog = await recordedCount(page);
const dialog = await openClearEpgDialog(page);
await expect(dialog).not.toHaveClass(/_mat-animation-noopable/);
await expect(dialog).toHaveClass(/mdc-dialog--open/);
expect(
await dialog.evaluate((el) =>
el.style.getPropertyValue(
'--mat-dialog-transition-duration'
)
)
).toMatch(/^[1-9]\d*ms$/);
// The surface scales in while opening.
await expectMotion(page, 'transitionend', 'transform', {
targetClass: 'mat-mdc-dialog-surface',
since: beforeDialog,
});
const beforeClose = await recordedCount(page);
await dialog.getByTestId('confirm-dialog-confirm').click();
// Closing fades the inner container out. Material detaches the
// container on a timer of the same length, so the fade's end
// may never be observed; its start is, in the first frame.
await expectMotion(page, 'transitionrun', 'opacity', {
targetClass: 'mat-mdc-dialog-inner-container',
since: beforeClose,
});
await expect(dialog).toHaveCount(0);
const snackBar = page.locator('.mat-mdc-snack-bar-container');
await expect(snackBar).toBeVisible();
await expect(snackBar).toHaveClass(
/mat-snack-bar-container-animations-enabled/
);
await expectMotion(page, 'animationstart', '_mat-snack-bar-enter');
// The confirmation toast lasts 2s, then leaves through its exit
// animation; the container is removed when that one ends.
await expectMotion(page, 'animationstart', '_mat-snack-bar-exit');
await expect(snackBar).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@theme @electron reduced motion turns the overlay motion off', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
// Material reads the preference once per document, so a fresh
// document must start under the emulated media.
await page.emulateMedia({ reducedMotion: 'reduce' });
await reloadFromRenderer(app);
await expectRendererReloadedOnRoute(page, /\/workspace/);
await openSettings(page);
await openSettingsSection(page, 'general');
await recordMotion(page);
await page.getByTestId('select-language').click();
const panel = page.locator('.mat-mdc-select-panel');
await expect(panel).toBeVisible();
await expect(panel).not.toHaveClass(
/mat-select-panel-animations-enabled/
);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
const dialog = await openClearEpgDialog(page);
await expect(dialog).toHaveClass(/_mat-animation-noopable/);
// Material writes the transition duration only when it animates.
expect(
await dialog.evaluate((el) =>
el.style.getPropertyValue(
'--mat-dialog-transition-duration'
)
)
).toBe('');
await dialog.getByTestId('confirm-dialog-confirm').click();
await expect(dialog).toHaveCount(0);
const snackBar = page.locator('.mat-mdc-snack-bar-container');
await expect(snackBar).toBeVisible();
await expect(snackBar).not.toHaveClass(
/mat-snack-bar-container-animations-enabled/
);
await expect(snackBar).toHaveCount(0, { timeout: 10_000 });
const names = (await motionEvents(page))
.filter((event) => event.type === 'animationstart')
.map((event) => event.name);
expect(names.filter((name) => /^_mat-/.test(name))).toEqual([]);
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,21 @@
import { expect, Locator, Page } from '@playwright/test';
/**
* Wait until keyboard focus has entered the open `mat-menu`, and return it.
*
* Material moves focus to the first item after the next render, which the
* zoneless scheduler runs on a later task than the keydown that opened the
* menu. The panel is visible before that, so a navigation key sent as soon
* as it appears still reaches the trigger and the menu never sees it.
*/
export async function waitForMenuFocus(page: Page): Promise<Locator> {
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect
.poll(
() => menu.evaluate((el) => el.contains(document.activeElement)),
{ message: 'focus inside the open menu' }
)
.toBe(true);
return menu;
}
@@ -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.splashId, 'initial-splash');
assert.equal(options.routeFragment, '/workspace'); assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/); 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, { assert.deepEqual(options.settle, {
capMs: JOURNEY_SETTLE_CAP_MS, capMs: JOURNEY_SETTLE_CAP_MS,
quietMs: JOURNEY_SETTLE_QUIET_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 ?? '', options.startClick?.selector ?? '',
/dashboard-recent-sources-rail-card/ /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.equal(options.routeFragment, '/workspace/xtreams/');
assert.match(options.cardSelector, /app-grid-list mat-card/); assert.match(options.cardSelector, /app-grid-list mat-card/);
assert.match(options.cardSelector, /channel-item/); 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'; export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */ /** The Xtream portal card on the dashboard or its row on /workspace/sources. */
export const JOURNEY_OPEN_SOURCE_START_SELECTOR = 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 * 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 * before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id
@@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions(
): JourneyRendererProbeOptions { ): JourneyRendererProbeOptions {
return { return {
cardSelector: 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, cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
...(idleWindowMs === null ...(idleWindowMs === null
? {} ? {}
@@ -262,7 +262,7 @@ async function runIteration(
await renderer.markOperationStart(); await renderer.markOperationStart();
const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first(); 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 renderer.waitForCancelClick();
await Promise.all([ await Promise.all([
renderer.waitForTerminal(), renderer.waitForTerminal(),
@@ -301,7 +301,7 @@ async function installRendererProbe(
const epoch = (): number => const epoch = (): number =>
performance.timeOrigin + performance.now(); performance.timeOrigin + performance.now();
const sourceRow = (): Element | undefined => 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) (element) => element.textContent?.includes(title)
); );
const state = { const state = {
@@ -330,7 +330,7 @@ async function installRendererProbe(
const row = sourceRow(); const row = sourceRow();
const message = const message =
row row
?.querySelector('.busy-state__message') ?.querySelector('.source-row__busy-message')
?.textContent?.trim() ?.textContent?.trim()
.toLowerCase() ?? ''; .toLowerCase() ?? '';
for (const phase of ['fetch', 'pars', 'sav']) { for (const phase of ['fetch', 'pars', 'sav']) {
@@ -345,9 +345,10 @@ async function installRendererProbe(
state.cancelClickEpochMs !== null && state.cancelClickEpochMs !== null &&
state.uiSettledEpochMs === null && state.uiSettledEpochMs === null &&
row !== undefined && row !== undefined &&
row.querySelector('.busy-state__message') === null && row.querySelector('.source-row__busy-message') === null &&
row.querySelector('.action-spinner') === null && row.querySelector('.source-row__refresh mat-spinner') ===
row.querySelector('.cancel-btn') === null null &&
row.querySelector('.source-row__cancel') === null
) { ) {
state.uiSettledEpochMs = epoch(); state.uiSettledEpochMs = epoch();
state.uiPaintFrameRequestId = requestAnimationFrame(() => { state.uiPaintFrameRequestId = requestAnimationFrame(() => {
@@ -421,7 +422,9 @@ async function installRendererProbe(
event.phase === 'parsing' && event.phase === 'parsing' &&
state.cancelClickEpochMs === null state.cancelClickEpochMs === null
) { ) {
const button = sourceRow()?.querySelector('.cancel-btn'); const button = sourceRow()?.querySelector(
'.source-row__cancel'
);
state.cancelButtonFound = state.cancelButtonFound =
button instanceof HTMLButtonElement; button instanceof HTMLButtonElement;
if (button instanceof HTMLButtonElement) { if (button instanceof HTMLButtonElement) {
@@ -10,7 +10,6 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe';
interface TargetConfiguration { interface TargetConfiguration {
configurations?: Record<string, Record<string, unknown>>; configurations?: Record<string, Record<string, unknown>>;
defaultConfiguration?: string;
dependsOn?: unknown; dependsOn?: unknown;
executor?: unknown; executor?: unknown;
options?: Record<string, unknown>; options?: Record<string, unknown>;
@@ -163,12 +162,7 @@ test('only the web performance build installs the tick counter the journeys read
for (const [name, configuration] of Object.entries( for (const [name, configuration] of Object.entries(
webProject.targets['build'].configurations ?? {} webProject.targets['build'].configurations ?? {}
)) { )) {
if ( if (name === 'electron-performance') continue;
name === 'electron-performance' ||
name === 'electron-performance-zoneless'
) {
continue;
}
assert.doesNotMatch( assert.doesNotMatch(
JSON.stringify(configuration['fileReplacements'] ?? []), JSON.stringify(configuration['fileReplacements'] ?? []),
/environment\.performance/, /environment\.performance/,
@@ -186,12 +180,7 @@ test('only development web builds provide the NgRx store devtools', () => {
replace: 'apps/web/src/environments/store-devtools.providers.ts', replace: 'apps/web/src/environments/store-devtools.providers.ts',
with: 'apps/web/src/environments/store-devtools.providers.dev.ts', with: 'apps/web/src/environments/store-devtools.providers.dev.ts',
}; };
// electron-e2e-zoneless is electron-e2e plus the zoneless swap. const developmentConfigurations = new Set(['development', 'electron-e2e']);
const developmentConfigurations = new Set([
'development',
'electron-e2e',
'electron-e2e-zoneless',
]);
assert.match( assert.match(
readFileSync(join(environments, 'store-devtools.providers.ts'), 'utf8'), readFileSync(join(environments, 'store-devtools.providers.ts'), 'utf8'),
@@ -230,42 +219,23 @@ test('only development web builds provide the NgRx store devtools', () => {
} }
}); });
// Plan item C6 measures zoneless change detection behind a build-time flag: // Plan item C6: the renderer runs zoneless. A zone.js polyfill or a zone
// each *-zoneless configuration is its base configuration plus one swap of // provider would bring back a tick after every macrotask and make the
// the change-detection providers, and nothing else selects that swap. // change-detection tick counters racy again.
test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => { test('the web app bootstraps zoneless without the zone.js polyfill', () => {
const configurations = webProject.targets['build'].configurations ?? {}; const build = webProject.targets['build'];
const zonelessReplacement = { const configurations = Object.entries(build.configurations ?? {});
replace: 'apps/web/src/environments/change-detection.providers.ts',
with: 'apps/web/src/environments/change-detection.providers.zoneless.ts',
};
for (const base of ['electron-performance', 'electron-e2e']) { assert.equal(build.options?.['polyfills'], undefined);
const baseConfiguration = configurations[base]; for (const [name, configuration] of configurations) {
const zoneless = configurations[`${base}-zoneless`]; assert.equal(configuration['polyfills'], undefined, name);
assert.ok(zoneless, `web:build must define ${base}-zoneless`);
assert.ok(baseConfiguration, `web:build must define ${base}`);
const { fileReplacements: baseReplacements, ...baseRest } =
baseConfiguration;
const { fileReplacements, ...rest } = zoneless;
assert.deepEqual(rest, baseRest, base);
assert.deepEqual(fileReplacements, [
...((baseReplacements as unknown[] | undefined) ?? []),
zonelessReplacement,
]);
} }
for (const [name, configuration] of Object.entries(configurations)) { const appConfig = readFileSync(
if (name.endsWith('-zoneless')) continue; join(workspaceRoot, 'apps/web/src/app/app.config.ts'),
assert.doesNotMatch( 'utf8'
JSON.stringify(configuration['fileReplacements'] ?? []),
/change-detection\.providers/,
name
);
}
assert.equal(
webProject.targets['build'].defaultConfiguration,
'production'
); );
assert.match(appConfig, /provideZonelessChangeDetection\(\)/);
assert.doesNotMatch(appConfig, /provideZoneChangeDetection/);
}); });
test('the resolved web build cache output is the renderer directory', () => { test('the resolved web build cache output is the renderer directory', () => {
@@ -152,11 +152,11 @@ export async function installXtreamRendererProbe(
); );
const sourceRowAbsent = (): boolean => const sourceRowAbsent = (): boolean =>
!Array.from( !Array.from(
document.querySelectorAll('app-playlist-item') document.querySelectorAll('app-source-row')
).some( ).some(
(row) => (row) =>
row row
.querySelector('.playlist-title') .querySelector('.source-row__name')
?.textContent?.trim() === state.sourceTitle ?.textContent?.trim() === state.sourceTitle
); );
const terminalDomGate = (): boolean => { const terminalDomGate = (): boolean => {
@@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => {
"'#serverUrl'", "'#serverUrl'",
"'#username'", "'#username'",
"'#password'", "'#password'",
"'app-playlist-item'", "'app-workspace-sources app-source-row'",
"'.refresh-btn'", "'.source-row__refresh'",
"'.delete-btn'", "'.source-row__more'",
"'Refresh playlist'", "'Refresh playlist'",
"'Remove playlist'", "'Remove playlist'",
"'.busy-state__message'", "'.source-row__busy'",
"'.action-spinner'", "'mat-spinner'",
"'.cancel-btn'", "'.source-row__cancel'",
"'.workspace-loading-overlay'", "'.workspace-loading-overlay'",
]) { ]) {
assert.ok(ui.includes(selector), `missing ${selector}`); 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('installXtreamCancellationObserver'));
assert.ok(driver.includes('installXtreamOperationWindow')); assert.ok(driver.includes('installXtreamOperationWindow'));
assert.ok(driver.includes("'delete-playlist'"));
assert.ok(driver.includes('runBackgroundProbe')); assert.ok(driver.includes('runBackgroundProbe'));
assert.doesNotMatch(combined, /waitForTimeout\s*\(/); assert.doesNotMatch(combined, /waitForTimeout\s*\(/);
assert.doesNotMatch(combined, /setTimeout\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 () => { 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, XTREAM_SCENARIO_ID.REFRESH_LARGE,
'.refresh-btn', '.source-row__refresh',
'Refresh playlist', '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) { ] as const) {
const confirmClick = `name=${confirmLabel};exact=true`;
const fake = new FakePage(); const fake = new FakePage();
const prepared = await prepareXtreamScenario( const prepared = await prepareXtreamScenario(
fake.asPage(), 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 () => { it('arms cancellation after preparation and immediately before the final Add trigger', async () => {
const fake = new FakePage(); const fake = new FakePage();
const prepared = await prepareXtreamScenario( const prepared = await prepareXtreamScenario(
@@ -19,7 +19,8 @@ import {
prepareXtreamSourceActionTrigger, prepareXtreamSourceActionTrigger,
waitForXtreamCancellationTerminal, waitForXtreamCancellationTerminal,
waitForXtreamCatalogTerminal, waitForXtreamCatalogTerminal,
waitForXtreamDeleteTerminal, removeXtreamSource,
waitForXtreamSourceRowGone,
} from './xtream-scenario-ui'; } from './xtream-scenario-ui';
import { assertXtreamMockOrigin } from './xtream-control-client'; import { assertXtreamMockOrigin } from './xtream-control-client';
import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe'; import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe';
@@ -106,6 +107,7 @@ export async function prepareXtreamScenario(
return createPreparedScenario({ return createPreparedScenario({
backgroundOperation, backgroundOperation,
cancellationObserver: null, cancellationObserver: null,
deleteOperation: null,
options, options,
page, page,
trigger, trigger,
@@ -119,6 +121,8 @@ export async function prepareXtreamScenario(
interface PreparedState { interface PreparedState {
readonly backgroundOperation: XtreamOperationWindow | null; readonly backgroundOperation: XtreamOperationWindow | null;
cancellationObserver: XtreamCancellationObserver | null; cancellationObserver: XtreamCancellationObserver | null;
/** The playlist delete, armed before its commit click. */
deleteOperation: XtreamOperationWindow<'delete-playlist'> | null;
readonly options: PrepareXtreamScenarioOptions; readonly options: PrepareXtreamScenarioOptions;
readonly page: Page; readonly page: Page;
readonly trigger: Locator; readonly trigger: Locator;
@@ -136,6 +140,7 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario {
await Promise.allSettled([ await Promise.allSettled([
state.cancellationObserver?.dispose(), state.cancellationObserver?.dispose(),
state.backgroundOperation?.dispose(), state.backgroundOperation?.dispose(),
state.deleteOperation?.dispose(),
]); ]);
}; };
const prepared: PreparedXtreamScenario = { const prepared: PreparedXtreamScenario = {
@@ -189,8 +194,21 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario {
state.cancellationObserver = state.cancellationObserver =
await installXtreamCancellationObserver(state.page); 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(); const triggerEpochMs = Date.now();
await state.trigger.click(); await commit.click();
return { return {
scenarioId: state.options.scenarioId, scenarioId: state.options.scenarioId,
triggerEpochMs, triggerEpochMs,
@@ -224,12 +242,20 @@ async function waitForScenarioTerminal(
cancellation = await state.cancellationObserver.waitForClick(); cancellation = await state.cancellationObserver.waitForClick();
await waitForXtreamCancellationTerminal(state.page); await waitForXtreamCancellationTerminal(state.page);
break; break;
case XTREAM_SCENARIO_ID.DELETE_LARGE: case XTREAM_SCENARIO_ID.DELETE_LARGE: {
await waitForXtreamDeleteTerminal( 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.page,
state.options.sourceTitle state.options.sourceTitle
); );
break; break;
}
case XTREAM_SCENARIO_ID.BACKGROUND_UI: case XTREAM_SCENARIO_ID.BACKGROUND_UI:
if (readBackgroundProbe() === null) { if (readBackgroundProbe() === null) {
throw new Error('xtream-background-probe-incomplete'); throw new Error('xtream-background-probe-incomplete');
@@ -2,11 +2,16 @@ import type { Page } from '@playwright/test';
const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow'; const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow';
const DELETE_OPERATION = 'delete-xtream-content'; 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; const OPERATION_TIMEOUT_MS = 120_000;
export interface XtreamOperationWindowSnapshot { export interface XtreamOperationWindowSnapshot<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
> {
readonly active: boolean; readonly active: boolean;
readonly operation: typeof DELETE_OPERATION; readonly operation: Operation;
readonly operationId: string; readonly operationId: string;
readonly playlistId: string; readonly playlistId: string;
readonly startedEpochMs: number; readonly startedEpochMs: number;
@@ -14,16 +19,21 @@ export interface XtreamOperationWindowSnapshot {
readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null; readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null;
} }
export interface XtreamOperationWindow { export interface XtreamOperationWindow<
assertActive(): Promise<XtreamOperationWindowSnapshot>; Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
> {
assertActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
dispose(): Promise<void>; dispose(): Promise<void>;
waitForActive(): Promise<XtreamOperationWindowSnapshot>; waitForActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
waitForTerminal(): Promise<XtreamOperationWindowSnapshot>; waitForTerminal(): Promise<XtreamOperationWindowSnapshot<Operation>>;
} }
export async function installXtreamOperationWindow( export async function installXtreamOperationWindow<
page: Page Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
): Promise<XtreamOperationWindow> { >(
page: Page,
operation: Operation = DELETE_OPERATION as Operation
): Promise<XtreamOperationWindow<Operation>> {
await page.evaluate( await page.evaluate(
({ command, operation, stateKey }) => { ({ command, operation, stateKey }) => {
interface State { interface State {
@@ -104,20 +114,20 @@ export async function installXtreamOperationWindow(
}, },
{ {
command: 'install-operation', command: 'install-operation',
operation: DELETE_OPERATION, operation,
stateKey: OPERATION_STATE_KEY, stateKey: OPERATION_STATE_KEY,
} }
); );
return { return {
assertActive: () => assertOperationActive(page), assertActive: () => assertOperationActive(page, operation),
dispose: () => disposeOperationState(page), dispose: () => disposeOperationState(page),
async waitForActive() { async waitForActive() {
await waitForOperationState(page, 'operation-active'); await waitForOperationState(page, 'operation-active');
return assertOperationActive(page); return assertOperationActive(page, operation);
}, },
async waitForTerminal() { async waitForTerminal() {
await waitForOperationState(page, 'operation-terminal'); await waitForOperationState(page, 'operation-terminal');
return readOperationSnapshot(page); return readOperationSnapshot(page, operation);
}, },
}; };
} }
@@ -144,19 +154,21 @@ async function waitForOperationState(
); );
} }
async function assertOperationActive( async function assertOperationActive<Operation extends XtreamObservedOperation>(
page: Page page: Page,
): Promise<XtreamOperationWindowSnapshot> { operation: Operation
const snapshot = await readOperationSnapshot(page); ): Promise<XtreamOperationWindowSnapshot<Operation>> {
const snapshot = await readOperationSnapshot(page, operation);
if (!snapshot.active || snapshot.terminalEpochMs !== null) { if (!snapshot.active || snapshot.terminalEpochMs !== null) {
throw new Error('xtream-background-action-outside-delete-window'); throw new Error('xtream-background-action-outside-delete-window');
} }
return snapshot; return snapshot;
} }
async function readOperationSnapshot( async function readOperationSnapshot<Operation extends XtreamObservedOperation>(
page: Page page: Page,
): Promise<XtreamOperationWindowSnapshot> { operation: Operation
): Promise<XtreamOperationWindowSnapshot<Operation>> {
const value = await page.evaluate( const value = await page.evaluate(
({ stateKey }) => { ({ stateKey }) => {
const target = globalThis as unknown as Record<string, unknown>; const target = globalThis as unknown as Record<string, unknown>;
@@ -183,7 +195,7 @@ async function readOperationSnapshot(
}, },
{ command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY } { command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY }
); );
if (!isOperationSnapshot(value)) { if (!isOperationSnapshot(value, operation)) {
throw new Error('xtream-operation-window-invalid'); throw new Error('xtream-operation-window-invalid');
} }
return value; return value;
@@ -202,16 +214,17 @@ async function disposeOperationState(page: Page): Promise<void> {
); );
} }
function isOperationSnapshot( function isOperationSnapshot<Operation extends XtreamObservedOperation>(
value: unknown value: unknown,
): value is XtreamOperationWindowSnapshot { operation: Operation
): value is XtreamOperationWindowSnapshot<Operation> {
if (!isRecord(value)) return false; if (!isRecord(value)) return false;
const active = value['active']; const active = value['active'];
const startedEpochMs = value['startedEpochMs']; const startedEpochMs = value['startedEpochMs'];
const terminalEpochMs = value['terminalEpochMs']; const terminalEpochMs = value['terminalEpochMs'];
const terminalStatus = value['terminalStatus']; const terminalStatus = value['terminalStatus'];
return ( return (
value['operation'] === DELETE_OPERATION && value['operation'] === operation &&
typeof active === 'boolean' && typeof active === 'boolean' &&
finitePositive(startedEpochMs) && finitePositive(startedEpochMs) &&
nonEmptyString(value['operationId']) && nonEmptyString(value['operationId']) &&
@@ -68,28 +68,54 @@ export async function prepareXtreamSourceActionTrigger(
const row = xtreamSourceRow(page, sourceTitle); const row = xtreamSourceRow(page, sourceTitle);
await row.waitFor({ state: 'visible' }); await row.waitFor({ state: 'visible' });
for (const selector of [ for (const selector of [
'.busy-state__message', '.source-row__busy',
'.action-spinner', 'mat-spinner',
'.cancel-btn', '.source-row__cancel',
]) { ]) {
await row.locator(selector).waitFor({ state: 'detached' }); await row.locator(selector).waitFor({ state: 'detached' });
} }
const actionSelector = if (action === 'refresh') {
action === 'refresh' ? '.refresh-btn' : '.delete-btn'; const refresh = row.locator('.source-row__refresh');
const actionButton = row.locator(actionSelector); await assertTriggerReady(refresh, 'xtream-source-refresh');
await assertTriggerReady(actionButton, `xtream-source-${action}`); await refresh.click();
await actionButton.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(); // Remove starts the Undo window, which commits on its own after four
await dialog.waitFor({ state: 'visible' }); // seconds: preparation stops at the open menu, and the trigger removes
const confirmation = dialog // and commits at once (removeXtreamSource).
.getByRole('button', { const more = row.locator('.source-row__more');
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist', await assertTriggerReady(more, 'xtream-source-delete-menu');
exact: true, await more.click();
}) const remove = page
.last(); .locator('.cdk-overlay-pane [mat-menu-item]')
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`); .filter({ hasText: 'Remove playlist' })
return confirmation; .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> { 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, page: Page,
sourceTitle: string sourceTitle: string
): Promise<void> { ): Promise<void> {
@@ -157,9 +187,9 @@ async function selectXtreamMethod(dialog: Locator): Promise<void> {
function xtreamSourceRow(page: Page, sourceTitle: string): Locator { function xtreamSourceRow(page: Page, sourceTitle: string): Locator {
const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u'); const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u');
return page return page
.locator('app-playlist-item') .locator('app-workspace-sources app-source-row')
.filter({ .filter({
has: page.locator('.playlist-title', { has: page.locator('.source-row__name', {
hasText: exactTitle, hasText: exactTitle,
}), }),
}) })
@@ -178,7 +178,7 @@ class SearchCompletionFakePage {
} }
this.searchGateObserved = this.searchGateObserved =
/searchParams\.get\((['"])q\1\)/u.test(source) && /searchParams\.get\((['"])q\1\)/u.test(source) &&
source.includes('app-playlist-item') && source.includes('app-source-row') &&
source.includes('no-results-state'); source.includes('no-results-state');
if (this.searchGateObserved) { if (this.searchGateObserved) {
this.searchResultsApplied = true; this.searchResultsApplied = true;
@@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions(
); );
const resultRows = Array.from( const resultRows = Array.from(
document.querySelectorAll( document.querySelectorAll(
'app-workspace-sources app-playlist-item' 'app-workspace-sources app-source-row'
) )
); );
const resultPainted = const resultPainted =
resultRows.length > 0 resultRows.length > 0
? resultRows.every((row) => ? resultRows.every((row) =>
( (
row.querySelector('.playlist-title') row.querySelector('.source-row__name')
?.textContent ?? '' ?.textContent ?? ''
) )
.trim() .trim()
@@ -30,6 +30,7 @@ import {
writeTemporaryM3uFile, writeTemporaryM3uFile,
xtreamMockServer, xtreamMockServer,
} from './electron-test-fixtures'; } from './electron-test-fixtures';
import { waitForMenuFocus } from './menu-keyboard';
import { import {
routePlayableStreams, routePlayableStreams,
startAndConfirmPlayback, startAndConfirmPlayback,
@@ -245,44 +246,41 @@ test.describe('Electron Playlist Switcher', () => {
await waitForM3uCatalog(app.mainWindow); await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow); await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( await expect(
1 channelItemByTitle(app.mainWindow, localAChannelName)
); ).toHaveCount(1);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( await expect(
0 channelItemByTitle(app.mainWindow, localBChannelName)
); ).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName); await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname( await expectPathname(
app.mainWindow, app.mainWindow,
/\/workspace\/global-favorites$/ /\/workspace\/global-favorites$/
); );
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( await expect(
1 channelItemByTitle(app.mainWindow, localBChannelName)
); ).toHaveCount(1);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( await expect(
0 channelItemByTitle(app.mainWindow, localAChannelName)
); ).toHaveCount(0);
await openPlaylistRecent(app.mainWindow); await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( await expect(
1 channelItemByTitle(app.mainWindow, localBChannelName)
); ).toHaveCount(1);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( await expect(
0 channelItemByTitle(app.mainWindow, localAChannelName)
); ).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName); await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname( await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
app.mainWindow, await expect(
/\/workspace\/global-recent$/ channelItemByTitle(app.mainWindow, localAChannelName)
); ).toHaveCount(1);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( await expect(
1 channelItemByTitle(app.mainWindow, localBChannelName)
); ).toHaveCount(0);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
} finally { } finally {
await closeElectronApp(app); await closeElectronApp(app);
} }
@@ -319,7 +317,9 @@ test.describe('Electron Playlist Switcher', () => {
username: xtreamStressUsername, 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 expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, { await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content', operation: 'save-content',
@@ -428,17 +428,171 @@ test.describe('Electron Playlist Switcher', () => {
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName); await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow); await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( await expect(
0 app.mainWindow.locator('.xtream-content-gate')
); ).toHaveCount(0);
app = await restartElectronApp(app, dataDir); app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow); await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( await expect(
0 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 = await waitForMenuFocus(page);
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 { } finally {
await closeElectronApp(app); await closeElectronApp(app);
} }
@@ -446,12 +600,12 @@ test.describe('Electron Playlist Switcher', () => {
}); });
async function openPlaylistSwitcher(page: Page): Promise<void> { async function openPlaylistSwitcher(page: Page): Promise<void> {
await page await page.getByTestId('playlist-switcher-trigger').click();
.locator('app-playlist-switcher .playlist-switcher-trigger') await expect(switcherOptions(page).first()).toBeVisible();
.click(); }
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first() function switcherOptions(page: Page) {
).toBeVisible(); return page.locator('app-playlist-switcher-panel [role="option"]');
} }
async function switchPlaylistFromHeader( async function switchPlaylistFromHeader(
@@ -459,16 +613,11 @@ async function switchPlaylistFromHeader(
title: string title: string
): Promise<void> { ): Promise<void> {
await openPlaylistSwitcher(page); await openPlaylistSwitcher(page);
const item = page const item = switcherOptions(page).filter({ hasText: title }).first();
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible(); await expect(item).toBeVisible();
await item.click(); await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount( await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0);
0
);
} }
async function expectPathname(page: Page, pattern: RegExp): Promise<void> { async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
@@ -503,16 +652,21 @@ async function selectSettingsOption(
} }
async function expectXtreamUnavailableWarning(page: Page): Promise<void> { async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect( await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({
page.getByTestId('xtream-offline-warning') timeout: 20000,
).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(); const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 }); await expect(item).toBeVisible({ timeout: 20000 });
await item.hover(); await item.hover();
await item.locator('.favorite-button').first().click(); 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/
);
} }
@@ -9,6 +9,7 @@ import {
waitForM3uCatalog, waitForM3uCatalog,
writeTemporaryM3uFile, writeTemporaryM3uFile,
} from './electron-test-fixtures'; } from './electron-test-fixtures';
import { waitForMenuFocus } from './menu-keyboard';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// A source row opens from the keyboard. Each row has its own Tab stop: the // A source row opens from the keyboard. Each row has its own Tab stop: the
@@ -31,7 +32,7 @@ type RowRing = {
function readRowRing(page: Page): Promise<RowRing> { function readRowRing(page: Page): Promise<RowRing> {
return page.evaluate(() => { return page.evaluate(() => {
const focused = document.activeElement as HTMLElement; 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'); const probe = document.createElement('span');
probe.style.color = 'var(--app-focus-ring)'; probe.style.color = 'var(--app-focus-ring)';
row.append(probe); row.append(probe);
@@ -93,16 +94,19 @@ test.describe('Electron Sources keyboard', () => {
timeout: 30_000, timeout: 30_000,
}); });
await openSources(page); 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, timeout: 30_000,
}); });
// Bravo opened last, so it is the active source; open the other. // Bravo opened last, so it is the active source; open the other.
const row = sourceRowByTitle(page, 'keyboard-alpha').first(); const row = sourceRowByTitle(page, 'keyboard-alpha').first();
await expect(row.locator('.playlist-item')).not.toHaveClass( await expect(row.locator('.source-row__body')).not.toHaveAttribute(
/\bselected\b/ 'aria-current',
'true'
); );
const title = ( const title = (
await row.locator('.playlist-title').textContent() await row.locator('.source-row__name').textContent()
)?.trim(); )?.trim();
expect(title).toBeTruthy(); expect(title).toBeTruthy();
@@ -153,7 +157,7 @@ test.describe('Electron Sources keyboard', () => {
await expect(open).not.toBeFocused(); await expect(open).not.toBeFocused();
expect( expect(
await row await row
.locator('.playlist-item') .locator('.source-row')
.evaluate((el) => getComputedStyle(el).outlineStyle) .evaluate((el) => getComputedStyle(el).outlineStyle)
).toBe('none'); ).toBe('none');
await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Shift+Tab');
@@ -172,4 +176,70 @@ test.describe('Electron Sources keyboard', () => {
await closeElectronApp(app); 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 waitForMenuFocus(page);
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
// The row leaves at once; Undo takes the focus the row had.
await expect(rows).toHaveCount(1);
const undo = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__undo'
);
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(rows).toHaveCount(2);
expect(
await rows.locator('.source-row__name').allTextContents()
).toEqual(before);
await expect(
page.locator('mat-snack-bar-container.app-toast')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
}); });
+27 -16
View File
@@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8
await expect( await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator( 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( await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator( 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 deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
await expect( await expect(
@@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8
try { try {
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath); await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
await waitForM3uCatalog(app.mainWindow); 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( await expect(
app.mainWindow.locator( app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' })
'app-playlist-switcher .trigger-refresh-button'
)
).toBeVisible(); ).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); await goToDashboard(app.mainWindow);
const sourceCard = app.mainWindow const sourceCard = app.mainWindow
@@ -822,8 +831,8 @@ https://streams.example.test/original-url.m3u8
await expect( await expect(
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName) sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
.first() .first()
.locator('.refresh-btn') .locator('.source-row__refresh')
).toBeVisible(); ).toBeAttached();
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [ writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
{ {
@@ -865,8 +874,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect( await expect(
sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u') sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
.first() .first()
.locator('.meta') .locator('.source-row__meta')
).toContainText('Updated:'); ).toContainText('updated');
await waitForSourceRowIdle( await waitForSourceRowIdle(
app.mainWindow, app.mainWindow,
'refresh-url-source.m3u' 'refresh-url-source.m3u'
@@ -876,9 +885,11 @@ https://streams.example.test/refreshed-url.m3u8
'refresh-url-source.m3u' 'refresh-url-source.m3u'
).first(); ).first();
await expect( await expect(
refreshedUrlRow.locator('.playlist-item') refreshedUrlRow.locator('.source-row')
).not.toHaveClass(/is-busy/); ).not.toHaveClass(/source-row--busy/);
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled(); await expect(
refreshedUrlRow.locator('.source-row__refresh')
).toBeEnabled();
await refreshedUrlRow await refreshedUrlRow
.getByText('refresh-url-source.m3u', { exact: true }) .getByText('refresh-url-source.m3u', { exact: true })
.click(); .click();
@@ -904,8 +915,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect( await expect(
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source') sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
.first() .first()
.locator('.meta') .locator('.source-row__meta')
).toContainText('Updated:'); ).toContainText('updated');
} finally { } finally {
await closeElectronApp(app); await closeElectronApp(app);
await urlServer.close(); await urlServer.close();
@@ -47,16 +47,27 @@ async function outlineContrast(
}, fillSelector); }, fillSelector);
} }
/** Resolves a CSS color (including var() and color-mix()) to rgb()/rgba(). */ /**
async function resolveColor(page: Page, value: string): Promise<string> { * Resolves a CSS color (including var() and color-mix()) the way the given
return page.evaluate((css) => { * property serializes it, so it compares with a computed `color` or
const probe = document.createElement('div'); * `backgroundColor`.
probe.style.color = css; */
document.body.appendChild(probe); async function resolveColor(
const color = getComputedStyle(probe).color; page: Page,
probe.remove(); value: string,
return color; property: 'color' | 'backgroundColor' = 'color'
}, value); ): Promise<string> {
return page.evaluate(
({ css, prop }) => {
const probe = document.createElement('div');
probe.style[prop] = css;
document.body.appendChild(probe);
const color = getComputedStyle(probe)[prop];
probe.remove();
return color;
},
{ css: value, prop: property }
);
} }
async function systemVariable(page: Page, name: string): Promise<string> { async function systemVariable(page: Page, name: string): Promise<string> {
@@ -200,7 +211,7 @@ test.describe('Theme tokens', () => {
} }
}); });
test('@theme @electron destructive actions name the action and use the error color', async ({ test('@theme @electron destructive actions name the action and use the danger tint', async ({
dataDir, dataDir,
}) => { }) => {
const app = await launchElectronApp(dataDir); const app = await launchElectronApp(dataDir);
@@ -211,18 +222,35 @@ test.describe('Theme tokens', () => {
for (const theme of ['light', 'dark'] as const) { for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme); await applyTheme(page, theme);
const error = await resolveColor(page, 'var(--mat-sys-error)'); const dangerText = await resolveColor(
page,
'var(--app-danger-text)'
);
const dangerSurface = await resolveColor(
page,
'var(--app-danger-surface)',
'backgroundColor'
);
const trigger = page.getByRole('button', { const trigger = page.getByRole('button', {
name: 'Clear EPG data', name: 'Clear EPG data',
exact: true, exact: true,
}); });
// The `warn` color input was a no-op with M3: the trigger // The `warn` color input was a no-op with M3: the trigger
// rendered in the primary color. // rendered in the primary color. The kit draws a destructive
// button as a red tint, never a solid error fill, and the
// outlined trigger carries the same tint as a filled one.
await expect await expect
.poll(() => .poll(() =>
trigger.evaluate((el) => getComputedStyle(el).color) trigger.evaluate((el) => getComputedStyle(el).color)
) )
.toBe(error); .toBe(dangerText);
await expect
.poll(() =>
trigger.evaluate(
(el) => getComputedStyle(el).backgroundColor
)
)
.toBe(dangerSurface);
await trigger.click(); await trigger.click();
const dialog = page.locator('mat-dialog-container'); const dialog = page.locator('mat-dialog-container');
@@ -239,7 +267,12 @@ test.describe('Theme tokens', () => {
(el) => getComputedStyle(el).backgroundColor (el) => getComputedStyle(el).backgroundColor
) )
) )
.toBe(error); .toBe(dangerSurface);
await expect
.poll(() =>
confirm.evaluate((el) => getComputedStyle(el).color)
)
.toBe(dangerText);
await dialog await dialog
.getByRole('button', { name: 'Cancel', exact: true }) .getByRole('button', { name: 'Cancel', exact: true })
@@ -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'; const dbWorkerRowsPerTransaction = '100';
function playlistRowByName(name: string) { 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]) { async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
@@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => {
); );
await expect(playlistRow).toBeVisible({ timeout: 20000 }); await expect(playlistRow).toBeVisible({ timeout: 20000 });
await playlistRow.locator('.delete-btn').click(); // Delete from the row menu; the delete starts when the Undo
// toast closes, and the row comes back to show its progress.
const confirmDialog = app.mainWindow.locator( await playlistRow.hover();
'mat-dialog-container' await playlistRow.locator('.source-row__more').click();
); await app.mainWindow
await expect(confirmDialog).toBeVisible(); .locator('.cdk-overlay-pane [mat-menu-item]')
await confirmDialog .filter({ hasText: 'Remove playlist' })
.getByRole('button', { name: 'Remove playlist', exact: true })
.click(); .click();
await waitForDbOperationEvent(app.mainWindow, { await waitForDbOperationEvent(app.mainWindow, {
@@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => {
timeoutMs: 20000, timeoutMs: 20000,
}); });
await expect( await expect(playlistRow.locator('.source-row__busy')).toBeVisible({
playlistRow.locator('.busy-state__message') timeout: 20000,
).toBeVisible({ timeout: 20000 }); });
const cancel = playlistRow.locator('.cancel-btn'); const cancel = playlistRow.locator('.source-row__cancel');
await expect(cancel).toBeVisible(); await expect(cancel).toBeVisible();
// The row's action buttons inherit its color; the cancel action // The row's action buttons inherit its color; the cancel action
// must still read as destructive on the selected row. // must still read as destructive on the selected row.
+2 -6
View File
@@ -111,16 +111,12 @@ test('@a11y icon-only buttons are named in Sources and the playlist info dialog'
}) => { }) => {
await addM3uUrlPlaylist(page, M3U_TITLE); 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); await openSources(page);
const row = sourceRowByTitle(page, M3U_TITLE); const row = sourceRowByTitle(page, M3U_TITLE);
await expect(row).toBeVisible({ timeout: 15_000 }); await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover(); await row.hover();
for (const name of [ for (const name of ['Refresh playlist', 'Edit…', 'Playlist actions']) {
'Refresh playlist',
'Show playlist details',
'Remove playlist',
]) {
await expect(row.getByRole('button', { name })).toBeAttached(); await expect(row.getByRole('button', { name })).toBeAttached();
} }
await expectNamedButtons(page, 3); await expectNamedButtons(page, 3);
@@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise<string> {
.getByRole('button', { name: 'Add playlist', exact: true }) .getByRole('button', { name: 'Add playlist', exact: true })
.click(), .click(),
]); ]);
await expect(page.getByText('1 channels')).toBeVisible(); await expect(page.getByText('1 channel', { exact: false })).toBeVisible();
return page.url(); return page.url();
} }
+1 -1
View File
@@ -259,7 +259,7 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
channel = 'Refreshed UA Channel'; channel = 'Refreshed UA Channel';
const row = sourceRowByTitle(page, 'Protected M3U'); const row = sourceRowByTitle(page, 'Protected M3U');
await row.hover(); await row.hover();
await row.locator('.refresh-btn').click(); await row.locator('.source-row__refresh').click();
await expect( await expect(
page.locator('.mat-mdc-snack-bar-label').last() page.locator('.mat-mdc-snack-bar-label').last()
).toContainText('updated'); ).toContainText('updated');
+5 -2
View File
@@ -190,7 +190,10 @@ export async function openSources(page: Page): Promise<void> {
} }
export function sourceRowByTitle(page: Page, title: string): Locator { 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( export async function openSourceEditor(
@@ -200,7 +203,7 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title); const row = sourceRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 15_000 }); await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover(); await row.hover();
await row.locator('.edit-btn').click(); await row.locator('.source-row__edit').click();
const dialog = page.locator('mat-dialog-container').last(); const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
return dialog; return dialog;
@@ -0,0 +1,78 @@
import type { Page } from '@playwright/test';
import { pressTab } from './e2e-helpers';
import { expect } from './fixtures';
/** The computed colour a custom property resolves to on this page. */
async function computedColor(page: Page, property: string): Promise<string> {
return page.evaluate((name) => {
const probe = document.createElement('span');
probe.style.color = `var(${name})`;
document.body.append(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}, property);
}
/**
* The radio player's play/pause control is a `mat-icon-button` drawn as the
* 56px primary circle the fab used to draw (the fab tokens alone were about
* 6 KB of the initial stylesheet), with the icon centred, and keyboard focus
* draws the app ring that the fab reset with `outline: none`.
*/
export async function verifyRadioPlayControl(
page: Page,
browserName: string
): Promise<void> {
const player = page.locator('app-audio-player');
const play = player.locator('.play-btn');
await expect(play).toBeVisible();
await expect(play).toHaveClass(/mat-mdc-icon-button/);
await expect(play).not.toHaveClass(/mat-mdc-fab/);
const style = await play.evaluate((button) => {
const icon = button.querySelector('mat-icon') as HTMLElement;
const box = button.getBoundingClientRect();
const iconBox = icon.getBoundingClientRect();
const { width, height, borderRadius, backgroundColor } =
getComputedStyle(button);
const centre = (rect: DOMRect) => [
rect.left + rect.width / 2,
rect.top + rect.height / 2,
];
const [x, y] = centre(box);
const [iconX, iconY] = centre(iconBox);
return {
width,
height,
borderRadius,
backgroundColor,
iconColor: getComputedStyle(icon).color,
iconFontSize: getComputedStyle(icon).fontSize,
iconOffset: [Math.round(iconX - x), Math.round(iconY - y)],
};
});
expect(style).toEqual({
width: '56px',
height: '56px',
borderRadius: '50%',
backgroundColor: await computedColor(page, '--app-selection-color'),
iconColor: 'rgb(255, 255, 255)',
iconFontSize: '32px',
iconOffset: [0, 0],
});
await player.locator('.skip-btn').first().focus();
await pressTab(page, browserName);
await expect(play).toBeFocused();
const ring = await play.evaluate((button) => {
const { outlineStyle, outlineWidth, outlineColor } =
getComputedStyle(button);
return { outlineStyle, outlineWidth, outlineColor };
});
expect(ring).toEqual({
outlineStyle: 'solid',
outlineWidth: '2px',
outlineColor: await computedColor(page, '--app-focus-ring'),
});
}
+10 -4
View File
@@ -18,6 +18,7 @@ import {
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture'; import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture';
import { playFirstItvChannel } from './stalker-itv-playback.fixture'; import { playFirstItvChannel } from './stalker-itv-playback.fixture';
import { verifyRadioPlayControl } from './stalker-radio-player.fixture';
import { import {
AUTH_REJECTED_MAC, AUTH_REJECTED_MAC,
BACKEND_PROXY, BACKEND_PROXY,
@@ -182,12 +183,15 @@ test('@stalker account info dialog shows subscription facts from the portal', as
}) => { }) => {
await addStalkerPortal(page); await addStalkerPortal(page);
// Open the header playlist switcher and its "Account info" entry for // Open the header playlist switcher and the "Account info" entry in the
// the active stalker portal. // active stalker portal's row menu.
await page.locator('.playlist-switcher-trigger').click(); await page.locator('[data-test-id="playlist-switcher-trigger"]').click();
await page await page
.locator('.context-action-item', { hasText: 'Account info' }) .locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
)
.click(); .click();
await page.getByRole('menuitem', { name: 'Account info' }).click();
const dialog = page.locator('mat-dialog-container'); const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible(); 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 ({ test('@stalker radio — stations use the inline audio player without EPG', async ({
page, page,
browserName,
}) => { }) => {
await addStalkerPortal(page); 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({ await expect(page.locator('app-audio-player')).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); });
await verifyRadioPlayControl(page, browserName);
// Level 2: only the categories rail folds; the channels header turns // Level 2: only the categories rail folds; the channels header turns
// its title into a category dropdown that opens the same rail as a // its title into a category dropdown that opens the same rail as a
+4 -7
View File
@@ -61,18 +61,15 @@ async function openPlaylistDetailsDialog(page: Page, title: string) {
await playlistSettingsButton.click(); await playlistSettingsButton.click();
} else { } else {
await page await page
.locator('app-playlist-switcher .playlist-switcher-trigger') .locator('[data-test-id="playlist-switcher-trigger"]')
.click(); .click();
const switcherMenu = page.getByRole('menu').filter({ hasText: title }); const sourceRow = page
const sourceRow = switcherMenu .locator('app-playlist-switcher-panel [role="option"]')
.locator('.playlist-item')
.filter({ hasText: title }) .filter({ hasText: title })
.first(); .first();
await expect(sourceRow).toBeVisible(); await expect(sourceRow).toBeVisible();
await sourceRow await sourceRow.locator('.source-row__more').click();
.getByRole('button', { name: 'Playlist actions' })
.click();
await page.getByRole('menuitem', { name: 'Playlist info' }).click(); await page.getByRole('menuitem', { name: 'Playlist info' }).click();
} }
-43
View File
@@ -19,7 +19,6 @@
"base": "dist/apps/web", "base": "dist/apps/web",
"browser": "" "browser": ""
}, },
"polyfills": ["zone.js"],
"tsConfig": "apps/web/tsconfig.app.json", "tsConfig": "apps/web/tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"serviceWorker": "ngsw-config.json", "serviceWorker": "ngsw-config.json",
@@ -149,31 +148,6 @@
} }
] ]
}, },
"electron-performance-zoneless": {
"baseHref": "./",
"serviceWorker": false,
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false,
"removeSpecialComments": true
},
"fonts": true
},
"outputHashing": "all",
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/environment.ts",
"with": "apps/web/src/environments/environment.performance.ts"
},
{
"replace": "apps/web/src/environments/change-detection.providers.ts",
"with": "apps/web/src/environments/change-detection.providers.zoneless.ts"
}
]
},
"electron-e2e": { "electron-e2e": {
"baseHref": "./", "baseHref": "./",
"serviceWorker": false, "serviceWorker": false,
@@ -186,23 +160,6 @@
"with": "apps/web/src/environments/store-devtools.providers.dev.ts" "with": "apps/web/src/environments/store-devtools.providers.dev.ts"
} }
] ]
},
"electron-e2e-zoneless": {
"baseHref": "./",
"serviceWorker": false,
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/store-devtools.providers.ts",
"with": "apps/web/src/environments/store-devtools.providers.dev.ts"
},
{
"replace": "apps/web/src/environments/change-detection.providers.ts",
"with": "apps/web/src/environments/change-detection.providers.zoneless.ts"
}
]
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"
@@ -6,7 +6,6 @@ import {
input, input,
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { import {
@@ -19,7 +18,7 @@ import {
@Component({ @Component({
selector: 'app-startup-status', selector: 'app-startup-status',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatProgressSpinnerModule, TranslatePipe], imports: [MatButtonModule, TranslatePipe],
template: ` template: `
@if (!complete()) { @if (!complete()) {
<section <section
@@ -45,7 +44,40 @@ import {
/> />
</svg> </svg>
@if (!failed()) { @if (!failed()) {
<mat-spinner diameter="32" aria-hidden="true" /> <div class="startup-spinner" aria-hidden="true">
<div class="startup-spinner__container">
<div class="startup-spinner__layer">
<div
class="startup-spinner__clipper startup-spinner__clipper--left"
>
<svg
viewBox="0 0 25.2 25.2"
focusable="false"
>
<circle r="11" cx="50%" cy="50%" />
</svg>
</div>
<div class="startup-spinner__gap">
<svg
viewBox="0 0 25.2 25.2"
focusable="false"
>
<circle r="11" cx="50%" cy="50%" />
</svg>
</div>
<div
class="startup-spinner__clipper startup-spinner__clipper--right"
>
<svg
viewBox="0 0 25.2 25.2"
focusable="false"
>
<circle r="11" cx="50%" cy="50%" />
</svg>
</div>
</div>
</div>
</div>
} }
<h1> <h1>
{{ {{
@@ -121,6 +153,148 @@ import {
margin-top: 8px; margin-top: 8px;
app-region: no-drag; app-region: no-drag;
} }
/*
* The same drawing as an indeterminate <mat-spinner diameter="32">
* (two clipped half circles plus a gap patch, Material's durations,
* curves and keyframes, a 10% stroke on a 25.2 viewBox around an
* r=11 circle, 1.25x slower under reduced motion), as static markup:
* the component would load the progress-spinner chunk before the
* app's first screen and set every attribute after insertion.
*/
.startup-spinner {
--startup-spinner-multiplier: 1;
position: relative;
width: 32px;
height: 32px;
overflow: hidden;
line-height: 0;
direction: ltr;
}
@media (prefers-reduced-motion: reduce) {
.startup-spinner {
--startup-spinner-multiplier: 1.25;
}
}
.startup-spinner__container,
.startup-spinner__layer,
.startup-spinner svg {
position: absolute;
width: 100%;
height: 100%;
}
.startup-spinner__container {
animation: startup-spinner-container-rotate
calc(1568.2352941176ms * var(--startup-spinner-multiplier))
linear infinite;
}
.startup-spinner__layer {
animation: startup-spinner-layer-rotate
calc(5332ms * var(--startup-spinner-multiplier))
cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
.startup-spinner__clipper {
display: inline-flex;
position: relative;
width: 50%;
height: 100%;
overflow: hidden;
}
.startup-spinner__clipper svg {
width: 200%;
}
.startup-spinner__clipper--left svg {
animation: startup-spinner-left-spin
calc(1333ms * var(--startup-spinner-multiplier))
cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
.startup-spinner__clipper--right svg {
left: -100%;
animation: startup-spinner-right-spin
calc(1333ms * var(--startup-spinner-multiplier))
cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
.startup-spinner__gap {
position: absolute;
top: 0;
left: 47.5%;
box-sizing: border-box;
width: 5%;
height: 100%;
overflow: hidden;
}
.startup-spinner__gap svg {
left: -900%;
width: 2000%;
transform: rotate(180deg);
}
.startup-spinner svg {
fill: transparent;
}
.startup-spinner circle {
stroke: var(--mat-sys-primary);
stroke-width: 10%;
stroke-dasharray: 69.11503837897544px;
stroke-dashoffset: 34.55751918948772px;
}
@media (forced-colors: active) {
.startup-spinner circle {
stroke: CanvasText;
}
}
@keyframes startup-spinner-container-rotate {
to {
transform: rotate(360deg);
}
}
@keyframes startup-spinner-layer-rotate {
12.5% {
transform: rotate(135deg);
}
25% {
transform: rotate(270deg);
}
37.5% {
transform: rotate(405deg);
}
50% {
transform: rotate(540deg);
}
62.5% {
transform: rotate(675deg);
}
75% {
transform: rotate(810deg);
}
87.5% {
transform: rotate(945deg);
}
100% {
transform: rotate(1080deg);
}
}
@keyframes startup-spinner-left-spin {
from {
transform: rotate(265deg);
}
50% {
transform: rotate(130deg);
}
to {
transform: rotate(265deg);
}
}
@keyframes startup-spinner-right-spin {
from {
transform: rotate(-265deg);
}
50% {
transform: rotate(-130deg);
}
to {
transform: rotate(-265deg);
}
}
`, `,
}) })
export class AppStartupStatusComponent { export class AppStartupStatusComponent {
@@ -0,0 +1,264 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateInstallService } from './services/app-update-install.service';
/**
* The update notification itself: the card with its download, install and
* release-notes actions. `AppUpdateNotificationPanelComponent` defers it
* until an update is available, so this file and its Material imports must
* stay out of every file that loads with the app.
*/
@Component({
selector: 'app-update-notification-card',
imports: [MatButtonModule, MatIconModule, TranslatePipe],
template: `
<section
class="app-update-notification"
data-test-id="app-update-notification"
>
<header class="app-update-notification__header">
<span>
<mat-icon>system_update</mat-icon>
{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}
</span>
<button
mat-icon-button
type="button"
(click)="dismissed.emit()"
[attr.aria-label]="'CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</header>
<div class="app-update-notification__body">
<strong>
{{
labelKey()
| translate
: {
version:
status().latestVersion ||
status().currentVersion,
}
}}
</strong>
@if (status().progress; as progress) {
<progress [value]="progress.percent" max="100"></progress>
}
</div>
<div class="app-update-notification__actions">
<button
mat-stroked-button
type="button"
(click)="openReleaseNotes()"
data-test-id="app-update-notification-release-notes"
>
<mat-icon>article</mat-icon>
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
</button>
@if (status().status === appUpdateStatuses.Downloaded) {
<button
mat-flat-button
type="button"
(click)="installUpdate()"
data-test-id="app-update-notification-install"
>
<mat-icon>restart_alt</mat-icon>
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
</button>
} @else {
<button
mat-flat-button
type="button"
[disabled]="
status().status === appUpdateStatuses.Downloading
"
(click)="downloadUpdate()"
data-test-id="app-update-notification-download"
>
<mat-icon>{{ primaryActionIcon() }}</mat-icon>
{{ primaryActionLabelKey() | translate }}
</button>
}
</div>
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.app-update-notification {
position: fixed;
right: 20px;
bottom: 20px;
width: 320px;
max-width: calc(100vw - 40px);
z-index: 901;
overflow: hidden;
border: 1px solid var(--app-separator);
border-radius: 12px;
background: var(--app-widget-bg);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.08),
0 8px 24px rgba(0, 0, 0, 0.18),
0 16px 48px rgba(0, 0, 0, 0.12);
}
.app-update-notification__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 8px 12px;
border-bottom: 1px solid var(--app-separator);
}
.app-update-notification__header span {
display: flex;
align-items: center;
gap: 8px;
color: var(--app-heading-color);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.app-update-notification__header mat-icon {
color: var(--app-selection-color);
}
.app-update-notification__body {
display: grid;
gap: 8px;
padding: 12px;
color: var(--app-body-color);
font-size: 0.88rem;
}
.app-update-notification__body progress {
width: 100%;
}
.app-update-notification__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
padding: 0 12px 12px;
}
@media (max-width: 560px) {
.app-update-notification {
right: 12px;
bottom: 12px;
left: 12px;
width: auto;
max-width: none;
}
}
`,
],
})
export class AppUpdateNotificationCardComponent {
private readonly dialog = inject(MatDialog);
private readonly installService = inject(AppUpdateInstallService);
readonly status = input.required<ElectronBridgeAppUpdateStatus>();
/** A status the card learnt from the updater (download, install). */
readonly statusChange = output<ElectronBridgeAppUpdateStatus>();
readonly dismissed = output<void>();
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
readonly labelKey = computed(() =>
this.status().status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
? 'SETTINGS.APP_UPDATE_DOWNLOADED'
: 'SETTINGS.APP_UPDATE_AVAILABLE'
);
readonly primaryActionLabelKey = computed(() =>
this.status().supportedSelfUpdate === false
? 'SETTINGS.APP_UPDATE_OPEN_RELEASE'
: 'SETTINGS.APP_UPDATE_DOWNLOAD'
);
readonly primaryActionIcon = computed(() =>
this.status().supportedSelfUpdate === false ? 'open_in_new' : 'download'
);
/**
* Loaded on demand: the dialog renders Markdown with `marked`, which this
* card would otherwise bring along with the first notification. A field
* so specs can substitute it.
*/
loadReleaseNotesDialog = () =>
import('./settings/app-update-release-notes-dialog.component');
async openReleaseNotes(): Promise<void> {
const status = this.status();
let dialogModule: Awaited<
ReturnType<typeof this.loadReleaseNotesDialog>
>;
try {
dialogModule = await this.loadReleaseNotesDialog();
} catch (error) {
// The same notes are on the releases page; the next click retries.
console.error('Could not load the release notes dialog:', error);
if (status.manualDownloadUrl) {
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
}
return;
}
const { AppUpdateReleaseNotesDialogComponent } = dialogModule;
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
initialVersion: status.latestVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
});
}
async downloadUpdate(): Promise<void> {
const status = this.status();
if (!status.supportedSelfUpdate) {
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
return;
}
if (!window.electron?.downloadAppUpdate) {
return;
}
this.statusChange.emit(await window.electron.downloadAppUpdate());
}
async installUpdate(): Promise<void> {
// Never window.electron.installAppUpdate() directly: the install
// quits the app, and AppUpdateInstallService stands the settings
// unload guard down first — this panel is global, so the settings
// form can be dirty right now.
const status = await this.installService.installAppUpdate();
if (status) {
this.statusChange.emit(status);
}
}
}
@@ -1,10 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import {
ComponentFixture,
DeferBlockBehavior,
DeferBlockState,
TestBed,
} from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES, ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus, ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import { AppUpdateNotificationCardComponent } from './app-update-notification-card.component';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component'; import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component'; import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
@@ -62,9 +69,34 @@ describe('AppUpdateNotificationPanelComponent', () => {
window.electron = originalElectron; window.electron = originalElectron;
}); });
it('shows a startup update notification when the main process reports an available update', () => { /**
statusHandler?.(availableStatus); * Pushes a status and lets the deferred card render: its chunk resolves
* asynchronously, after which the panel needs one more check.
*/
async function showUpdate(
status: ElectronBridgeAppUpdateStatus
): Promise<void> {
statusHandler?.(status);
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
fixture.detectChanges();
}
function card(): AppUpdateNotificationCardComponent {
return fixture.debugElement.query(
By.directive(AppUpdateNotificationCardComponent)
).componentInstance as AppUpdateNotificationCardComponent;
}
it('renders no card before the main process reports an update', () => {
expect(
fixture.nativeElement.querySelector('app-update-notification-card')
).toBeNull();
});
it('shows a startup update notification when the main process reports an available update', async () => {
await showUpdate(availableStatus);
expect( expect(
fixture.nativeElement.querySelector( fixture.nativeElement.querySelector(
@@ -73,10 +105,27 @@ describe('AppUpdateNotificationPanelComponent', () => {
).not.toBeNull(); ).not.toBeNull();
}); });
it('opens release notes without dismissing the notification', async () => { it('hides the notification when it is dismissed', async () => {
statusHandler?.(availableStatus); await showUpdate(availableStatus);
(
fixture.nativeElement.querySelector(
'.app-update-notification__header button'
) as HTMLButtonElement
).click();
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.dismissedVersion()).toBe('0.23.0');
expect(
fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification"]'
)
).toBeNull();
});
it('opens release notes without dismissing the notification', async () => {
await showUpdate(availableStatus);
( (
fixture.nativeElement.querySelector( fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification-release-notes"]' '[data-test-id="app-update-notification-release-notes"]'
@@ -104,12 +153,12 @@ describe('AppUpdateNotificationPanelComponent', () => {
const error = jest const error = jest
.spyOn(console, 'error') .spyOn(console, 'error')
.mockImplementation(() => undefined); .mockImplementation(() => undefined);
fixture.componentInstance.loadReleaseNotesDialog = () => await showUpdate(availableStatus);
const notification = card();
notification.loadReleaseNotesDialog = () =>
Promise.reject(new Error('chunk failed')); Promise.reject(new Error('chunk failed'));
statusHandler?.(availableStatus);
fixture.detectChanges();
await fixture.componentInstance.openReleaseNotes(); await notification.openReleaseNotes();
expect(TestBed.inject(MatDialog).open).not.toHaveBeenCalled(); expect(TestBed.inject(MatDialog).open).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledWith( expect(open).toHaveBeenCalledWith(
@@ -123,8 +172,7 @@ describe('AppUpdateNotificationPanelComponent', () => {
}); });
it('starts downloading the update from the notification action', async () => { it('starts downloading the update from the notification action', async () => {
statusHandler?.(availableStatus); await showUpdate(availableStatus);
fixture.detectChanges();
( (
fixture.nativeElement.querySelector( fixture.nativeElement.querySelector(
@@ -134,15 +182,47 @@ describe('AppUpdateNotificationPanelComponent', () => {
await fixture.whenStable(); await fixture.whenStable();
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1); expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
// The card reports the updater's reply back to the panel.
expect(fixture.componentInstance.status()?.status).toBe(
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
);
}); });
it('labels unsupported platform action as a GitHub release link', () => { it('tells the user when the card chunk cannot be loaded', async () => {
fixture.destroy();
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [
AppUpdateNotificationPanelComponent,
TranslateModule.forRoot(),
],
providers: [{ provide: MatDialog, useValue: { open: jest.fn() } }],
deferBlockBehavior: DeferBlockBehavior.Manual,
}).compileComponents();
fixture = TestBed.createComponent(AppUpdateNotificationPanelComponent);
fixture.detectChanges();
await fixture.whenStable();
statusHandler?.(availableStatus);
fixture.detectChanges();
const [block] = await fixture.getDeferBlocks();
await block.render(DeferBlockState.Error);
const notice = fixture.nativeElement.querySelector('[role="alert"]');
expect(notice?.textContent).toContain(
'SETTINGS.APP_UPDATE_PANEL_UNAVAILABLE'
);
expect(
fixture.nativeElement.querySelector('app-update-notification-card')
).toBeNull();
});
it('labels unsupported platform action as a GitHub release link', async () => {
const open = jest.spyOn(window, 'open').mockImplementation(() => null); const open = jest.spyOn(window, 'open').mockImplementation(() => null);
statusHandler?.({ await showUpdate({
...availableStatus, ...availableStatus,
supportedSelfUpdate: false, supportedSelfUpdate: false,
}); });
fixture.detectChanges();
const button = fixture.nativeElement.querySelector( const button = fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification-download"]' '[data-test-id="app-update-notification-download"]'
@@ -1,204 +1,78 @@
import { import {
ChangeDetectionStrategy,
Component, Component,
computed, computed,
OnDestroy, OnDestroy,
OnInit, OnInit,
signal, signal,
inject,
ChangeDetectionStrategy,
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES, ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus, ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import { AppUpdateInstallService } from './services/app-update-install.service'; import { AppUpdateNotificationCardComponent } from './app-update-notification-card.component';
/**
* Follows the main process's update status and shows the notification card
* once an update is available. The panel is mounted on every launch, so the
* card and its Material buttons, icons and dialog load with the first
* notification instead of with the app. Should that chunk fail to load, a
* notice points at the settings, where the update can still be installed.
*/
@Component({ @Component({
selector: 'app-update-notification-panel', selector: 'app-update-notification-panel',
imports: [MatButtonModule, MatIconModule, TranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AppUpdateNotificationCardComponent, TranslatePipe],
template: ` template: `
@if (isVisible(); as visible) { @defer (when visibleStatus()) {
<section @if (visibleStatus(); as current) {
class="app-update-notification" <app-update-notification-card
data-test-id="app-update-notification" [status]="current"
> (statusChange)="status.set($event)"
<header class="app-update-notification__header"> (dismissed)="dismiss()"
<span> />
<mat-icon>system_update</mat-icon> }
{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }} } @error {
</span> @if (visibleStatus()) {
<button <div class="app-update-notification-unavailable" role="alert">
mat-icon-button {{ 'SETTINGS.APP_UPDATE_PANEL_UNAVAILABLE' | translate }}
type="button"
(click)="dismiss()"
[attr.aria-label]="'CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</header>
<div class="app-update-notification__body">
<strong>
{{
labelKey()
| translate
: {
version:
status()?.latestVersion ||
status()?.currentVersion,
}
}}
</strong>
@if (status()?.progress; as progress) {
<progress
[value]="progress.percent"
max="100"
></progress>
}
</div> </div>
}
<div class="app-update-notification__actions">
<button
mat-stroked-button
type="button"
(click)="openReleaseNotes()"
data-test-id="app-update-notification-release-notes"
>
<mat-icon>article</mat-icon>
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
</button>
@if (status()?.status === appUpdateStatuses.Downloaded) {
<button
mat-flat-button
type="button"
(click)="installUpdate()"
data-test-id="app-update-notification-install"
>
<mat-icon>restart_alt</mat-icon>
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
</button>
} @else {
<button
mat-flat-button
type="button"
[disabled]="
status()?.status ===
appUpdateStatuses.Downloading
"
(click)="downloadUpdate()"
data-test-id="app-update-notification-download"
>
<mat-icon>{{ primaryActionIcon() }}</mat-icon>
{{ primaryActionLabelKey() | translate }}
</button>
}
</div>
</section>
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, styles: `
styles: [ .app-update-notification-unavailable {
` position: fixed;
.app-update-notification { right: 20px;
position: fixed; bottom: 20px;
right: 20px; z-index: 901;
bottom: 20px; width: 320px;
width: 320px; max-width: calc(100vw - 40px);
max-width: calc(100vw - 40px); padding: 10px 12px;
z-index: 901; border: 1px solid var(--app-separator);
overflow: hidden; border-radius: 12px;
border: 1px solid var(--app-separator); background: var(--app-widget-bg);
border-radius: 12px; color: var(--app-body-color);
background: var(--app-widget-bg); font-size: 0.88rem;
box-shadow: line-height: 1.4;
0 1px 2px rgba(0, 0, 0, 0.08), }
0 8px 24px rgba(0, 0, 0, 0.18), @media (max-width: 560px) {
0 16px 48px rgba(0, 0, 0, 0.12); .app-update-notification-unavailable {
right: 12px;
bottom: 12px;
left: 12px;
width: auto;
max-width: none;
} }
}
.app-update-notification__header { `,
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 8px 12px;
border-bottom: 1px solid var(--app-separator);
}
.app-update-notification__header span {
display: flex;
align-items: center;
gap: 8px;
color: var(--app-heading-color);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.app-update-notification__header mat-icon {
color: var(--app-selection-color);
}
.app-update-notification__body {
display: grid;
gap: 8px;
padding: 12px;
color: var(--app-body-color);
font-size: 0.88rem;
}
.app-update-notification__body progress {
width: 100%;
}
.app-update-notification__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
padding: 0 12px 12px;
}
@media (max-width: 560px) {
.app-update-notification {
right: 12px;
bottom: 12px;
left: 12px;
width: auto;
max-width: none;
}
}
`,
],
}) })
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy { export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
private readonly dialog = inject(MatDialog);
private readonly installService = inject(AppUpdateInstallService);
private unsubscribeStatus: (() => void) | null = null; private unsubscribeStatus: (() => void) | null = null;
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
readonly status = signal<ElectronBridgeAppUpdateStatus | null>(null); readonly status = signal<ElectronBridgeAppUpdateStatus | null>(null);
readonly dismissedVersion = signal<string | null>(null); readonly dismissedVersion = signal<string | null>(null);
readonly labelKey = computed(() =>
this.status()?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
? 'SETTINGS.APP_UPDATE_DOWNLOADED'
: 'SETTINGS.APP_UPDATE_AVAILABLE'
);
readonly primaryActionLabelKey = computed(() =>
this.status()?.supportedSelfUpdate === false
? 'SETTINGS.APP_UPDATE_OPEN_RELEASE'
: 'SETTINGS.APP_UPDATE_DOWNLOAD'
);
readonly primaryActionIcon = computed(() =>
this.status()?.supportedSelfUpdate === false
? 'open_in_new'
: 'download'
);
readonly isVisible = computed(() => { readonly isVisible = computed(() => {
const status = this.status(); const status = this.status();
@@ -216,6 +90,10 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
); );
}); });
/** The status the card shows, or null while there is nothing to show. */
readonly visibleStatus = computed(() =>
this.isVisible() ? this.status() : null
);
ngOnInit(): void { ngOnInit(): void {
if (!window.electron?.getAppUpdateStatus) { if (!window.electron?.getAppUpdateStatus) {
@@ -237,74 +115,6 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
this.unsubscribeStatus = null; this.unsubscribeStatus = null;
} }
/**
* Loaded on demand: the dialog renders Markdown with `marked`, which this
* always-mounted panel would otherwise put on the initial path. A field so
* specs can substitute it.
*/
loadReleaseNotesDialog = () =>
import('./settings/app-update-release-notes-dialog.component');
async openReleaseNotes(): Promise<void> {
const status = this.status();
const latestVersion = status?.latestVersion;
let dialogModule: Awaited<
ReturnType<typeof this.loadReleaseNotesDialog>
>;
try {
dialogModule = await this.loadReleaseNotesDialog();
} catch (error) {
// The same notes are on the releases page; the next click retries.
console.error('Could not load the release notes dialog:', error);
if (status?.manualDownloadUrl) {
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
}
return;
}
const { AppUpdateReleaseNotesDialogComponent } = dialogModule;
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
initialVersion: latestVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
});
}
async downloadUpdate(): Promise<void> {
const status = this.status();
if (!status) {
return;
}
if (!status.supportedSelfUpdate) {
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
return;
}
if (!window.electron?.downloadAppUpdate) {
return;
}
this.status.set(await window.electron.downloadAppUpdate());
}
async installUpdate(): Promise<void> {
// Never window.electron.installAppUpdate() directly: the install
// quits the app, and AppUpdateInstallService stands the settings
// unload guard down first — this panel is global, so the settings
// form can be dirty right now.
const status = await this.installService.installAppUpdate();
if (status) {
this.status.set(status);
}
}
dismiss(): void { dismiss(): void {
this.dismissedVersion.set(this.status()?.latestVersion ?? null); this.dismissedVersion.set(this.status()?.latestVersion ?? null);
} }
+33
View File
@@ -0,0 +1,33 @@
import { ANIMATION_MODULE_TYPE, RendererFactory2 } from '@angular/core';
type ProviderEntry = { provide?: unknown };
function flattenProviders(providers: readonly unknown[]): ProviderEntry[] {
return providers.flatMap((provider) => {
if (Array.isArray(provider)) {
return flattenProviders(provider);
}
// makeEnvironmentProviders() wraps its list in ɵproviders.
const wrapped = (provider as { ɵproviders?: unknown[] } | null)
?.ɵproviders;
return wrapped
? flattenProviders(wrapped)
: [provider as ProviderEntry];
});
}
describe('appConfig', () => {
// The @angular/animations renderer queues every DOM removal until the
// end of the next change detection, while the router inserts a routed
// page at once: leaving a portal for the dashboard laid out both pages
// until the coalesced tick and scored the swap as a layout shift.
it('keeps the default DOM renderer, so routed pages swap in one task', async () => {
const { appConfig } = await import('./app.config');
const tokens = flattenProviders(appConfig.providers).map(
(provider) => provider?.provide
);
expect(tokens).not.toContain(RendererFactory2);
expect(tokens).not.toContain(ANIMATION_MODULE_TYPE);
});
});
+13 -10
View File
@@ -8,9 +8,12 @@ import {
FullscreenOverlayContainer, FullscreenOverlayContainer,
OverlayContainer, OverlayContainer,
} from '@angular/cdk/overlay'; } from '@angular/cdk/overlay';
import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core'; import {
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; ApplicationConfig,
import { provideAnimations } from '@angular/platform-browser/animations'; inject,
importProvidersFrom,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker'; import { provideServiceWorker } from '@angular/service-worker';
import { provideEffects } from '@ngrx/effects'; import { provideEffects } from '@ngrx/effects';
@@ -32,7 +35,6 @@ import {
DataService, DataService,
} from '@iptvnator/services'; } from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces'; import { dbConfig } from '@iptvnator/shared/interfaces';
import { changeDetectionProviders } from '../environments/change-detection.providers';
import { storeDevtoolsProviders } from '../environments/store-devtools.providers'; import { storeDevtoolsProviders } from '../environments/store-devtools.providers';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { ElectronService } from './services/electron.service'; import { ElectronService } from './services/electron.service';
@@ -105,9 +107,14 @@ export function DataFactory() {
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
...changeDetectionProviders, provideZonelessChangeDetection(),
provideRouter(routes, withComponentInputBinding()), provideRouter(routes, withComponentInputBinding()),
provideAnimations(), // 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, 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 // CDK overlays (menus, tooltips, dialogs) live in a container under
// <body>, which the browser hides behind an element in DOM fullscreen. // <body>, which the browser hides behind an element in DOM fullscreen.
// This container follows the fullscreen element instead, so the // This container follows the fullscreen element instead, so the
@@ -162,9 +169,5 @@ export const appConfig: ApplicationConfig = {
...provideParentalLockPrompt(), ...provideParentalLockPrompt(),
provideParentalLockPlaylistCleanup(), provideParentalLockPlaylistCleanup(),
...provideXtreamDataSource(), ...provideXtreamDataSource(),
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: { appearance: 'outline', subscriptSizing: 'dynamic' },
},
], ],
}; };
@@ -164,7 +164,8 @@ export class ElectronService extends DataService {
} }
if (type === PLAYLIST_UPDATE) { if (type === PLAYLIST_UPDATE) {
this.updateM3uPlaylistFromFile( // Settles when the refresh does, so callers keep the source busy.
await this.updateM3uPlaylistFromFile(
payload as { payload as {
id: string; id: string;
filePath?: string; filePath?: string;
@@ -1,22 +1,22 @@
import { inject, Injectable, Provider } from '@angular/core'; import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { import {
PARENTAL_LOCK_PROMPT, PARENTAL_LOCK_PROMPT,
ParentalLockPrompt, ParentalLockPrompt,
ParentalLockPromptRequest, ParentalLockPromptRequest,
} from '@iptvnator/services'; } from '@iptvnator/services';
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
/** /**
* Application implementation of the parental lock prompt: the shared PIN * Application implementation of the parental lock prompt: the shared PIN
* dialog from `@iptvnator/ui/components`, which the data-access lock * dialog from `@iptvnator/ui/components`, which the data-access lock
* service cannot import itself. The dialog is loaded on first use (the * service cannot import itself. The dialog, and Material's dialog service
* service is constructed at startup, the dialog is not needed until a PIN * with it, is loaded on first use (the service is constructed at startup,
* is asked for). * the dialog is not needed until a PIN is asked for).
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AppParentalLockPromptService implements ParentalLockPrompt { export class AppParentalLockPromptService implements ParentalLockPrompt {
private readonly dialog = inject(MatDialog); private readonly lazyDialog = inject(LazyMatDialog);
/** The dynamic import; a field so specs can substitute it. */ /** The dynamic import; a field so specs can substitute it. */
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy'); loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
@@ -25,8 +25,12 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
request: ParentalLockPromptRequest request: ParentalLockPromptRequest
): Promise<string | null> { ): Promise<string | null> {
let module: Awaited<ReturnType<typeof this.loadPinDialog>>; let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
let dialog: Awaited<ReturnType<LazyMatDialog['resolve']>>;
try { try {
module = await this.loadPinDialog(); [dialog, module] = await Promise.all([
this.lazyDialog.resolve(),
this.loadPinDialog(),
]);
} catch (error) { } catch (error) {
// A failed chunk load reads as a cancelled prompt: the gate it // A failed chunk load reads as a cancelled prompt: the gate it
// guards stays closed. // guards stays closed.
@@ -36,17 +40,14 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
); );
return null; return null;
} }
const dialogRef = module.ParentalLockPinDialogComponent.open( const dialogRef = module.ParentalLockPinDialogComponent.open(dialog, {
this.dialog, mode: request.mode,
{ verify: request.verify,
mode: request.mode, throttle: request.throttle,
verify: request.verify, titleKey: request.titleKey,
throttle: request.throttle, descriptionKey: request.descriptionKey,
titleKey: request.titleKey, submitKey: request.submitKey,
descriptionKey: request.descriptionKey, });
submitKey: request.submitKey,
}
);
const pin = await firstValueFrom(dialogRef.afterClosed()); const pin = await firstValueFrom(dialogRef.afterClosed());
return typeof pin === 'string' ? pin : null; return typeof pin === 'string' ? pin : null;
} }
+10 -6
View File
@@ -1,5 +1,4 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DataService } from '@iptvnator/services'; import { DataService } from '@iptvnator/services';
import { import {
ExternalPlayerSession, ExternalPlayerSession,
@@ -10,13 +9,14 @@ import {
VideoPlayer, VideoPlayer,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces'; import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
import { SettingsStore } from './settings-store.service'; import { SettingsStore } from './settings-store.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class PlayerService { export class PlayerService {
private dialog = inject(MatDialog); private lazyDialog = inject(LazyMatDialog);
private dataService = inject(DataService); private dataService = inject(DataService);
private settingsStore = inject(SettingsStore); private settingsStore = inject(SettingsStore);
@@ -85,12 +85,16 @@ export class PlayerService {
/** /**
* Loaded on demand: the dialog's Material checkbox brings @angular/forms, * Loaded on demand: the dialog's Material checkbox brings @angular/forms,
* and this service is created with the app, so a static import would put * and this service is created with the app, so a static import would put
* both on the initial path. Playback does not wait for the dialog. * both, and Material's dialog, on the initial path. Playback does not
* wait for the dialog.
*/ */
private showExternalPlayerInfoDialog(): void { private showExternalPlayerInfoDialog(): void {
this.loadExternalPlayerInfoDialog() Promise.all([
.then(({ ExternalPlayerInfoDialogComponent }) => this.lazyDialog.resolve(),
this.dialog.open(ExternalPlayerInfoDialogComponent) this.loadExternalPlayerInfoDialog(),
])
.then(([dialog, { ExternalPlayerInfoDialogComponent }]) =>
dialog.open(ExternalPlayerInfoDialogComponent)
) )
.catch((error: unknown) => { .catch((error: unknown) => {
// Informational only: playback is already starting. // Informational only: playback is already starting.
@@ -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 () => { it('appends the proxy network code to the playlist-refresh failure toast', async () => {
const unhandled: unknown[] = []; const unhandled: unknown[] = [];
const previousHandler = rxjsConfig.onUnhandledError; const previousHandler = rxjsConfig.onUnhandledError;
+36 -26
View File
@@ -133,8 +133,10 @@ export class PwaService extends DataService {
} }
if (type === PLAYLIST_UPDATE) { if (type === PLAYLIST_UPDATE) {
this.refreshPlaylist(payload as Partial<Playlist & { id: string }>); // Settles when the refresh does, so callers keep the source busy.
return undefined as T; return this.refreshPlaylist(
payload as Partial<Playlist & { id: string }>
) as T;
} }
if (type === XTREAM_REQUEST) { 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) { if (!payload?.url || !payload?.id) {
return; return Promise.resolve();
} }
const playlistId = payload.id; const playlistId = payload.id;
const { url, userAgent } = payload;
this.getPlaylistFromUrl(payload.url, payload.userAgent) return new Promise<void>((resolve) => {
.pipe( this.getPlaylistFromUrl(url, userAgent).subscribe({
catchError((error) => { 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.snackBar.open(
this.appendProviderErrorCode( this.appendProviderErrorCode(
this.getPlaylistRefreshErrorMessage(error), this.getPlaylistRefreshErrorMessage(error),
@@ -235,26 +260,11 @@ export class PwaService extends DataService {
duration: 5000, duration: 5000,
} }
); );
return throwError(() => error); resolve();
}) },
) complete: () => resolve(),
.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 }
);
}); });
});
} }
private getPlaylistRefreshErrorMessage(error: unknown): string { private getPlaylistRefreshErrorMessage(error: unknown): string {
@@ -1,7 +1,8 @@
import { inject, Injectable, Provider } from '@angular/core'; import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import type { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util/tokens'; import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util/tokens';
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
import { import {
WORKSPACE_SHELL_ACTIONS, WORKSPACE_SHELL_ACTIONS,
WorkspaceAccountInfoData, WorkspaceAccountInfoData,
@@ -10,15 +11,21 @@ import {
WorkspaceStalkerAccountInfoData, WorkspaceStalkerAccountInfoData,
} from '@iptvnator/workspace/shell/util'; } from '@iptvnator/workspace/shell/util';
/**
* Created with the app (the shell's keyboard shortcuts reach it from the
* root), so every dialog it opens, and Material's dialog service itself,
* is imported on first use.
*/
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AppWorkspaceShellActionsService implements WorkspaceShellActions { export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
private readonly dialog = inject(MatDialog); private readonly lazyDialog = inject(LazyMatDialog);
private readonly router = inject(Router); private readonly router = inject(Router);
openAddPlaylistDialog(type?: WorkspacePlaylistType): void { openAddPlaylistDialog(type?: WorkspacePlaylistType): void {
void import('@iptvnator/playlist/import/feature').then( this.openDialog(
({ AddPlaylistDialogComponent }) => { import('@iptvnator/playlist/import/feature'),
this.dialog.open(AddPlaylistDialogComponent, { (dialog, { AddPlaylistDialogComponent }) => {
dialog.open(AddPlaylistDialogComponent, {
// Width sized for the 5-card method picker plus the // Width sized for the 5-card method picker plus the
// selected method's form below. Matches the v0.22 // selected method's form below. Matches the v0.22
// mockup; falls back to viewport-clamped width on // mockup; falls back to viewport-clamped width on
@@ -44,9 +51,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
} }
openAccountInfo(data: WorkspaceAccountInfoData): void { openAccountInfo(data: WorkspaceAccountInfoData): void {
void import('@iptvnator/portal/xtream/feature').then( this.openDialog(
({ AccountInfoComponent }) => { import('@iptvnator/portal/xtream/feature'),
this.dialog.open(AccountInfoComponent, { (dialog, { AccountInfoComponent }) => {
dialog.open(AccountInfoComponent, {
width: '80%', width: '80%',
maxWidth: '1200px', maxWidth: '1200px',
maxHeight: '90vh', maxHeight: '90vh',
@@ -57,9 +65,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
} }
openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void { openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void {
void import('@iptvnator/portal/stalker/feature').then( this.openDialog(
({ StalkerAccountInfoComponent }) => { import('@iptvnator/portal/stalker/feature'),
this.dialog.open(StalkerAccountInfoComponent, { (dialog, { StalkerAccountInfoComponent }) => {
dialog.open(StalkerAccountInfoComponent, {
width: '80%', width: '80%',
maxWidth: '1200px', maxWidth: '1200px',
maxHeight: '90vh', maxHeight: '90vh',
@@ -68,6 +77,16 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
} }
); );
} }
/** Opens once both the dialog service and the component's chunk are in. */
private openDialog<T>(
load: Promise<T>,
open: (dialog: MatDialog, module: T) => void
): void {
void Promise.all([this.lazyDialog.resolve(), load]).then(
([dialog, module]) => open(dialog, module)
);
}
} }
export function provideWorkspaceShellActions(): Provider[] { export function provideWorkspaceShellActions(): Provider[] {
@@ -20,6 +20,7 @@ import {
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import { UpdateChannelOption } from './settings.models'; import { UpdateChannelOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface SettingsUpdateStatusChip { interface SettingsUpdateStatusChip {
readonly labelKey: string; readonly labelKey: string;
@@ -28,6 +29,7 @@ interface SettingsUpdateStatusChip {
@Component({ @Component({
selector: 'app-settings-about-section', selector: 'app-settings-about-section',
providers: [provideFormFieldDefaults()],
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
@@ -23,9 +23,11 @@ import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { EpgViewModeOption } from './settings.models'; import { EpgViewModeOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({ @Component({
selector: 'app-settings-epg-section', selector: 'app-settings-epg-section',
providers: [provideFormFieldDefaults()],
imports: [ imports: [
EpgSourceStatusComponent, EpgSourceStatusComponent,
MatButtonModule, MatButtonModule,
@@ -20,9 +20,11 @@ import {
ThemeOption, ThemeOption,
} from './settings.models'; } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({ @Component({
selector: 'app-settings-general-section', selector: 'app-settings-general-section',
providers: [provideFormFieldDefaults()],
imports: [ imports: [
CommonModule, CommonModule,
MatFormFieldModule, MatFormFieldModule,
@@ -9,9 +9,11 @@ import {
} from '@angular/material/slide-toggle'; } from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces'; import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({ @Component({
selector: 'app-settings-parental-lock-section', selector: 'app-settings-parental-lock-section',
providers: [provideFormFieldDefaults()],
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
@@ -22,9 +22,11 @@ import {
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models'; import { SettingsPlayerOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({ @Component({
selector: 'app-settings-playback-section', selector: 'app-settings-playback-section',
providers: [provideFormFieldDefaults()],
imports: [ imports: [
CommonModule, CommonModule,
MatButtonModule, MatButtonModule,
@@ -15,9 +15,11 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode'; import { QRCodeComponent } from 'angularx-qrcode';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({ @Component({
selector: 'app-settings-remote-control-section', selector: 'app-settings-remote-control-section',
providers: [provideFormFieldDefaults()],
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
@@ -17,11 +17,13 @@ import { TranslateModule } from '@ngx-translate/core';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
@Component({ @Component({
selector: 'app-settings-tmdb-section', selector: 'app-settings-tmdb-section',
providers: [provideFormFieldDefaults()],
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
@@ -1,4 +1,4 @@
import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; import { inject, Injectable, OnDestroy } from '@angular/core';
import { AppUpdateInstallService } from '../services/app-update-install.service'; import { AppUpdateInstallService } from '../services/app-update-install.service';
import { SettingsForm } from './settings-form.utils'; import { SettingsForm } from './settings-form.utils';
@@ -39,7 +39,6 @@ export interface SettingsUnloadGuardHost {
*/ */
@Injectable() @Injectable()
export class SettingsUnloadGuardService implements OnDestroy { export class SettingsUnloadGuardService implements OnDestroy {
private readonly zone = inject(NgZone);
private readonly installService = inject(AppUpdateInstallService); private readonly installService = inject(AppUpdateInstallService);
private host: SettingsUnloadGuardHost | null = null; private host: SettingsUnloadGuardHost | null = null;
private unsubscribeCloseRequests: (() => void) | null = null; private unsubscribeCloseRequests: (() => void) | null = null;
@@ -78,7 +77,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
this.unsubscribeCloseRequests = this.unsubscribeCloseRequests =
window.electron?.onWindowCloseRequested?.((requestId) => { window.electron?.onWindowCloseRequested?.((requestId) => {
this.latestCloseRequestId = requestId; this.latestCloseRequestId = requestId;
this.zone.run(() => void this.handleCloseRequest('close')); void this.handleCloseRequest('close');
}) ?? null; }) ?? null;
// Whatever surface installs an app update — the settings About // Whatever surface installs an app update — the settings About
@@ -141,9 +140,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
this.host = null; this.host = null;
} }
private readonly beforeUnloadHandler = ( private readonly beforeUnloadHandler = (event: BeforeUnloadEvent): void => {
event: BeforeUnloadEvent
): void => {
if (!this.host?.form.dirty) { if (!this.host?.form.dirty) {
return; return;
} }
@@ -160,7 +157,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
// handler entirely — so this can only be a reload: ask, then // handler entirely — so this can only be a reload: ask, then
// re-trigger it. // re-trigger it.
setTimeout(() => { setTimeout(() => {
this.zone.run(() => void this.handleCloseRequest('reload')); void this.handleCloseRequest('reload');
}); });
} }
}; };
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "الاسم (ي-أ)", "NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)", "NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)", "OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)" "CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)",
"UPDATED": "آخر تحديث",
"CHANNELS": "الأكثر قنوات",
"FAILING_FIRST": "غير المستجيبة أولاً"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)", "M3U": "M3U (محلي، رابط، نص)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث", "APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "لم يتم العثور على ملاحظات الإصدار {{version}} على GitHub. ربما تمت إزالة الإصدار أو لم يُنشر بعد.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "لم يتم العثور على ملاحظات الإصدار {{version}} على GitHub. ربما تمت إزالة الإصدار أو لم يُنشر بعد.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "تعذّر تحميل ملاحظات الإصدار.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "تعذّر تحميل ملاحظات الإصدار.",
"APP_UPDATE_PANEL_UNAVAILABLE": "تعذّر عرض إشعار التحديث. تحقّق من التحديثات في إعدادات التطبيق.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار غير متاحة في هذه النسخة من التطبيق.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار غير متاحة في هذه النسخة من التطبيق.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات", "APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات",
"APP_UPDATE_CHANNEL": "قناة التحديث", "APP_UPDATE_CHANNEL": "قناة التحديث",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.", "FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
"IMPORT_PROGRESS": "استيراد EPG", "IMPORT_PROGRESS": "استيراد EPG",
"IMPORT_PANEL_UNAVAILABLE": "تعذّر تحميل لوحة استيراد EPG. أعد تشغيل التطبيق لمتابعة تقدّم الاستيراد؛ تبقى حالة كل مصدر في إعدادات EPG.",
"CHANNELS": "قنوات", "CHANNELS": "قنوات",
"PROGRAMS": "برامج", "PROGRAMS": "برامج",
"LOADING": "جارٍ التحميل", "LOADING": "جارٍ التحميل",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "قوائم Stalker", "STALKER_PLAYLISTS": "قوائم Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل", "ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل واحدة", "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": { "CONTEXT": {
"CATEGORIES": "الفئات", "CATEGORIES": "الفئات",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "نقل تحديد لوحة الأوامر", "COMMAND_PALETTE_NAVIGATION": "نقل تحديد لوحة الأوامر",
"COMMAND_PALETTE_RUN": "تشغيل عنصر لوحة الأوامر المحدد", "COMMAND_PALETTE_RUN": "تشغيل عنصر لوحة الأوامر المحدد",
"CLOSE_DIALOGS": "إغلاق مربع الحوار أو إخفاء التراكب", "CLOSE_DIALOGS": "إغلاق مربع الحوار أو إخفاء التراكب",
"DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة" "DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة",
"OPEN_PLAYLIST_SWITCHER": "تبديل قائمة التشغيل"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "أضف قائمة تشغيل وسيظهر سجل المشاهدة هنا.", "NO_PLAYLISTS_BODY": "أضف قائمة تشغيل وسيظهر سجل المشاهدة هنا.",
"NO_ITEMS_TITLE": "لا توجد مشاهدات بعد", "NO_ITEMS_TITLE": "لا توجد مشاهدات بعد",
"NO_ITEMS_BODY": "ابدأ بمشاهدة أي محتوى من مكتبتك وسيظهر هنا." "NO_ITEMS_BODY": "ابدأ بمشاهدة أي محتوى من مكتبتك وسيظهر هنا."
},
"SWITCHER": {
"SEARCH": "ابحث عن قائمة تشغيل…",
"RECENT": "الأخيرة",
"NO_MATCHES": "لم يُعثر على شيء",
"ALL_SOURCES": "كل المصادر",
"HINT_SELECT": "تحديد",
"HINT_OPEN": "فتح",
"HINT_CLOSE": "إغلاق"
} }
}, },
"LOADING": "جارٍ التحميل", "LOADING": "جارٍ التحميل",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "الاسم (ي-أ)", "NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)", "NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)", "OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)" "CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)",
"UPDATED": "آخر تحديث",
"CHANNELS": "اللي فيها كتر القنوات",
"FAILING_FIRST": "اللي ما كيجاوبوش اللولين"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)", "M3U": "M3U (محلي، رابط، نص)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث", "APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "ما لقيناش ملاحظات الإصدار {{version}} فـ GitHub. يمكن الإصدار تحيد ولا مازال ما تنشرش.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "ما لقيناش ملاحظات الإصدار {{version}} فـ GitHub. يمكن الإصدار تحيد ولا مازال ما تنشرش.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "ما قدرناش نحمّلو ملاحظات الإصدار.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "ما قدرناش نحمّلو ملاحظات الإصدار.",
"APP_UPDATE_PANEL_UNAVAILABLE": "ما قدرناش نعرضو إشعار التحديث. تأكد من التحديثات فإعدادات التطبيق.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار ما متوفراش فهاد النسخة.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار ما متوفراش فهاد النسخة.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات", "APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات",
"APP_UPDATE_CHANNEL": "قناة التحديث", "APP_UPDATE_CHANNEL": "قناة التحديث",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG تجلب بنجاح", "FETCH_SUCCESS": "EPG تجلب بنجاح",
"IMPORT_PROGRESS": "استيراد EPG", "IMPORT_PROGRESS": "استيراد EPG",
"IMPORT_PANEL_UNAVAILABLE": "ما قدرناش نحمّلو لوحة استيراد EPG. عاود شغّل التطبيق باش تشوف تقدّم الاستيراد؛ حالة كل مصدر كتبقى فإعدادات EPG.",
"CHANNELS": "قنوات", "CHANNELS": "قنوات",
"PROGRAMS": "برامج", "PROGRAMS": "برامج",
"LOADING": "جاري التشريج", "LOADING": "جاري التشريج",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "قوائم تشغيل Stalker", "STALKER_PLAYLISTS": "قوائم تشغيل Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل", "ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل وحدة", "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": { "CONTEXT": {
"CATEGORIES": "الفئات", "CATEGORIES": "الفئات",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "حرك الاختيار فلوحة الأوامر", "COMMAND_PALETTE_NAVIGATION": "حرك الاختيار فلوحة الأوامر",
"COMMAND_PALETTE_RUN": "شغل العنصر المختار فلوحة الأوامر", "COMMAND_PALETTE_RUN": "شغل العنصر المختار فلوحة الأوامر",
"CLOSE_DIALOGS": "سد النافذة ولا خبي الطبقة", "CLOSE_DIALOGS": "سد النافذة ولا خبي الطبقة",
"DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة" "DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة",
"OPEN_PLAYLIST_SWITCHER": "بدّل الليستة"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "زيد قائمة تشغيل وسجل التفرج ديالك غادي يبان هنا.", "NO_PLAYLISTS_BODY": "زيد قائمة تشغيل وسجل التفرج ديالك غادي يبان هنا.",
"NO_ITEMS_TITLE": "ما تشاف حتى شي حاجة دابا", "NO_ITEMS_TITLE": "ما تشاف حتى شي حاجة دابا",
"NO_ITEMS_BODY": "بدا تفرج فشي حاجة من المكتبة ديالك وغادي تبان هنا." "NO_ITEMS_BODY": "بدا تفرج فشي حاجة من المكتبة ديالك وغادي تبان هنا."
},
"SWITCHER": {
"SEARCH": "قلّب على ليستة…",
"RECENT": "الجداد",
"NO_MATCHES": "ما لقينا والو",
"ALL_SOURCES": "كاع المصادر",
"HINT_SELECT": "ختار",
"HINT_OPEN": "حلّ",
"HINT_CLOSE": "سدّ"
} }
}, },
"LOADING": "كيتشارجا", "LOADING": "كيتشارجا",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Назва (Я-А)", "NAME_DESC": "Назва (Я-А)",
"NEWEST": "Дата дадання (спачатку новыя)", "NEWEST": "Дата дадання (спачатку новыя)",
"OLDEST": "Дата дадання (спачатку старыя)", "OLDEST": "Дата дадання (спачатку старыя)",
"CUSTOM_ORDER": "Уласны парадак (перацягванне)" "CUSTOM_ORDER": "Уласны парадак (перацягванне)",
"UPDATED": "Апошняе абнаўленне",
"CHANNELS": "Колькасць каналаў",
"FAILING_FIRST": "Спачатку тыя, што не адказваюць"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U (лакальны, URL, тэкст)", "M3U": "M3U (лакальны, URL, тэкст)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Навейшая версія", "APP_UPDATE_RELEASE_NOTES_NEWER": "Навейшая версія",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заўвагі да версіі {{version}} на GitHub не знойдзены. Рэліз мог быць выдалены або яшчэ не апублікаваны.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заўвагі да версіі {{version}} на GitHub не знойдзены. Рэліз мог быць выдалены або яшчэ не апублікаваны.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не ўдалося загрузіць заўвагі да версіі.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не ўдалося загрузіць заўвагі да версіі.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Не ўдалося паказаць апавяшчэнне пра абнаўленне. Праверце наяўнасць абнаўленняў у наладах праграмы.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заўвагі да версіі недаступныя ў гэтай зборцы.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заўвагі да версіі недаступныя ў гэтай зборцы.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Адкрыць старонку рэлізаў", "APP_UPDATE_OPEN_RELEASES_PAGE": "Адкрыць старонку рэлізаў",
"APP_UPDATE_CHANNEL": "Канал абнаўленняў", "APP_UPDATE_CHANNEL": "Канал абнаўленняў",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG быў паспяхова загружаны", "FETCH_SUCCESS": "EPG быў паспяхова загружаны",
"IMPORT_PROGRESS": "Імпарт EPG", "IMPORT_PROGRESS": "Імпарт EPG",
"IMPORT_PANEL_UNAVAILABLE": "Не ўдалося загрузіць панэль імпарту EPG. Перазапусціце праграму, каб бачыць ход імпарту; стан кожнай крыніцы застаецца ў наладах EPG.",
"CHANNELS": "каналаў", "CHANNELS": "каналаў",
"PROGRAMS": "перадач", "PROGRAMS": "перадач",
"LOADING": "Загрузка", "LOADING": "Загрузка",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker плэйлісты", "STALKER_PLAYLISTS": "Stalker плэйлісты",
"ALL_PLAYLISTS": "Усе плэйлісты", "ALL_PLAYLISTS": "Усе плэйлісты",
"PLAYLIST_COUNT_ONE": "1 плэйліст", "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": { "CONTEXT": {
"CATEGORIES": "Катэгорыі", "CATEGORIES": "Катэгорыі",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Перамясціць выбар у палітры каманд", "COMMAND_PALETTE_NAVIGATION": "Перамясціць выбар у палітры каманд",
"COMMAND_PALETTE_RUN": "Выканаць выбраны элемент палітры каманд", "COMMAND_PALETTE_RUN": "Выканаць выбраны элемент палітры каманд",
"CLOSE_DIALOGS": "Закрыць дыялог або схаваць накладку", "CLOSE_DIALOGS": "Закрыць дыялог або схаваць накладку",
"DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы" "DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы",
"OPEN_PLAYLIST_SWITCHER": "Пераключыць плэйліст"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Дадайце плэйліст, і гісторыя прагляду з’явіцца тут.", "NO_PLAYLISTS_BODY": "Дадайце плэйліст, і гісторыя прагляду з’явіцца тут.",
"NO_ITEMS_TITLE": "Праглядаў пакуль няма", "NO_ITEMS_TITLE": "Праглядаў пакуль няма",
"NO_ITEMS_BODY": "Пачніце глядзець што-небудзь з бібліятэкі, і гэта з’явіцца тут." "NO_ITEMS_BODY": "Пачніце глядзець што-небудзь з бібліятэкі, і гэта з’явіцца тут."
},
"SWITCHER": {
"SEARCH": "Знайсці плэйліст…",
"RECENT": "Нядаўнія",
"NO_MATCHES": "Нічога не знойдзена",
"ALL_SOURCES": "Усе крыніцы",
"HINT_SELECT": "выбар",
"HINT_OPEN": "адкрыць",
"HINT_CLOSE": "закрыць"
} }
}, },
"LOADING": "Загрузка", "LOADING": "Загрузка",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Name (Z-A)", "NAME_DESC": "Name (Z-A)",
"NEWEST": "Hinzugefügt (Neueste zuerst)", "NEWEST": "Hinzugefügt (Neueste zuerst)",
"OLDEST": "Hinzugefügt (Älteste 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": { "PLAYLIST_TYPES": {
"M3U": "M3U (lokal, URL, Text)", "M3U": "M3U (lokal, URL, Text)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Neuere Version", "APP_UPDATE_RELEASE_NOTES_NEWER": "Neuere Version",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Für {{version}} wurden auf GitHub keine Release-Notes gefunden. Das Release wurde möglicherweise entfernt oder ist noch nicht veröffentlicht.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Für {{version}} wurden auf GitHub keine Release-Notes gefunden. Das Release wurde möglicherweise entfernt oder ist noch nicht veröffentlicht.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Die Release-Notes konnten nicht geladen werden.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Die Release-Notes konnten nicht geladen werden.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Die Update-Benachrichtigung konnte nicht angezeigt werden. Prüfe in den App-Einstellungen auf Updates.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release-Notes sind in diesem Build nicht verfügbar.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release-Notes sind in diesem Build nicht verfügbar.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Release-Seite öffnen", "APP_UPDATE_OPEN_RELEASES_PAGE": "Release-Seite öffnen",
"APP_UPDATE_CHANNEL": "Update-Kanal", "APP_UPDATE_CHANNEL": "Update-Kanal",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen", "FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen",
"IMPORT_PROGRESS": "EPG-Import", "IMPORT_PROGRESS": "EPG-Import",
"IMPORT_PANEL_UNAVAILABLE": "Das EPG-Importfenster konnte nicht geladen werden. Starte die App neu, um den Importfortschritt zu sehen; der Status jeder Quelle bleibt in den EPG-Einstellungen.",
"CHANNELS": "Sender", "CHANNELS": "Sender",
"PROGRAMS": "Programme", "PROGRAMS": "Programme",
"LOADING": "Wird geladen", "LOADING": "Wird geladen",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker-Playlists", "STALKER_PLAYLISTS": "Stalker-Playlists",
"ALL_PLAYLISTS": "Alle Playlists", "ALL_PLAYLISTS": "Alle Playlists",
"PLAYLIST_COUNT_ONE": "1 Playlist", "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": { "CONTEXT": {
"CATEGORIES": "Kategorien", "CATEGORIES": "Kategorien",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Auswahl in der Befehlspalette verschieben", "COMMAND_PALETTE_NAVIGATION": "Auswahl in der Befehlspalette verschieben",
"COMMAND_PALETTE_RUN": "Ausgewählten Eintrag der Befehlspalette ausführen", "COMMAND_PALETTE_RUN": "Ausgewählten Eintrag der Befehlspalette ausführen",
"CLOSE_DIALOGS": "Dialog schließen oder Overlay ausblenden", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Füge eine Wiedergabeliste hinzu, und dein Wiedergabeverlauf erscheint hier.", "NO_PLAYLISTS_BODY": "Füge eine Wiedergabeliste hinzu, und dein Wiedergabeverlauf erscheint hier.",
"NO_ITEMS_TITLE": "Noch nichts angesehen", "NO_ITEMS_TITLE": "Noch nichts angesehen",
"NO_ITEMS_BODY": "Beginne, etwas aus deiner Bibliothek anzusehen, dann erscheint es hier." "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", "LOADING": "Laden",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Όνομα (Z-A)", "NAME_DESC": "Όνομα (Z-A)",
"NEWEST": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)", "NEWEST": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)",
"OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)", "OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)",
"CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)" "CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)",
"UPDATED": "Τελευταία ενημέρωση",
"CHANNELS": "Περισσότερα κανάλια",
"FAILING_FIRST": "Πρώτα όσα δεν αποκρίνονται"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U (τοπικό, διεύθυνση url, κείμενο)", "M3U": "M3U (τοπικό, διεύθυνση url, κείμενο)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Νεότερη έκδοση", "APP_UPDATE_RELEASE_NOTES_NEWER": "Νεότερη έκδοση",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Δεν βρέθηκαν σημειώσεις έκδοσης για την {{version}} στο GitHub. Η έκδοση μπορεί να αφαιρέθηκε ή να μην έχει δημοσιευτεί ακόμη.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Δεν βρέθηκαν σημειώσεις έκδοσης για την {{version}} στο GitHub. Η έκδοση μπορεί να αφαιρέθηκε ή να μην έχει δημοσιευτεί ακόμη.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των σημειώσεων έκδοσης.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των σημειώσεων έκδοσης.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Δεν ήταν δυνατή η εμφάνιση της ειδοποίησης ενημέρωσης. Ελέγξτε για ενημερώσεις στις ρυθμίσεις της εφαρμογής.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Οι σημειώσεις έκδοσης δεν είναι διαθέσιμες σε αυτή τη δομή.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Οι σημειώσεις έκδοσης δεν είναι διαθέσιμες σε αυτή τη δομή.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Άνοιγμα σελίδας εκδόσεων", "APP_UPDATE_OPEN_RELEASES_PAGE": "Άνοιγμα σελίδας εκδόσεων",
"APP_UPDATE_CHANNEL": "Κανάλι ενημερώσεων", "APP_UPDATE_CHANNEL": "Κανάλι ενημερώσεων",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία", "FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία",
"IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)", "IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)",
"IMPORT_PANEL_UNAVAILABLE": "Δεν ήταν δυνατή η φόρτωση του πίνακα εισαγωγής EPG. Επανεκκινήστε την εφαρμογή για να παρακολουθήσετε την εισαγωγή· η κατάσταση κάθε πηγής παραμένει στις ρυθμίσεις EPG.",
"CHANNELS": "κανάλια", "CHANNELS": "κανάλια",
"PROGRAMS": "προγράμματα", "PROGRAMS": "προγράμματα",
"LOADING": "Φόρτωση", "LOADING": "Φόρτωση",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Λίστες αναπαραγωγής Stalker", "STALKER_PLAYLISTS": "Λίστες αναπαραγωγής Stalker",
"ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής", "ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής",
"PLAYLIST_COUNT_ONE": "1 λίστα αναπαραγωγής", "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": { "CONTEXT": {
"CATEGORIES": "Κατηγορίες", "CATEGORIES": "Κατηγορίες",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Μετακίνηση επιλογής παλέτας εντολών", "COMMAND_PALETTE_NAVIGATION": "Μετακίνηση επιλογής παλέτας εντολών",
"COMMAND_PALETTE_RUN": "Εκτέλεση επιλεγμένου στοιχείου παλέτας εντολών", "COMMAND_PALETTE_RUN": "Εκτέλεση επιλεγμένου στοιχείου παλέτας εντολών",
"CLOSE_DIALOGS": "Κλείσιμο διαλόγου ή απόκρυψη overlay", "CLOSE_DIALOGS": "Κλείσιμο διαλόγου ή απόκρυψη overlay",
"DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη" "DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη",
"OPEN_PLAYLIST_SWITCHER": "Εναλλαγή λίστας"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Προσθέστε μια λίστα αναπαραγωγής και το ιστορικό προβολής σας θα εμφανιστεί εδώ.", "NO_PLAYLISTS_BODY": "Προσθέστε μια λίστα αναπαραγωγής και το ιστορικό προβολής σας θα εμφανιστεί εδώ.",
"NO_ITEMS_TITLE": "Δεν έχει παρακολουθηθεί τίποτα ακόμη", "NO_ITEMS_TITLE": "Δεν έχει παρακολουθηθεί τίποτα ακόμη",
"NO_ITEMS_BODY": "Ξεκινήστε να παρακολουθείτε οτιδήποτε από τη βιβλιοθήκη σας και θα εμφανιστεί εδώ." "NO_ITEMS_BODY": "Ξεκινήστε να παρακολουθείτε οτιδήποτε από τη βιβλιοθήκη σας και θα εμφανιστεί εδώ."
},
"SWITCHER": {
"SEARCH": "Εύρεση λίστας…",
"RECENT": "Πρόσφατα",
"NO_MATCHES": "Δεν βρέθηκε τίποτα",
"ALL_SOURCES": "Όλες οι πηγές",
"HINT_SELECT": "επιλογή",
"HINT_OPEN": "άνοιγμα",
"HINT_CLOSE": "κλείσιμο"
} }
}, },
"LOADING": "Φόρτωση", "LOADING": "Φόρτωση",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Name (Z-A)", "NAME_DESC": "Name (Z-A)",
"NEWEST": "Date added (Newest first)", "NEWEST": "Date added (Newest first)",
"OLDEST": "Date added (Oldest 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": { "PLAYLIST_TYPES": {
"M3U": "M3U (local, url, text)", "M3U": "M3U (local, url, text)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Newer release", "APP_UPDATE_RELEASE_NOTES_NEWER": "Newer release",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No release notes were found for {{version}} on GitHub. The release may have been removed or is not published yet.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No release notes were found for {{version}} on GitHub. The release may have been removed or is not published yet.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Release notes could not be loaded.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Release notes could not be loaded.",
"APP_UPDATE_PANEL_UNAVAILABLE": "The update notification could not be shown. Check for updates in the app settings.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release notes are not available in this build.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release notes are not available in this build.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Open releases page", "APP_UPDATE_OPEN_RELEASES_PAGE": "Open releases page",
"APP_UPDATE_CHANNEL": "Update channel", "APP_UPDATE_CHANNEL": "Update channel",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG was fetched successfully", "FETCH_SUCCESS": "EPG was fetched successfully",
"IMPORT_PROGRESS": "EPG Import", "IMPORT_PROGRESS": "EPG Import",
"IMPORT_PANEL_UNAVAILABLE": "The EPG import panel could not be loaded. Restart the app to see import progress; each source's status stays in the EPG settings.",
"CHANNELS": "channels", "CHANNELS": "channels",
"PROGRAMS": "programs", "PROGRAMS": "programs",
"LOADING": "Loading", "LOADING": "Loading",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker Playlists", "STALKER_PLAYLISTS": "Stalker Playlists",
"ALL_PLAYLISTS": "All Playlists", "ALL_PLAYLISTS": "All Playlists",
"PLAYLIST_COUNT_ONE": "1 playlist", "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": { "CONTEXT": {
"CATEGORIES": "Categories", "CATEGORIES": "Categories",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Move command palette selection", "COMMAND_PALETTE_NAVIGATION": "Move command palette selection",
"COMMAND_PALETTE_RUN": "Run selected command palette item", "COMMAND_PALETTE_RUN": "Run selected command palette item",
"CLOSE_DIALOGS": "Close dialog or dismiss overlay", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Add a playlist and your watching history will appear here.", "NO_PLAYLISTS_BODY": "Add a playlist and your watching history will appear here.",
"NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." "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", "LOADING": "Loading",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nombre (Z-A)", "NAME_DESC": "Nombre (Z-A)",
"NEWEST": "Fecha de adición (más recientes primero)", "NEWEST": "Fecha de adición (más recientes primero)",
"OLDEST": "Fecha de adición (más antiguas 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": { "PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)", "M3U": "M3U (local, url, texto)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versión más reciente", "APP_UPDATE_RELEASE_NOTES_NEWER": "Versión más reciente",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No se encontraron notas de la versión {{version}} en GitHub. Es posible que la versión se haya eliminado o aún no esté publicada.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No se encontraron notas de la versión {{version}} en GitHub. Es posible que la versión se haya eliminado o aún no esté publicada.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "No se pudieron cargar las notas de la versión.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "No se pudieron cargar las notas de la versión.",
"APP_UPDATE_PANEL_UNAVAILABLE": "No se pudo mostrar la notificación de actualización. Busca actualizaciones en los ajustes de la aplicación.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Las notas de la versión no están disponibles en esta compilación.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Las notas de la versión no están disponibles en esta compilación.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Abrir página de versiones", "APP_UPDATE_OPEN_RELEASES_PAGE": "Abrir página de versiones",
"APP_UPDATE_CHANNEL": "Canal de actualizaciones", "APP_UPDATE_CHANNEL": "Canal de actualizaciones",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG obtenida correctamente", "FETCH_SUCCESS": "EPG obtenida correctamente",
"IMPORT_PROGRESS": "Importación de EPG", "IMPORT_PROGRESS": "Importación de EPG",
"IMPORT_PANEL_UNAVAILABLE": "No se pudo cargar el panel de importación de EPG. Reinicia la aplicación para ver el progreso de la importación; el estado de cada fuente sigue en los ajustes de EPG.",
"CHANNELS": "canales", "CHANNELS": "canales",
"PROGRAMS": "programas", "PROGRAMS": "programas",
"LOADING": "Cargando", "LOADING": "Cargando",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Listas Stalker", "STALKER_PLAYLISTS": "Listas Stalker",
"ALL_PLAYLISTS": "Todas las listas", "ALL_PLAYLISTS": "Todas las listas",
"PLAYLIST_COUNT_ONE": "1 lista de reproducción", "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": { "CONTEXT": {
"CATEGORIES": "Categorías", "CATEGORIES": "Categorías",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Mover la selección de la paleta de comandos", "COMMAND_PALETTE_NAVIGATION": "Mover la selección de la paleta de comandos",
"COMMAND_PALETTE_RUN": "Ejecutar el elemento seleccionado 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", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Añade una lista de reproducción y tu historial de visualización aparecerá aquí.", "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_TITLE": "Aún no has visto nada",
"NO_ITEMS_BODY": "Empieza a ver cualquier contenido de tu biblioteca y aparecerá aquí." "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", "LOADING": "Cargando",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nom (Z-A)", "NAME_DESC": "Nom (Z-A)",
"NEWEST": "Date d'ajout (Plus récent)", "NEWEST": "Date d'ajout (Plus récent)",
"OLDEST": "Date d'ajout (Plus ancien)", "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": { "PLAYLIST_TYPES": {
"M3U": "M3U (local, URL, texte)", "M3U": "M3U (local, URL, texte)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Version plus récente", "APP_UPDATE_RELEASE_NOTES_NEWER": "Version plus récente",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Aucune note de version n'a été trouvée pour {{version}} sur GitHub. La version a peut-être été supprimée ou n'est pas encore publiée.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Aucune note de version n'a été trouvée pour {{version}} sur GitHub. La version a peut-être été supprimée ou n'est pas encore publiée.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossible de charger les notes de version.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossible de charger les notes de version.",
"APP_UPDATE_PANEL_UNAVAILABLE": "La notification de mise à jour n'a pas pu être affichée. Recherchez les mises à jour dans les réglages de l'application.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Les notes de version ne sont pas disponibles dans cette compilation.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Les notes de version ne sont pas disponibles dans cette compilation.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Ouvrir la page des versions", "APP_UPDATE_OPEN_RELEASES_PAGE": "Ouvrir la page des versions",
"APP_UPDATE_CHANNEL": "Canal de mise à jour", "APP_UPDATE_CHANNEL": "Canal de mise à jour",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès", "FETCH_SUCCESS": "L'EPG a été récupéré avec succès",
"IMPORT_PROGRESS": "Importation EPG", "IMPORT_PROGRESS": "Importation EPG",
"IMPORT_PANEL_UNAVAILABLE": "Le panneau d'import EPG n'a pas pu être chargé. Redémarrez l'application pour suivre l'import ; l'état de chaque source reste dans les réglages EPG.",
"CHANNELS": "chaînes", "CHANNELS": "chaînes",
"PROGRAMS": "programmes", "PROGRAMS": "programmes",
"LOADING": "Chargement", "LOADING": "Chargement",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Listes de lecture Stalker", "STALKER_PLAYLISTS": "Listes de lecture Stalker",
"ALL_PLAYLISTS": "Toutes les listes de lecture", "ALL_PLAYLISTS": "Toutes les listes de lecture",
"PLAYLIST_COUNT_ONE": "1 liste 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": { "CONTEXT": {
"CATEGORIES": "Catégories", "CATEGORIES": "Catégories",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Déplacer la sélection dans la palette de commandes", "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", "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", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Ajoutez une playlist et votre historique de visionnage apparaîtra ici.", "NO_PLAYLISTS_BODY": "Ajoutez une playlist et votre historique de visionnage apparaîtra ici.",
"NO_ITEMS_TITLE": "Rien regardé pour le moment", "NO_ITEMS_TITLE": "Rien regardé pour le moment",
"NO_ITEMS_BODY": "Commencez à regarder un contenu de votre bibliothèque et il apparaîtra ici." "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", "LOADING": "Chargement",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Név szerint (Z–A)", "NAME_DESC": "Név szerint (Z–A)",
"NEWEST": "Hozzáadás dátuma (legújabb elöl)", "NEWEST": "Hozzáadás dátuma (legújabb elöl)",
"OLDEST": "Hozzáadás dátuma (legrégebbi 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": { "PLAYLIST_TYPES": {
"M3U": "M3U (helyi fájl, URL vagy szöveg)", "M3U": "M3U (helyi fájl, URL vagy szöveg)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Újabb kiadás", "APP_UPDATE_RELEASE_NOTES_NEWER": "Újabb kiadás",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "A(z) {{version}} verzióhoz nem található kiadási jegyzet a GitHubon. A kiadást talán eltávolították, vagy még nem tették közzé.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "A(z) {{version}} verzióhoz nem található kiadási jegyzet a GitHubon. A kiadást talán eltávolították, vagy még nem tették közzé.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "A kiadási jegyzetek betöltése nem sikerült.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "A kiadási jegyzetek betöltése nem sikerült.",
"APP_UPDATE_PANEL_UNAVAILABLE": "A frissítési értesítés nem jeleníthető meg. Keress frissítéseket az alkalmazás beállításaiban.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Ebben a buildben nem érhetők el a kiadási jegyzetek.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Ebben a buildben nem érhetők el a kiadási jegyzetek.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Kiadások oldalának megnyitása", "APP_UPDATE_OPEN_RELEASES_PAGE": "Kiadások oldalának megnyitása",
"APP_UPDATE_CHANNEL": "Frissítési csatorna", "APP_UPDATE_CHANNEL": "Frissítési csatorna",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "Az EPG lekérése sikerült", "FETCH_SUCCESS": "Az EPG lekérése sikerült",
"IMPORT_PROGRESS": "EPG importálása", "IMPORT_PROGRESS": "EPG importálása",
"IMPORT_PANEL_UNAVAILABLE": "Az EPG-importpanel nem tölthető be. Indítsd újra az alkalmazást az importálás követéséhez; az egyes források állapota az EPG-beállításokban marad.",
"CHANNELS": "csatorna", "CHANNELS": "csatorna",
"PROGRAMS": "műsor", "PROGRAMS": "műsor",
"LOADING": "Betöltés", "LOADING": "Betöltés",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker-lejátszási listák", "STALKER_PLAYLISTS": "Stalker-lejátszási listák",
"ALL_PLAYLISTS": "Összes lejátszási lista", "ALL_PLAYLISTS": "Összes lejátszási lista",
"PLAYLIST_COUNT_ONE": "1 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": { "CONTEXT": {
"CATEGORIES": "Kategóriák", "CATEGORIES": "Kategóriák",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Kijelölés mozgatása a parancspalettán", "COMMAND_PALETTE_NAVIGATION": "Kijelölés mozgatása a parancspalettán",
"COMMAND_PALETTE_RUN": "A parancspalettán kijelölt művelet végrehajtása", "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", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Adjon hozzá egy lejátszási listát, és megtekintési előzményei itt fognak megjelenni.", "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_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." "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", "LOADING": "Betöltés",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nome (Z-A)", "NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data aggiunta (prima più recenti)", "NEWEST": "Data aggiunta (prima più recenti)",
"OLDEST": "Data aggiunta (prima più vecchi)", "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": { "PLAYLIST_TYPES": {
"M3U": "M3U (locale, URL, testo)", "M3U": "M3U (locale, URL, testo)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versione più recente", "APP_UPDATE_RELEASE_NOTES_NEWER": "Versione più recente",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nessuna nota di rilascio trovata per {{version}} su GitHub. La versione potrebbe essere stata rimossa o non è ancora pubblicata.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nessuna nota di rilascio trovata per {{version}} su GitHub. La versione potrebbe essere stata rimossa o non è ancora pubblicata.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossibile caricare le note di rilascio.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossibile caricare le note di rilascio.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Impossibile mostrare la notifica di aggiornamento. Cerca gli aggiornamenti nelle impostazioni dell'app.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Le note di rilascio non sono disponibili in questa build.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Le note di rilascio non sono disponibili in questa build.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Apri la pagina delle versioni", "APP_UPDATE_OPEN_RELEASES_PAGE": "Apri la pagina delle versioni",
"APP_UPDATE_CHANNEL": "Canale di aggiornamento", "APP_UPDATE_CHANNEL": "Canale di aggiornamento",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG scaricato correttamente", "FETCH_SUCCESS": "EPG scaricato correttamente",
"IMPORT_PROGRESS": "Importazione EPG", "IMPORT_PROGRESS": "Importazione EPG",
"IMPORT_PANEL_UNAVAILABLE": "Impossibile caricare il pannello di importazione EPG. Riavvia l'app per seguire l'importazione; lo stato di ogni sorgente resta nelle impostazioni EPG.",
"CHANNELS": "canali", "CHANNELS": "canali",
"PROGRAMS": "programmi", "PROGRAMS": "programmi",
"LOADING": "Caricamento", "LOADING": "Caricamento",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Playlist Stalker", "STALKER_PLAYLISTS": "Playlist Stalker",
"ALL_PLAYLISTS": "Tutte le playlist", "ALL_PLAYLISTS": "Tutte le playlist",
"PLAYLIST_COUNT_ONE": "1 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": { "CONTEXT": {
"CATEGORIES": "Categorie", "CATEGORIES": "Categorie",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Sposta selezione della palette comandi", "COMMAND_PALETTE_NAVIGATION": "Sposta selezione della palette comandi",
"COMMAND_PALETTE_RUN": "Esegui elemento selezionato della palette comandi", "COMMAND_PALETTE_RUN": "Esegui elemento selezionato della palette comandi",
"CLOSE_DIALOGS": "Chiudi dialogo o nascondi overlay", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Aggiungi una playlist e la tua cronologia di visione apparirà qui.", "NO_PLAYLISTS_BODY": "Aggiungi una playlist e la tua cronologia di visione apparirà qui.",
"NO_ITEMS_TITLE": "Ancora nessun contenuto guardato", "NO_ITEMS_TITLE": "Ancora nessun contenuto guardato",
"NO_ITEMS_BODY": "Inizia a guardare qualsiasi contenuto della tua libreria e apparirà qui." "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", "LOADING": "Caricamento",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名前(Z-A)", "NAME_DESC": "名前(Z-A)",
"NEWEST": "追加日(新しい順)", "NEWEST": "追加日(新しい順)",
"OLDEST": "追加日(古い順)", "OLDEST": "追加日(古い順)",
"CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)" "CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)",
"UPDATED": "最終更新",
"CHANNELS": "チャンネル数",
"FAILING_FIRST": "応答なしを先頭に"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U(ローカル、URL、テキスト)", "M3U": "M3U(ローカル、URL、テキスト)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "新しいリリース", "APP_UPDATE_RELEASE_NOTES_NEWER": "新しいリリース",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "{{version}} のリリースノートが GitHub に見つかりません。リリースが削除されたか、まだ公開されていない可能性があります。", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "{{version}} のリリースノートが GitHub に見つかりません。リリースが削除されたか、まだ公開されていない可能性があります。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "リリースノートを読み込めませんでした。", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "リリースノートを読み込めませんでした。",
"APP_UPDATE_PANEL_UNAVAILABLE": "更新の通知を表示できませんでした。アプリの設定で更新を確認してください。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "このビルドではリリースノートを利用できません。", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "このビルドではリリースノートを利用できません。",
"APP_UPDATE_OPEN_RELEASES_PAGE": "リリースページを開く", "APP_UPDATE_OPEN_RELEASES_PAGE": "リリースページを開く",
"APP_UPDATE_CHANNEL": "更新チャンネル", "APP_UPDATE_CHANNEL": "更新チャンネル",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPGの取得に成功しました", "FETCH_SUCCESS": "EPGの取得に成功しました",
"IMPORT_PROGRESS": "EPGインポート", "IMPORT_PROGRESS": "EPGインポート",
"IMPORT_PANEL_UNAVAILABLE": "EPGインポートパネルを読み込めませんでした。インポートの進行状況を確認するにはアプリを再起動してください。各ソースの状態はEPG設定で確認できます。",
"CHANNELS": "チャンネル", "CHANNELS": "チャンネル",
"PROGRAMS": "番組", "PROGRAMS": "番組",
"LOADING": "読み込み中", "LOADING": "読み込み中",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalkerプレイリスト", "STALKER_PLAYLISTS": "Stalkerプレイリスト",
"ALL_PLAYLISTS": "すべてのプレイリスト", "ALL_PLAYLISTS": "すべてのプレイリスト",
"PLAYLIST_COUNT_ONE": "1 件のプレイリスト", "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": { "CONTEXT": {
"CATEGORIES": "カテゴリー", "CATEGORIES": "カテゴリー",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "コマンドパレットの選択を移動", "COMMAND_PALETTE_NAVIGATION": "コマンドパレットの選択を移動",
"COMMAND_PALETTE_RUN": "選択中のコマンドパレット項目を実行", "COMMAND_PALETTE_RUN": "選択中のコマンドパレット項目を実行",
"CLOSE_DIALOGS": "ダイアログを閉じる、またはオーバーレイを解除", "CLOSE_DIALOGS": "ダイアログを閉じる、またはオーバーレイを解除",
"DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く" "DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く",
"OPEN_PLAYLIST_SWITCHER": "プレイリストを切り替え"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "プレイリストを追加すると、視聴履歴がここに表示されます。", "NO_PLAYLISTS_BODY": "プレイリストを追加すると、視聴履歴がここに表示されます。",
"NO_ITEMS_TITLE": "視聴履歴はまだありません", "NO_ITEMS_TITLE": "視聴履歴はまだありません",
"NO_ITEMS_BODY": "ライブラリ内のコンテンツを視聴し始めると、ここに表示されます。" "NO_ITEMS_BODY": "ライブラリ内のコンテンツを視聴し始めると、ここに表示されます。"
},
"SWITCHER": {
"SEARCH": "プレイリストを検索…",
"RECENT": "最近",
"NO_MATCHES": "見つかりませんでした",
"ALL_SOURCES": "すべてのソース",
"HINT_SELECT": "選択",
"HINT_OPEN": "開く",
"HINT_CLOSE": "閉じる"
} }
}, },
"LOADING": "読み込み中", "LOADING": "読み込み中",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "이름 (Z-A)", "NAME_DESC": "이름 (Z-A)",
"NEWEST": "추가 날짜 (최신순)", "NEWEST": "추가 날짜 (최신순)",
"OLDEST": "추가 날짜 (오래된순)", "OLDEST": "추가 날짜 (오래된순)",
"CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)" "CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)",
"UPDATED": "최근 업데이트",
"CHANNELS": "채널 많은 순",
"FAILING_FIRST": "응답 없는 항목 먼저"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U (로컬, URL, 텍스트)", "M3U": "M3U (로컬, URL, 텍스트)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "이후 릴리스", "APP_UPDATE_RELEASE_NOTES_NEWER": "이후 릴리스",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub에서 {{version}}의 릴리스 노트를 찾을 수 없습니다. 릴리스가 삭제되었거나 아직 게시되지 않았을 수 있습니다.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub에서 {{version}}의 릴리스 노트를 찾을 수 없습니다. 릴리스가 삭제되었거나 아직 게시되지 않았을 수 있습니다.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "릴리스 노트를 불러올 수 없습니다.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "릴리스 노트를 불러올 수 없습니다.",
"APP_UPDATE_PANEL_UNAVAILABLE": "업데이트 알림을 표시할 수 없습니다. 앱 설정에서 업데이트를 확인하세요.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "이 빌드에서는 릴리스 노트를 사용할 수 없습니다.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "이 빌드에서는 릴리스 노트를 사용할 수 없습니다.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "릴리스 페이지 열기", "APP_UPDATE_OPEN_RELEASES_PAGE": "릴리스 페이지 열기",
"APP_UPDATE_CHANNEL": "업데이트 채널", "APP_UPDATE_CHANNEL": "업데이트 채널",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다", "FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다",
"IMPORT_PROGRESS": "EPG 가져오기", "IMPORT_PROGRESS": "EPG 가져오기",
"IMPORT_PANEL_UNAVAILABLE": "EPG 가져오기 패널을 불러올 수 없습니다. 가져오기 진행 상황을 보려면 앱을 다시 시작하세요. 각 소스의 상태는 EPG 설정에서 확인할 수 있습니다.",
"CHANNELS": "채널", "CHANNELS": "채널",
"PROGRAMS": "프로그램", "PROGRAMS": "프로그램",
"LOADING": "불러오는 중", "LOADING": "불러오는 중",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker 재생목록", "STALKER_PLAYLISTS": "Stalker 재생목록",
"ALL_PLAYLISTS": "모든 재생목록", "ALL_PLAYLISTS": "모든 재생목록",
"PLAYLIST_COUNT_ONE": "1개 재생목록", "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": { "CONTEXT": {
"CATEGORIES": "카테고리", "CATEGORIES": "카테고리",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "명령 팔레트 선택 이동", "COMMAND_PALETTE_NAVIGATION": "명령 팔레트 선택 이동",
"COMMAND_PALETTE_RUN": "선택한 명령 팔레트 항목 실행", "COMMAND_PALETTE_RUN": "선택한 명령 팔레트 항목 실행",
"CLOSE_DIALOGS": "대화상자 닫기 또는 오버레이 해제", "CLOSE_DIALOGS": "대화상자 닫기 또는 오버레이 해제",
"DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기" "DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기",
"OPEN_PLAYLIST_SWITCHER": "재생목록 전환"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "플레이리스트를 추가하면 시청 기록이 여기에 표시됩니다.", "NO_PLAYLISTS_BODY": "플레이리스트를 추가하면 시청 기록이 여기에 표시됩니다.",
"NO_ITEMS_TITLE": "아직 시청 기록이 없습니다", "NO_ITEMS_TITLE": "아직 시청 기록이 없습니다",
"NO_ITEMS_BODY": "라이브러리의 콘텐츠를 시청하기 시작하면 여기에 표시됩니다." "NO_ITEMS_BODY": "라이브러리의 콘텐츠를 시청하기 시작하면 여기에 표시됩니다."
},
"SWITCHER": {
"SEARCH": "재생목록 찾기…",
"RECENT": "최근",
"NO_MATCHES": "찾은 항목 없음",
"ALL_SOURCES": "모든 소스",
"HINT_SELECT": "선택",
"HINT_OPEN": "열기",
"HINT_CLOSE": "닫기"
} }
}, },
"LOADING": "로드 중", "LOADING": "로드 중",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Naam (Z-A)", "NAME_DESC": "Naam (Z-A)",
"NEWEST": "Datum toegevoegd (Nieuwste eerst)", "NEWEST": "Datum toegevoegd (Nieuwste eerst)",
"OLDEST": "Datum toegevoegd (Oudste 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": { "PLAYLIST_TYPES": {
"M3U": "M3U (lokaal, URL, tekst)", "M3U": "M3U (lokaal, URL, tekst)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Nieuwere versie", "APP_UPDATE_RELEASE_NOTES_NEWER": "Nieuwere versie",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Er zijn geen releasenotes voor {{version}} gevonden op GitHub. De release is mogelijk verwijderd of nog niet gepubliceerd.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Er zijn geen releasenotes voor {{version}} gevonden op GitHub. De release is mogelijk verwijderd of nog niet gepubliceerd.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "De releasenotes konden niet worden geladen.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "De releasenotes konden niet worden geladen.",
"APP_UPDATE_PANEL_UNAVAILABLE": "De updatemelding kon niet worden getoond. Controleer op updates in de app-instellingen.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Releasenotes zijn niet beschikbaar in deze build.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Releasenotes zijn niet beschikbaar in deze build.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Releasepagina openen", "APP_UPDATE_OPEN_RELEASES_PAGE": "Releasepagina openen",
"APP_UPDATE_CHANNEL": "Updatekanaal", "APP_UPDATE_CHANNEL": "Updatekanaal",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "TV-Gids bijgewerkt", "FETCH_SUCCESS": "TV-Gids bijgewerkt",
"IMPORT_PROGRESS": "EPG-import", "IMPORT_PROGRESS": "EPG-import",
"IMPORT_PANEL_UNAVAILABLE": "Het EPG-importpaneel kon niet worden geladen. Herstart de app om de importvoortgang te zien; de status van elke bron blijft in de EPG-instellingen.",
"CHANNELS": "kanalen", "CHANNELS": "kanalen",
"PROGRAMS": "programma's", "PROGRAMS": "programma's",
"LOADING": "Laden", "LOADING": "Laden",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker-afspeellijsten", "STALKER_PLAYLISTS": "Stalker-afspeellijsten",
"ALL_PLAYLISTS": "Alle afspeellijsten", "ALL_PLAYLISTS": "Alle afspeellijsten",
"PLAYLIST_COUNT_ONE": "1 afspeellijst", "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": { "CONTEXT": {
"CATEGORIES": "Categorieën", "CATEGORIES": "Categorieën",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Selectie in opdrachtpalet verplaatsen", "COMMAND_PALETTE_NAVIGATION": "Selectie in opdrachtpalet verplaatsen",
"COMMAND_PALETTE_RUN": "Geselecteerd item in opdrachtpalet uitvoeren", "COMMAND_PALETTE_RUN": "Geselecteerd item in opdrachtpalet uitvoeren",
"CLOSE_DIALOGS": "Dialoogvenster sluiten of overlay verbergen", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Voeg een afspeellijst toe en uw kijkgeschiedenis verschijnt hier.", "NO_PLAYLISTS_BODY": "Voeg een afspeellijst toe en uw kijkgeschiedenis verschijnt hier.",
"NO_ITEMS_TITLE": "Nog niets bekeken", "NO_ITEMS_TITLE": "Nog niets bekeken",
"NO_ITEMS_BODY": "Begin iets uit uw bibliotheek te kijken en het verschijnt hier." "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", "LOADING": "Laden",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nazwa (Z-A)", "NAME_DESC": "Nazwa (Z-A)",
"NEWEST": "Data dodania (najnowsze pierwsze)", "NEWEST": "Data dodania (najnowsze pierwsze)",
"OLDEST": "Data dodania (najstarsze 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": { "PLAYLIST_TYPES": {
"M3U": "M3U (lokalny, URL, tekst)", "M3U": "M3U (lokalny, URL, tekst)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Nowsze wydanie", "APP_UPDATE_RELEASE_NOTES_NEWER": "Nowsze wydanie",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nie znaleziono informacji o wydaniu {{version}} w GitHub. Wydanie mogło zostać usunięte lub nie zostało jeszcze opublikowane.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nie znaleziono informacji o wydaniu {{version}} w GitHub. Wydanie mogło zostać usunięte lub nie zostało jeszcze opublikowane.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Nie udało się wczytać informacji o wydaniu.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Nie udało się wczytać informacji o wydaniu.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Nie udało się wyświetlić powiadomienia o aktualizacji. Sprawdź dostępność aktualizacji w ustawieniach aplikacji.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Informacje o wydaniu nie są dostępne w tej kompilacji.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Informacje o wydaniu nie są dostępne w tej kompilacji.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Otwórz stronę wydań", "APP_UPDATE_OPEN_RELEASES_PAGE": "Otwórz stronę wydań",
"APP_UPDATE_CHANNEL": "Kanał aktualizacji", "APP_UPDATE_CHANNEL": "Kanał aktualizacji",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG zostało pomyślnie pobrane", "FETCH_SUCCESS": "EPG zostało pomyślnie pobrane",
"IMPORT_PROGRESS": "Import EPG", "IMPORT_PROGRESS": "Import EPG",
"IMPORT_PANEL_UNAVAILABLE": "Nie udało się wczytać panelu importu EPG. Uruchom aplikację ponownie, aby śledzić import; stan każdego źródła pozostaje w ustawieniach EPG.",
"CHANNELS": "kanałów", "CHANNELS": "kanałów",
"PROGRAMS": "programów", "PROGRAMS": "programów",
"LOADING": "Ładowanie", "LOADING": "Ładowanie",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Listy odtwarzania Stalker", "STALKER_PLAYLISTS": "Listy odtwarzania Stalker",
"ALL_PLAYLISTS": "Wszystkie listy odtwarzania", "ALL_PLAYLISTS": "Wszystkie listy odtwarzania",
"PLAYLIST_COUNT_ONE": "1 lista 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": { "CONTEXT": {
"CATEGORIES": "Kategorie", "CATEGORIES": "Kategorie",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Przenieś zaznaczenie w palecie poleceń", "COMMAND_PALETTE_NAVIGATION": "Przenieś zaznaczenie w palecie poleceń",
"COMMAND_PALETTE_RUN": "Uruchom zaznaczoną pozycję palety poleceń", "COMMAND_PALETTE_RUN": "Uruchom zaznaczoną pozycję palety poleceń",
"CLOSE_DIALOGS": "Zamknij okno dialogowe lub ukryj nakładkę", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Dodaj listę odtwarzania, a historia oglądania pojawi się tutaj.", "NO_PLAYLISTS_BODY": "Dodaj listę odtwarzania, a historia oglądania pojawi się tutaj.",
"NO_ITEMS_TITLE": "Jeszcze nic nie oglądano", "NO_ITEMS_TITLE": "Jeszcze nic nie oglądano",
"NO_ITEMS_BODY": "Zacznij oglądać cokolwiek ze swojej biblioteki, a pojawi się tutaj." "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", "LOADING": "Ładowanie",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nome (Z-A)", "NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data de adição (Mais recente primeiro)", "NEWEST": "Data de adição (Mais recente primeiro)",
"OLDEST": "Data de adição (Mais antigo 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": { "PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)", "M3U": "M3U (local, url, texto)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versão mais recente", "APP_UPDATE_RELEASE_NOTES_NEWER": "Versão mais recente",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nenhuma nota de versão foi encontrada para {{version}} no GitHub. A versão pode ter sido removida ou ainda não foi publicada.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nenhuma nota de versão foi encontrada para {{version}} no GitHub. A versão pode ter sido removida ou ainda não foi publicada.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Não foi possível carregar as notas da versão.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Não foi possível carregar as notas da versão.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Não foi possível mostrar a notificação de atualização. Procure atualizações nas definições da aplicação.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "As notas da versão não estão disponíveis nesta compilação.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "As notas da versão não estão disponíveis nesta compilação.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Abrir página de versões", "APP_UPDATE_OPEN_RELEASES_PAGE": "Abrir página de versões",
"APP_UPDATE_CHANNEL": "Canal de atualização", "APP_UPDATE_CHANNEL": "Canal de atualização",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG foi buscado com sucesso", "FETCH_SUCCESS": "EPG foi buscado com sucesso",
"IMPORT_PROGRESS": "Importação de EPG", "IMPORT_PROGRESS": "Importação de EPG",
"IMPORT_PANEL_UNAVAILABLE": "Não foi possível carregar o painel de importação do EPG. Reinicie a aplicação para acompanhar a importação; o estado de cada fonte continua nas definições de EPG.",
"CHANNELS": "canais", "CHANNELS": "canais",
"PROGRAMS": "programas", "PROGRAMS": "programas",
"LOADING": "Carregando", "LOADING": "Carregando",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Playlists Stalker", "STALKER_PLAYLISTS": "Playlists Stalker",
"ALL_PLAYLISTS": "Todas as playlists", "ALL_PLAYLISTS": "Todas as playlists",
"PLAYLIST_COUNT_ONE": "1 playlist", "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": { "CONTEXT": {
"CATEGORIES": "Categorias", "CATEGORIES": "Categorias",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Mover seleção da paleta de comandos", "COMMAND_PALETTE_NAVIGATION": "Mover seleção da paleta de comandos",
"COMMAND_PALETTE_RUN": "Executar item selecionado da paleta de comandos", "COMMAND_PALETTE_RUN": "Executar item selecionado da paleta de comandos",
"CLOSE_DIALOGS": "Fechar diálogo ou dispensar sobreposição", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Adicione uma playlist e seu histórico de visualização aparecerá aqui.", "NO_PLAYLISTS_BODY": "Adicione uma playlist e seu histórico de visualização aparecerá aqui.",
"NO_ITEMS_TITLE": "Nada assistido ainda", "NO_ITEMS_TITLE": "Nada assistido ainda",
"NO_ITEMS_BODY": "Comece a assistir qualquer item da sua biblioteca e ele aparecerá aqui." "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", "LOADING": "A carregar",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Имени (Я-А)", "NAME_DESC": "Имени (Я-А)",
"NEWEST": "Дате добавления (сначала новые)", "NEWEST": "Дате добавления (сначала новые)",
"OLDEST": "Дате добавления (сначала старые)", "OLDEST": "Дате добавления (сначала старые)",
"CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)" "CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)",
"UPDATED": "Последнему обновлению",
"CHANNELS": "Числу каналов",
"FAILING_FIRST": "Сначала неактивные"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U (локально, URL, текст)", "M3U": "M3U (локально, URL, текст)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Более поздняя версия", "APP_UPDATE_RELEASE_NOTES_NEWER": "Более поздняя версия",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заметки к версии {{version}} на GitHub не найдены. Релиз мог быть удалён или ещё не опубликован.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заметки к версии {{version}} на GitHub не найдены. Релиз мог быть удалён или ещё не опубликован.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не удалось загрузить заметки к версии.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не удалось загрузить заметки к версии.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Не удалось показать уведомление об обновлении. Проверьте наличие обновлений в настройках приложения.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заметки к версии недоступны в этой сборке.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заметки к версии недоступны в этой сборке.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Открыть страницу релизов", "APP_UPDATE_OPEN_RELEASES_PAGE": "Открыть страницу релизов",
"APP_UPDATE_CHANNEL": "Канал обновлений", "APP_UPDATE_CHANNEL": "Канал обновлений",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена", "FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена",
"IMPORT_PROGRESS": "Импорт EPG", "IMPORT_PROGRESS": "Импорт EPG",
"IMPORT_PANEL_UNAVAILABLE": "Не удалось загрузить панель импорта EPG. Перезапустите приложение, чтобы видеть ход импорта; статус каждого источника остаётся в настройках EPG.",
"CHANNELS": "каналов", "CHANNELS": "каналов",
"PROGRAMS": "программ", "PROGRAMS": "программ",
"LOADING": "Загрузка", "LOADING": "Загрузка",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker плейлисты", "STALKER_PLAYLISTS": "Stalker плейлисты",
"ALL_PLAYLISTS": "Все плейлисты", "ALL_PLAYLISTS": "Все плейлисты",
"PLAYLIST_COUNT_ONE": "1 плейлист", "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": { "CONTEXT": {
"CATEGORIES": "Категории", "CATEGORIES": "Категории",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Переместить выбор в палитре команд", "COMMAND_PALETTE_NAVIGATION": "Переместить выбор в палитре команд",
"COMMAND_PALETTE_RUN": "Выполнить выбранный пункт палитры команд", "COMMAND_PALETTE_RUN": "Выполнить выбранный пункт палитры команд",
"CLOSE_DIALOGS": "Закрыть диалог или скрыть оверлей", "CLOSE_DIALOGS": "Закрыть диалог или скрыть оверлей",
"DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке" "DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке",
"OPEN_PLAYLIST_SWITCHER": "Переключить плейлист"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Добавьте плейлист, и история просмотра появится здесь.", "NO_PLAYLISTS_BODY": "Добавьте плейлист, и история просмотра появится здесь.",
"NO_ITEMS_TITLE": "Пока ничего не просмотрено", "NO_ITEMS_TITLE": "Пока ничего не просмотрено",
"NO_ITEMS_BODY": "Начните смотреть любой контент из библиотеки, и он появится здесь." "NO_ITEMS_BODY": "Начните смотреть любой контент из библиотеки, и он появится здесь."
},
"SWITCHER": {
"SEARCH": "Найти плейлист…",
"RECENT": "Недавние",
"NO_MATCHES": "Ничего не найдено",
"ALL_SOURCES": "Все источники",
"HINT_SELECT": "выбор",
"HINT_OPEN": "открыть",
"HINT_CLOSE": "закрыть"
} }
}, },
"LOADING": "Загрузка", "LOADING": "Загрузка",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "İsim (Z-A)", "NAME_DESC": "İsim (Z-A)",
"NEWEST": "Eklenme tarihi (En yeni önce)", "NEWEST": "Eklenme tarihi (En yeni önce)",
"OLDEST": "Eklenme tarihi (En eski ö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": { "PLAYLIST_TYPES": {
"M3U": "M3U (yerel, url, metin)", "M3U": "M3U (yerel, url, metin)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Daha yeni sürüm", "APP_UPDATE_RELEASE_NOTES_NEWER": "Daha yeni sürüm",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub'da {{version}} için sürüm notu bulunamadı. Sürüm kaldırılmış ya da henüz yayınlanmamış olabilir.", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub'da {{version}} için sürüm notu bulunamadı. Sürüm kaldırılmış ya da henüz yayınlanmamış olabilir.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Sürüm notları yüklenemedi.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Sürüm notları yüklenemedi.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Güncelleme bildirimi gösterilemedi. Güncellemeleri uygulama ayarlarından kontrol edin.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Sürüm notları bu derlemede kullanılamıyor.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Sürüm notları bu derlemede kullanılamıyor.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Sürümler sayfasını aç", "APP_UPDATE_OPEN_RELEASES_PAGE": "Sürümler sayfasını aç",
"APP_UPDATE_CHANNEL": "Güncelleme kanalı", "APP_UPDATE_CHANNEL": "Güncelleme kanalı",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG verileri başarıyla alındı", "FETCH_SUCCESS": "EPG verileri başarıyla alındı",
"IMPORT_PROGRESS": "EPG İçe Aktarımı", "IMPORT_PROGRESS": "EPG İçe Aktarımı",
"IMPORT_PANEL_UNAVAILABLE": "EPG içe aktarma paneli yüklenemedi. İçe aktarma ilerlemesini görmek için uygulamayı yeniden başlatın; her kaynağın durumu EPG ayarlarında kalır.",
"CHANNELS": "kanallar", "CHANNELS": "kanallar",
"PROGRAMS": "programlar", "PROGRAMS": "programlar",
"LOADING": "Yükleniyor", "LOADING": "Yükleniyor",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker Oynatma Listeleri", "STALKER_PLAYLISTS": "Stalker Oynatma Listeleri",
"ALL_PLAYLISTS": "Tüm Oynatma Listeleri", "ALL_PLAYLISTS": "Tüm Oynatma Listeleri",
"PLAYLIST_COUNT_ONE": "1 oynatma listesi", "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": { "CONTEXT": {
"CATEGORIES": "Kategoriler", "CATEGORIES": "Kategoriler",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "Komut paleti seçimini taşı", "COMMAND_PALETTE_NAVIGATION": "Komut paleti seçimini taşı",
"COMMAND_PALETTE_RUN": "Seçili komut paleti öğesini çalıştır", "COMMAND_PALETTE_RUN": "Seçili komut paleti öğesini çalıştır",
"CLOSE_DIALOGS": "İletişim kutusunu kapat veya katmanı gizle", "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": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "Bir oynatma listesi ekleyin; izleme geçmişiniz burada görünecek.", "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_TITLE": "Henüz hiçbir şey izlenmedi",
"NO_ITEMS_BODY": "Kitaplığınızdaki herhangi bir şeyi izlemeye başlayın, burada görünecek." "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", "LOADING": "Yükleniyor",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名称(Z-A)", "NAME_DESC": "名称(Z-A)",
"NEWEST": "添加日期(最新优先)", "NEWEST": "添加日期(最新优先)",
"OLDEST": "添加日期(最旧优先)", "OLDEST": "添加日期(最旧优先)",
"CUSTOM_ORDER": "自定义顺序(拖放)" "CUSTOM_ORDER": "自定义顺序(拖放)",
"UPDATED": "最近更新",
"CHANNELS": "频道数量",
"FAILING_FIRST": "无响应的优先"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U(本地、URL、文本)", "M3U": "M3U(本地、URL、文本)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "较新版本", "APP_UPDATE_RELEASE_NOTES_NEWER": "较新版本",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上未找到 {{version}} 的更新说明。该版本可能已被移除或尚未发布。", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上未找到 {{version}} 的更新说明。该版本可能已被移除或尚未发布。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "无法加载更新说明。", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "无法加载更新说明。",
"APP_UPDATE_PANEL_UNAVAILABLE": "无法显示更新通知。请在应用设置中检查更新。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此构建中没有可用的更新说明。", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此构建中没有可用的更新说明。",
"APP_UPDATE_OPEN_RELEASES_PAGE": "打开版本发布页面", "APP_UPDATE_OPEN_RELEASES_PAGE": "打开版本发布页面",
"APP_UPDATE_CHANNEL": "更新频道", "APP_UPDATE_CHANNEL": "更新频道",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG 获取成功", "FETCH_SUCCESS": "EPG 获取成功",
"IMPORT_PROGRESS": "EPG 导入", "IMPORT_PROGRESS": "EPG 导入",
"IMPORT_PANEL_UNAVAILABLE": "无法加载 EPG 导入面板。请重启应用以查看导入进度;每个来源的状态仍可在 EPG 设置中查看。",
"CHANNELS": "频道", "CHANNELS": "频道",
"PROGRAMS": "节目", "PROGRAMS": "节目",
"LOADING": "加载中", "LOADING": "加载中",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker 播放列表", "STALKER_PLAYLISTS": "Stalker 播放列表",
"ALL_PLAYLISTS": "所有播放列表", "ALL_PLAYLISTS": "所有播放列表",
"PLAYLIST_COUNT_ONE": "1 个播放列表", "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": { "CONTEXT": {
"CATEGORIES": "分类", "CATEGORIES": "分类",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "移动命令面板选中项", "COMMAND_PALETTE_NAVIGATION": "移动命令面板选中项",
"COMMAND_PALETTE_RUN": "运行选中的命令面板项目", "COMMAND_PALETTE_RUN": "运行选中的命令面板项目",
"CLOSE_DIALOGS": "关闭对话框或取消覆盖层", "CLOSE_DIALOGS": "关闭对话框或取消覆盖层",
"DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项" "DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项",
"OPEN_PLAYLIST_SWITCHER": "切换播放列表"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "添加播放列表后,您的观看历史会显示在这里。", "NO_PLAYLISTS_BODY": "添加播放列表后,您的观看历史会显示在这里。",
"NO_ITEMS_TITLE": "还没有观看记录", "NO_ITEMS_TITLE": "还没有观看记录",
"NO_ITEMS_BODY": "开始观看媒体库中的任意内容后,它会显示在这里。" "NO_ITEMS_BODY": "开始观看媒体库中的任意内容后,它会显示在这里。"
},
"SWITCHER": {
"SEARCH": "查找播放列表…",
"RECENT": "最近",
"NO_MATCHES": "未找到",
"ALL_SOURCES": "所有来源",
"HINT_SELECT": "选择",
"HINT_OPEN": "打开",
"HINT_CLOSE": "关闭"
} }
}, },
"LOADING": "加载中", "LOADING": "加载中",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名稱(Z-A)", "NAME_DESC": "名稱(Z-A)",
"NEWEST": "新增日期(最新優先)", "NEWEST": "新增日期(最新優先)",
"OLDEST": "新增日期(最舊優先)", "OLDEST": "新增日期(最舊優先)",
"CUSTOM_ORDER": "自訂順序(拖放)" "CUSTOM_ORDER": "自訂順序(拖放)",
"UPDATED": "最近更新",
"CHANNELS": "頻道數量",
"FAILING_FIRST": "無回應的優先"
}, },
"PLAYLIST_TYPES": { "PLAYLIST_TYPES": {
"M3U": "M3U(本機、網址、文字)", "M3U": "M3U(本機、網址、文字)",
@@ -589,6 +592,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "較新版本", "APP_UPDATE_RELEASE_NOTES_NEWER": "較新版本",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上找不到 {{version}} 的更新說明。該版本可能已被移除或尚未發布。", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上找不到 {{version}} 的更新說明。該版本可能已被移除或尚未發布。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "無法載入更新說明。", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "無法載入更新說明。",
"APP_UPDATE_PANEL_UNAVAILABLE": "無法顯示更新通知。請在應用程式設定中檢查更新。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此版本未提供更新說明。", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此版本未提供更新說明。",
"APP_UPDATE_OPEN_RELEASES_PAGE": "開啟版本發布頁面", "APP_UPDATE_OPEN_RELEASES_PAGE": "開啟版本發布頁面",
"APP_UPDATE_CHANNEL": "更新頻道", "APP_UPDATE_CHANNEL": "更新頻道",
@@ -980,6 +984,7 @@
}, },
"FETCH_SUCCESS": "EPG 已成功擷取", "FETCH_SUCCESS": "EPG 已成功擷取",
"IMPORT_PROGRESS": "EPG 匯入", "IMPORT_PROGRESS": "EPG 匯入",
"IMPORT_PANEL_UNAVAILABLE": "無法載入 EPG 匯入面板。請重新啟動應用程式以查看匯入進度;每個來源的狀態仍可在 EPG 設定中查看。",
"CHANNELS": "頻道", "CHANNELS": "頻道",
"PROGRAMS": "節目", "PROGRAMS": "節目",
"LOADING": "載入中", "LOADING": "載入中",
@@ -1904,7 +1909,29 @@
"STALKER_PLAYLISTS": "Stalker 播放清單", "STALKER_PLAYLISTS": "Stalker 播放清單",
"ALL_PLAYLISTS": "所有播放清單", "ALL_PLAYLISTS": "所有播放清單",
"PLAYLIST_COUNT_ONE": "1 個播放清單", "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": { "CONTEXT": {
"CATEGORIES": "類別", "CATEGORIES": "類別",
@@ -1973,7 +2000,8 @@
"COMMAND_PALETTE_NAVIGATION": "移動命令面板選取項", "COMMAND_PALETTE_NAVIGATION": "移動命令面板選取項",
"COMMAND_PALETTE_RUN": "執行選取的命令面板項目", "COMMAND_PALETTE_RUN": "執行選取的命令面板項目",
"CLOSE_DIALOGS": "關閉對話框或取消覆蓋層", "CLOSE_DIALOGS": "關閉對話框或取消覆蓋層",
"DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目" "DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目",
"OPEN_PLAYLIST_SWITCHER": "切換播放清單"
} }
}, },
"GLOBAL_FAVORITES": { "GLOBAL_FAVORITES": {
@@ -1996,6 +2024,15 @@
"NO_PLAYLISTS_BODY": "新增播放清單後,您的觀看歷史會顯示在這裡。", "NO_PLAYLISTS_BODY": "新增播放清單後,您的觀看歷史會顯示在這裡。",
"NO_ITEMS_TITLE": "還沒有觀看記錄", "NO_ITEMS_TITLE": "還沒有觀看記錄",
"NO_ITEMS_BODY": "開始觀看媒體庫中的任意內容後,它會顯示在這裡。" "NO_ITEMS_BODY": "開始觀看媒體庫中的任意內容後,它會顯示在這裡。"
},
"SWITCHER": {
"SEARCH": "尋找播放清單…",
"RECENT": "最近",
"NO_MATCHES": "找不到",
"ALL_SOURCES": "所有來源",
"HINT_SELECT": "選擇",
"HINT_OPEN": "開啟",
"HINT_CLOSE": "關閉"
} }
}, },
"LOADING": "載入中", "LOADING": "載入中",
@@ -9,11 +9,11 @@ import { ApplicationRef } from '@angular/core';
* *
* Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode * Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode
* `window.ng` global, which the optimized build does not publish. Every * `window.ng` global, which the optimized build does not publish. Every
* tick, whether scheduled by zone.js (`onMicrotaskEmpty`), the zoneless * tick, whether scheduled by the zoneless scheduler, an `afterNextRender`
* scheduler or an explicit `ApplicationRef.tick()`, goes through the * hook or an explicit `ApplicationRef.tick()`, goes through the internal
* internal `ApplicationRef._tick`, which is also where Angular emits the * `ApplicationRef._tick`, which is also where Angular emits the profiler's
* profiler's `ChangeDetectionStart`. Counting its calls therefore matches * `ChangeDetectionStart`. Counting its calls therefore matches the
* the profiler's count and stays comparable across the zoneless migration. * profiler's count; it did so under zone.js too, before the zoneless flip.
* Contract: docs/architecture/performance-journeys.md. * Contract: docs/architecture/performance-journeys.md.
*/ */
export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks'; export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks';
@@ -1,12 +0,0 @@
import {
EnvironmentProviders,
provideZoneChangeDetection,
} from '@angular/core';
// Change detection for every build: zone.js schedules the ticks. The
// *-zoneless build configurations replace this file with
// change-detection.providers.zoneless.ts while plan item C6 measures
// zoneless change detection; see docs/architecture/zoneless-migration.md.
export const changeDetectionProviders: EnvironmentProviders[] = [
provideZoneChangeDetection({ eventCoalescing: true }),
];
@@ -1,11 +0,0 @@
import {
EnvironmentProviders,
provideZonelessChangeDetection,
} from '@angular/core';
// Swapped in for change-detection.providers.ts by the *-zoneless build
// configurations only. zone.js stays in the polyfills until the flip, so
// Angular logs NG0914 in these builds; nothing patches through it.
export const changeDetectionProviders: EnvironmentProviders[] = [
provideZonelessChangeDetection(),
];
@@ -1,8 +1,8 @@
import { EnvironmentProviders } from '@angular/core'; import { EnvironmentProviders } from '@angular/core';
import { provideStoreDevtools } from '@ngrx/store-devtools'; import { provideStoreDevtools } from '@ngrx/store-devtools';
// Swapped in for store-devtools.providers.ts by the development, // Swapped in for store-devtools.providers.ts by the development and
// electron-e2e and electron-e2e-zoneless build configurations only. // electron-e2e build configurations only.
export const storeDevtoolsProviders: EnvironmentProviders[] = [ export const storeDevtoolsProviders: EnvironmentProviders[] = [
provideStoreDevtools({ maxAge: 25 }), provideStoreDevtools({ maxAge: 25 }),
]; ];
+176 -27
View File
@@ -1,8 +1,10 @@
@use 'sass:map'; @use 'sass:map';
@use 'sass:meta';
@use '@angular/material' as mat; @use '@angular/material' as mat;
@use 'styles'; @use 'styles';
@include mat.elevation-classes(); // No `mat.elevation-classes()`: nothing in the app uses the `.mat-elevation-z*`
// utilities, and the 25 rules cost 4.4 KB of the initial stylesheet.
@include mat.app-background(); @include mat.app-background();
// ─── Typography ───────────────────────────────────────────────────────────── // ─── Typography ─────────────────────────────────────────────────────────────
@@ -57,9 +59,60 @@ $dark-theme: mat.define-theme(
) )
); );
// ─── Themed components ───────────────────────────────────────────────────────
// Only the Material components the app imports, in Material's own order.
// `mat.all-component-themes` also emitted about 24 KB of tokens for 17
// components nothing imports. 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, 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,
progress-bar,
tooltip,
form-field,
input,
select,
dialog,
slide-toggle,
slider,
menu,
list,
checkbox,
button,
icon-button,
snack-bar,
progress-spinner,
button-toggle,
divider,
icon
);
@mixin component-themes($theme) {
@each $component in $material-components {
@include meta.apply(
meta.get-mixin('#{$component}-theme', 'mat'),
$theme
);
}
}
@mixin component-colors($theme) {
@each $component in $material-components {
@include meta.apply(
meta.get-mixin('#{$component}-color', 'mat'),
$theme
);
}
}
// ─── Apply Themes ──────────────────────────────────────────────────────────── // ─── Apply Themes ────────────────────────────────────────────────────────────
html { html {
@include mat.all-component-themes($light-theme); @include component-themes($light-theme);
// The theme is built with the legacy `define-theme` config, whose // The theme is built with the legacy `define-theme` config, whose
// component mixins emit resolved component tokens but never declare the // component mixins emit resolved component tokens but never declare the
@@ -169,8 +222,25 @@ html {
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1). // 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
--app-rating-color: #7a4a00; --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 { .dark-theme {
@include mat.all-component-colors($dark-theme); @include component-colors($dark-theme);
// Only the color roles differ per theme: the elevation shadows are // Only the color roles differ per theme: the elevation shadows are
// the same black in both, so html's declarations already apply here. // the same black in both, so html's declarations already apply here.
@include mat.system-level-colors($dark-theme); @include mat.system-level-colors($dark-theme);
@@ -274,13 +344,27 @@ html {
--app-cta-hover-bg: #ffffff; --app-cta-hover-bg: #ffffff;
--app-cta-meta-fg: #5a6274; --app-cta-meta-fg: #5a6274;
--app-rating-color: #ffcf5c; --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);
} }
} }
// ─── Global refined outlined inputs ────────────────────────────────────────── // ─── Global refined outlined inputs ──────────────────────────────────────────
// Applied app-wide AFTER mat.all-component-themes so these overrides win the // Applied app-wide AFTER the component themes so these overrides win the
// cascade. Pairs with MAT_FORM_FIELD_DEFAULT_OPTIONS (outline + dynamic) set // cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS
// in app.config.ts. // that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each
// component rendering a form field.
$app-refined-input-density: mat.define-theme(( $app-refined-input-density: mat.define-theme((
density: (scale: -2), density: (scale: -2),
)); ));
@@ -324,39 +408,104 @@ html {
font-size: 13.5px; font-size: 13.5px;
} }
// A select stays inside a form; its option panel follows the kit menu:
// a bordered 10px panel with 6px padding, 36px rows at 13px and the
// chosen row on the list-selection tokens. The overrides sit on the
// panel, which renders in the overlay container inside the theme
// context, so the `--app-*` values resolve per theme. The selector is
// doubled because Material sets the panel's padding and attached
// corners from `div.mat-mdc-select-panel` and
// `.mat-mdc-select-panel-above div.mat-mdc-select-panel`, injected after
// the global stylesheet.
div.mat-mdc-select-panel.mat-mdc-select-panel {
@include mat.select-overrides(
(
panel-background-color: var(--app-widget-bg),
container-elevation-shadow: 0 18px 40px rgba(0, 0, 0, 0.35),
)
);
@include mat.option-overrides(
(
label-text-size: 13px,
label-text-color: var(--app-heading-color),
selected-state-label-text-color: var(--app-list-selected-fg),
selected-state-layer-color: var(--app-list-selected-bg),
hover-state-layer-color:
color-mix(in srgb, var(--app-on-surface) 8%, transparent),
focus-state-layer-color:
color-mix(in srgb, var(--app-on-surface) 10%, transparent),
)
);
padding: 6px;
border: 1px solid var(--app-line-strong);
border-radius: 10px;
.mat-mdc-option {
min-height: 36px;
padding: 0 10px;
border-radius: 6px;
}
}
// ─── Destructive actions ─────────────────────────────────────────────── // ─── Destructive actions ───────────────────────────────────────────────
// Material only emits `.mat-warn` button colors for M2 themes, so the // Material only emits `.mat-warn` button colors for M2 themes, so the
// `warn` color input is a no-op with this M3 theme. Buttons that remove or // `warn` color input is a no-op with this M3 theme. Buttons that remove or
// discard user data opt in with this class instead; the error tokens are // discard user data opt in with this class instead. The kit draws them as
// declared per theme context above, so light and dark each get their own // a red tint (`--app-danger-*`, declared per theme context above), never
// error/on-error pair. // a solid error fill: hover changes the fill only, the ripple is off and
// a disabled button is the same button at 40% opacity.
.app-destructive-button { .app-destructive-button {
@include mat.button-overrides( @include mat.button-overrides(
( (
filled-container-color: var(--mat-sys-error), filled-container-color: var(--app-danger-surface),
filled-label-text-color: var(--mat-sys-on-error), filled-disabled-container-color: var(--app-danger-surface),
filled-state-layer-color: var(--mat-sys-on-error), filled-label-text-color: var(--app-danger-text),
filled-ripple-color: filled-disabled-label-text-color: var(--app-danger-text),
color-mix(in srgb, var(--mat-sys-on-error) 12%, transparent), filled-state-layer-color: var(--app-danger-text),
text-label-text-color: var(--mat-sys-error), filled-hover-state-layer-opacity: 0.08,
text-state-layer-color: var(--mat-sys-error), filled-focus-state-layer-opacity: 0.08,
text-ripple-color: filled-pressed-state-layer-opacity: 0.12,
color-mix(in srgb, var(--mat-sys-error) 12%, transparent), filled-ripple-color: transparent,
outlined-label-text-color: var(--mat-sys-error), text-label-text-color: var(--app-danger-text),
outlined-outline-color: var(--mat-sys-error), text-disabled-label-text-color: var(--app-danger-text),
outlined-state-layer-color: var(--mat-sys-error), text-state-layer-color: var(--app-danger-text),
outlined-ripple-color: text-hover-state-layer-opacity: 0.08,
color-mix(in srgb, var(--mat-sys-error) 12%, transparent), text-focus-state-layer-opacity: 0.08,
text-pressed-state-layer-opacity: 0.12,
text-ripple-color: transparent,
outlined-label-text-color: var(--app-danger-text),
outlined-disabled-label-text-color: var(--app-danger-text),
outlined-outline-color: transparent,
outlined-disabled-outline-color: transparent,
outlined-state-layer-color: var(--app-danger-text),
outlined-hover-state-layer-opacity: 0.08,
outlined-focus-state-layer-opacity: 0.08,
outlined-pressed-state-layer-opacity: 0.12,
outlined-ripple-color: transparent,
) )
); );
@include mat.icon-button-overrides( @include mat.icon-button-overrides(
( (
icon-color: var(--mat-sys-error), icon-color: var(--app-danger-text),
state-layer-color: var(--mat-sys-error), disabled-icon-color: var(--app-danger-text),
ripple-color: state-layer-color: var(--app-danger-text),
color-mix(in srgb, var(--mat-sys-error) 12%, transparent), hover-state-layer-opacity: 0.08,
focus-state-layer-opacity: 0.08,
pressed-state-layer-opacity: 0.12,
ripple-color: transparent,
) )
); );
// The kit has no outlined buttons: an outlined destructive button
// carries the same tint as a filled one.
&.mat-mdc-outlined-button {
background-color: var(--app-danger-surface);
}
&[disabled],
&.mat-mdc-button-disabled {
opacity: 0.4;
}
} }
.mat-mdc-form-field-subscript-wrapper { .mat-mdc-form-field-subscript-wrapper {
-1
View File
@@ -1 +0,0 @@
import 'zone.js';
-52
View File
@@ -1,52 +0,0 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags.ts';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
-1
View File
@@ -1 +0,0 @@
import 'jest-preset-angular/setup-jest';
+11 -4
View File
@@ -1,6 +1,7 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
@use '../../../libs/ui/components/src/lib/resizable/resizable.scss'; @use '../../../libs/ui/components/src/lib/resizable/resizable.scss';
@use '../../../libs/ui/styles/focus-ring'; @use '../../../libs/ui/styles/focus-ring';
@use '../../../libs/ui/styles/app-controls' as controls;
@use './settings-theme'; @use './settings-theme';
@use './cover-size'; @use './cover-size';
@@ -238,12 +239,18 @@ body.dark-theme {
overflow-x: hidden !important; overflow-x: hidden !important;
} }
.mat-drawer-inner-container { // Modal overlays blur the page behind their backdrop. The CDK gives a dialog
overflow: hidden !important; // the dark backdrop class by default; menus, selects and popovers open over
// `cdk-overlay-transparent-backdrop`, which sits at opacity 0 and stays a
// plain layer without a filter of its own.
.cdk-overlay-dark-backdrop {
backdrop-filter: blur(4px);
} }
.cdk-overlay-backdrop { // UI-kit menu panel: `<mat-menu class="app-menu">`. The panel renders in the
backdrop-filter: blur(4px); // overlay container, outside every component view, so the rule is global.
.mat-mdc-menu-panel.app-menu {
@include controls.menu-panel;
} }
// Command palette dialog — the palette-shell renders its own background, border, // Command palette dialog — the palette-shell renders its own background, border,
+248 -21
View File
@@ -22,6 +22,54 @@ Use it when changing existing views or introducing new list-based UI in the work
5. Scroll ownership must be explicit. 5. Scroll ownership must be explicit.
Headers stay visible. Lists scroll. Do not let nested panes compete for scroll. Headers stay visible. Lists scroll. Do not let nested panes compete for scroll.
## Angular Material's Role
Decision of 2026-10-12. The repository has no ADR folder; decisions live in
the subsystem's document.
**Decision.**
- Angular Material stays as the behaviour and accessibility layer: dialog,
menu, select (inside forms only), form field and input, snack-bar, tooltip,
slide-toggle, checkbox, progress and icon.
- The look comes from the IPTVnator design kit, applied through
`mat.*-overrides()` on the `--app-*` tokens in one place: the shared
partials under `libs/ui/styles/` (the control mixins live in
`_app-controls.scss`), not per-component `::ng-deep`.
- A kit component that Material lacks is built on CDK primitives (overlay,
a11y key managers), as the playlist switcher popover is.
- A plain action button may become a native `<button>` with the kit mixins.
- No wholesale replacement of Material. Re-evaluate Angular's headless
`@angular/aria` (developer preview as of v21; not installed) for menu,
listbox and combobox once it is stable.
**Why** (production build, `renderer.initialBytes` 1,597,763 bytes):
- Material's JavaScript is about 203 KB (12.7% of the initial bytes) and the
CDK's about 126 KB (7.9%). Any replacement keeps the CDK, and the CDK is
where the behaviour lives.
- In the global CSS the `--mat-*` tokens are 96 KB raw but about 11 KB
gzipped, and Material's own rules are 9 KB. 23.6 KB of the tokens belong to
17 components the app never imports, because `mat.all-component-themes`
emits every component.
- The startup cost came from a few eager imports (the form-field defaults
token in `app.config.ts`, the startup, update and EPG-progress components,
the confirm-dialog service), not from Material itself. Those are gone (see
Material off the startup path below): `renderer.initialBytes` went from
1,597,763 to 1,345,807 with Material still in place.
- Rewriting dialog focus trapping, menu keyboard handling, select listboxes
and snack-bar live regions would cost months and accessibility risk for no
visible gain.
**Consequences.**
- Material upgrades can change behaviour. Seen in v22: the menu panel has no
max-height; a submenu is offset by the parent's first direct
`mat-menu-item`, so a trigger that follows `role="group"` rows is wrapped
in `<div role="none">`; overlay panes are `position: static` inside the
bounding box.
- Keep E2E coverage on menus and dialogs.
## Canonical References ## Canonical References
- Channel row: - Channel row:
@@ -38,6 +86,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` `libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss`
- Shared list selection style: - Shared list selection style:
`libs/ui/styles/_nav-list.scss` `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: - Theme tokens:
`apps/web/src/m3-theme.scss` `apps/web/src/m3-theme.scss`
- Settings surfaces: - Settings surfaces:
@@ -67,6 +121,16 @@ in `apps/web/src/m3-theme.scss`):
- `--app-on-surface` — primary text - `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text - `--app-eyebrow-color` — secondary/muted text
- `--app-success-color` — done/watched status (episode watched toggle) - `--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,
destructive menu items and `.app-destructive-button`;
`--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 Angular Material mixins and Material-component overrides may use the tokens
owned by that component. Outside a Material-owned component, prefer the owned by that component. Outside a Material-owned component, prefer the
@@ -88,6 +152,58 @@ tokens, so the retired `--mdc-*` names compile but do nothing;
a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*` a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*`
token directly and says why. token directly and says why.
### Material off the startup path
`renderer.initialBytes` ([performance journeys](performance-journeys.md#rendererinitialbytes))
counts every module an eager file imports, and the bundler keeps a module in
one chunk, so one import of one export carries the whole module. Material
therefore reaches the initial bundle only through the snack-bar, which root
services inject (and with it `button`, the icon button and the CDK overlay).
Everything else is lazy by these rules:
- **Form-field defaults.** A component that renders `<mat-form-field>`
adds `providers: [provideFormFieldDefaults()]`
(`@iptvnator/ui/components/form-field-defaults`); the token is never
provided at the root, where its import alone put the 54 KB form-field
chunk on the initial path. `pnpm run ui:form-field-defaults:validate` (CI)
requires the provider on every such component and allows the token
nowhere else; see the Dialogs and forms notes below.
- **Dialogs from services created with the app.** A root service that opens a
dialog resolves `MatDialog` through `LazyMatDialog`
(`@iptvnator/ui/components/lazy-dialog`) and imports the dialog component
on demand, as `DialogService`, the player service, the parental lock prompt
and the workspace shell actions do; `openConfirmDialog` returns a promise
that settles once the dialog is open. A component in a lazy chunk injects
`MatDialog` directly.
- **Panels the shell mounts on every launch.** The EPG progress panel and the
update notification are an eager host with `@defer (when visible)` around
the view that imports Material; the startup status draws its spinner as
static markup copied from `mat-spinner`. A new always-mounted component
follows the same split.
- **Theme scope.** `m3-theme.scss` themes only the components in its
`$material-components` list, through `meta.apply` for both the light theme
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`; `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
`material-motion.e2e.ts` checks that dialogs, selects, menus, snack-bars and
tooltips still animate in and out and stop under reduced motion. One side
effect of the module is gone with it: its renderer deferred every DOM
removal to the end of the change-detection tick, so a destroy hook still
saw its view attached. Without it Angular detaches the view first, where
`scrollTop` reads 0 and Chromium has already fired a `focusout` with no
related target for the focused control. A destroy hook therefore never
reads geometry or focus from its own view: the catalog list tracks the
grid offset from scroll events, and the player settings panel decides
"focus was inside" in a microtask after such a `focusout`, which the close
destroys the panel before. Both have specs that detach the view first.
The app theme is the `.dark-theme` class that `SettingsService` puts on The app theme is the `.dark-theme` class that `SettingsService` puts on
`<body>`; a "System" choice resolves the OS preference there and nowhere `<body>`; a "System" choice resolves the OS preference there and nowhere
else. Component styles never use a `prefers-color-scheme` query, which else. Component styles never use a `prefers-color-scheme` query, which
@@ -167,8 +283,24 @@ Use this pattern for:
Do not add extra badges, left rails, or second selection systems unless there is a strong reason. 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 ## Single-Choice Menus And Status Chips
A `<mat-menu class="app-menu">` gets the kit panel from one global rule in
`styles.scss` (`menu-panel` in `libs/ui/styles/_app-controls.scss`): a
bordered 10px panel with 6px padding, 36px rows at 13px with 16px icons, the
checked row on the list-selection tokens, a destructive row marked
`app-menu-item--danger` after a divider, and a shortcut hint in a trailing
`kbd.app-menu-item__kbd`. The panel renders in the overlay container, outside
every component view, so a component never copies the mixin into its own
stylesheet.
A `mat-menu` that picks one value, such as a sort order or a rating A `mat-menu` that picks one value, such as a sort order or a rating
threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
`@iptvnator/ui/components`: `@iptvnator/ui/components`:
@@ -192,6 +324,15 @@ threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
- A menu with several groups, such as the catalog refine menu, wraps each - 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 group in `role="group"`. The group is named by its section title, and
`role="separator"` sits between groups. `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 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 catalog sort chip, shows its icon and a short value at every width, for
@@ -1014,14 +1155,17 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
- **Destructive actions.** Material only emits `warn` button colors for M2 - **Destructive actions.** Material only emits `warn` button colors for M2
themes, so the `color` input is a no-op here. A button that removes or themes, so the `color` input is a no-op here. A button that removes or
discards user data uses the global `.app-destructive-button` class from discards user data uses the global `.app-destructive-button` class from
`m3-theme.scss` (error/on-error tokens per theme, for filled, text, `m3-theme.scss`, as the unsaved-changes dialog's Discard does. It is the
outlined and icon buttons), as the unsaved-changes dialog's Discard does. kit's red tint on the `--app-danger-*` tokens per theme (tinted fill and
red text for filled and outlined buttons, red text for text and icon
buttons), never a solid error fill; hover changes the fill only, and a
disabled button is the same button at 40% opacity.
Confirmations go through `DialogService.openConfirmDialog` with a Confirmations go through `DialogService.openConfirmDialog` with a
translated verb as the required `confirmLabel` ("Remove playlist", translated verb as the required `confirmLabel` ("Remove playlist",
"Clear") and `tone: 'destructive'` when the action loses data; the dismiss "Clear") and `tone: 'destructive'` when the action loses data; the dismiss
defaults to "Cancel". Never confirm with "Yes"/"No". When the verb itself defaults to "Cancel". Never confirm with "Yes"/"No". When the verb itself
is "Cancel …", pass `cancelLabel` "Close" so the two buttons do not read is "Cancel …", pass `cancelLabel` "Close" so the two buttons do not read
alike. `theme-tokens.e2e.ts` checks the label and the error fill in both alike. `theme-tokens.e2e.ts` checks the label and the danger tint in both
themes. themes.
- **One control per action.** A page offers one control for a destructive - **One control per action.** A page offers one control for a destructive
action. Clearing a favorites or recently viewed tab goes through action. Clearing a favorites or recently viewed tab goes through
@@ -1033,6 +1177,19 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
## Forms ## Forms
Every `<mat-form-field>` is outlined with a dynamic subscript. A component
that renders one adds `providers: [provideFormFieldDefaults()]`
(`@iptvnator/ui/components/form-field-defaults`); the root injector does not
provide the defaults, because the token import there would load Material's
form field and `@angular/forms` on startup. `pnpm run
ui:form-field-defaults:validate` (in CI) reports a missing provider.
A `mat-select` belongs inside a form. Its option panel is styled once in
`m3-theme.scss` to match the kit menu (a bordered 10px panel, 36px rows at
13px, the chosen row on the list-selection tokens). A picker in a toolbar is
not a select: it is a secondary button with a chevron that opens an
`app-menu`.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears: vocabulary, so a field reads the same wherever it appears:
@@ -1304,27 +1461,97 @@ A visual change is not done until:
### Source row keyboard access ### Source row keyboard access
A Sources row (`app-playlist-item`) opens on a click anywhere on it, and it A Sources row (`app-source-row`, regular size) opens on a click anywhere on
also holds a drag handle, a health indicator and actions. Like it, and it also holds a drag handle and its actions. Like `app-content-card`,
`app-content-card`, it opens from the keyboard through a dedicated element it opens from the keyboard through a dedicated element that is a sibling of
that is a sibling of those controls: the title and meta block those controls: the title and meta block `.source-row__body`
`.playlist-content` (`role="button"`, `tabindex="0"`, named by the source (`role="button"`, `tabindex="0"`, named by the source's display name,
title, `aria-current` on the active source, `aria-disabled` while busy). `aria-current` on the active source, `aria-disabled` while busy). Enter and
Enter and Space open the source (Space prevents the page scroll), and Tab Space open the source (Space prevents the page scroll), and Tab reaches each
reaches each row before its actions. Never put `role="button"` or key row before its actions. Never put `role="button"` or key handlers on the whole
handlers on the whole row: the actions would sit inside a button, and their row: the actions would sit inside a button, and their Enter and Space would
Enter and Space would bubble into it. The row draws the ring for that bubble into it. The row draws the ring for that element, inset
element, inset (`$offset: -2px`), because the list's scroller would cut a (`$offset: -2px`), because the list's scroller would cut a ring drawn outside
ring drawn outside the full-width row. `sources-keyboard.e2e.ts` (Electron) the full-width row. `R` on a focused row refreshes it, as its menu item shows.
tabs to a row, checks its ring and opens the source with Enter. `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) ### Network source indicators (Electron)
The switcher and source rows use `SourceHealthIndicatorComponent`: green means The status dot on a source tile reads `SourceStatusService`: green answers,
available, orange expired, red disabled/access refused, and neutral means red does not, orange is an expired account, grey is not checked yet; local
checking or unverified. Accessible tooltip text distinguishes refusal from a files have no dot. On desktop the live `SourceHealthService` result wins; the
confirmed disabled account and includes the check time. M3U indicators describe last conclusive result per source is kept across restarts
the source URL only. Check again is a separate action, not a playlist refresh. (`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 ### Inactive-source cleanup dialog
+9
View File
@@ -1856,6 +1856,15 @@ shared data access. Electron checks Xtream account info, Stalker account/profile
facts through the existing session, and the first 64 KiB of M3U URL responses. 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 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. 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, Checks are demand-driven, never a startup readiness barrier: four at a time,
two per origin, shared between surfaces. Known results remain visible during two per origin, shared between surfaces. Known results remain visible during
+12 -8
View File
@@ -64,12 +64,14 @@ in both dependency sections and all official Nx packages on one exact version.
Use `.nvmrc` for development/CI; supported LTS Node ranges are Use `.nvmrc` for development/CI; supported LTS Node ranges are
`^22.22.3 || ^24.15.0`. Docker uses the supported Node 24 line. `^22.22.3 || ^24.15.0`. Docker uses the supported Node 24 line.
The Angular 22 migration explicitly preserves the old default change detection The Angular 22 migration explicitly preserved the old default change detection
with `ChangeDetectionStrategy.Eager`. Existing OnPush components and with `ChangeDetectionStrategy.Eager`. Plan item C6 since converted every component
`provideZoneChangeDetection` remain in their existing modes. the renderer uses to OnPush and switched it to
Existing eager components under Angular ESLint carry a line-scoped explanation `provideZonelessChangeDetection()` without the `zone.js` polyfill; see the
for the newly recommended OnPush rule; new components keep that rule enabled. [zoneless migration](zoneless-migration.md). `apps/remote-control-web` and its
Adopting OnPush in those components requires a separate behavior review. remote-control component still use zone.js and `ChangeDetectionStrategy.Eager`,
with a line-scoped explanation for the OnPush lint rule; new components keep
that rule enabled.
TypeScript 6 migration settings preserve compiler defaults and per-project output roots; TypeScript 6 migration settings preserve compiler defaults and per-project output roots;
`ignoreDeprecations: "6.0"` temporarily supports legacy Node test configs and `ignoreDeprecations: "6.0"` temporarily supports legacy Node test configs and
`baseUrl`, rather than changing the whole workspace to browser resolution. `baseUrl`, rather than changing the whole workspace to browser resolution.
@@ -105,8 +107,10 @@ imports `zone.js/testing` for existing `fakeAsync` helpers. This preserves the
Angular 21 scheduler used by jest-preset-angular 15: its `setupZoneTestEnv` loaded Angular 21 scheduler used by jest-preset-angular 15: its `setupZoneTestEnv` loaded
Zone but did not provide zone-based change detection on Angular 20+. Preset 17 Zone but did not provide zone-based change detection on Angular 20+. Preset 17
does provide it, which disables automatic fixture rendering and changes does provide it, which disables automatic fixture rendering and changes
`whenStable()` behavior. Production applications continue to use their existing `whenStable()` behavior. The production renderer (`apps/web`) runs
`provideZoneChangeDetection`; browser and Electron E2E validate that runtime. `provideZonelessChangeDetection()` like the tests; `apps/remote-control-web`
keeps `provideZoneChangeDetection`. Browser and Electron E2E validate both
runtimes.
## Vite Dev-Server Patch ## Vite Dev-Server Patch
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more