mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Merge remote-tracking branch 'origin/master' into perf/j3-dom-mutations-baseline
# Conflicts: # .github/workflows/ci.yml
This commit is contained in:
commit
a8505e0f91
279 files changed
+19836
-7874
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
type: fix
|
||||||
|
area: a11y
|
||||||
|
---
|
||||||
|
|
||||||
|
The radio player's play and pause button now shows the keyboard focus ring
|
||||||
|
when you tab to it, like every other control, and the app loads a little less
|
||||||
|
style code at startup.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
type: fix
|
||||||
|
area: 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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": "جارٍ التحميل",
|
||||||
|
|||||||
@@ -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": "كيتشارجا",
|
||||||
|
|||||||
@@ -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": "Загрузка",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Φόρτωση",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "読み込み中",
|
||||||
|
|||||||
@@ -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": "로드 중",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Загрузка",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "加载中",
|
||||||
|
|||||||
@@ -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
@@ -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 +0,0 @@
|
|||||||
import 'zone.js';
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
import 'jest-preset-angular/setup-jest';
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user