Merge origin/master into claude/server-storage-pwa

Resolves app-startup-status.component.ts: master replaced the progress
spinner with static markup (its import goes), the branch's router import
for the sign-in route stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 14:36:01 +02:00
commit 614b0d3500
239 files changed
+17820 -7244

No files matched your search

@@ -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.
+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: sources
---
Playlists added from a URL no longer show the login or password from that URL in the Sources list or the toolbar: they show the server's host instead.
@@ -0,0 +1,7 @@
---
type: feature
area: sources
highlight: Redesigned Sources and playlist switcher
---
Sources and the playlist switcher share a new, compact row with a status dot. Filter Sources by type and by whether sources respond, delete with Undo, and open the switcher with ⌘P / Ctrl+P to search, filter by type and switch from the keyboard.
@@ -0,0 +1,6 @@
---
type: fix
area: stalker
---
Stalker series details now keep their clickable genre and country chips after TMDB fills in the details, so you can jump to Discover from them as you can for movies.
@@ -0,0 +1,9 @@
---
type: perf
area: web
---
The app loads about 12% less code before it shows its first screen: dialog,
tooltip and progress-bar code, the animation engine and the theme colours of
unused components now load only when something needs them, so every launch
has less to download and parse.
@@ -0,0 +1,8 @@
---
type: fix
area: workspace
---
Going back to the dashboard from a portal section no longer makes the page
jump: the dashboard replaces the portal page in one step instead of appearing
below it for a moment.
@@ -0,0 +1,6 @@
---
type: fix
area: workspace
---
The side panel next to Sources, categories and Settings no longer draws a thin divider line against the content area, whose colour, shadow and rounded corner already mark the edge. Dragging the panel's edge still resizes it.
+8 -4
View File
@@ -1,10 +1,11 @@
# Every Dependabot PR triggers the full pipeline (~15 jobs), so version
# updates are batched weekly. Nx minor+patch updates use a dedicated group so
# official packages move together; Nx majors are manual coordinated migrations.
# Native packaging, parser, and version-locked playback dependencies stay in
# standalone PRs; other minor+patch updates are grouped per ecosystem. Security
# updates are separate and are not limited by this schedule; CI enforces complete
# Nx lockstep.
# Electron majors are held while Linux armv7l packages ship: Electron 44
# dropped the linux-armv7l binary. Native packaging, parser, and
# version-locked playback dependencies stay in standalone PRs; other
# 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
updates:
- package-ecosystem: npm
@@ -21,6 +22,9 @@ updates:
- dependency-name: '@nx/*'
update-types:
- version-update:semver-major
- dependency-name: electron
update-types:
- version-update:semver-major
groups:
nx-version-updates:
applies-to: version-updates
+11
View File
@@ -430,6 +430,17 @@ jobs:
- name: Validate icon-only button names
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
# the Playwright plugin infers serve dependencies only when CI is
# 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)
// are radio groups: every row reserves the same leading check slot, so the
// labels share one left edge, and `aria-checked` marks the active choice.
//
// The refine menu keeps its rating thresholds in a submenu, so it opens
// whole below its button in a short window.
// ---------------------------------------------------------------------------
const locales = ['en', 'de', 'ru', 'hu'] as const;
@@ -83,14 +86,62 @@ function openMenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last();
}
/** The refine menu's own panel, also while its rating submenu is open. */
function refineMenuPanel(page: Page): Locator {
return page.locator(
'.cdk-overlay-pane .mat-mdc-menu-panel.refine-menu:not(.refine-rating-menu)'
);
}
function ratingSubmenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .refine-rating-menu');
}
async function openRefineMenu(page: Page): Promise<Locator> {
await page.locator('app-category-content-view .refine-action').click();
const menu = openMenuPanel(page);
const menu = refineMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
return menu;
}
/** Opens the minimum-rating thresholds from the open refine menu. */
async function openRatingSubmenu(page: Page): Promise<Locator> {
await page.locator('.refine-rating-trigger').click();
const submenu = ratingSubmenuPanel(page);
await expect(submenu).toBeVisible();
await settleAnimations(submenu);
return submenu;
}
/** The panel lies inside the window and shows every row without scrolling. */
async function expectWholeOnScreen(
panel: Locator,
label: string
): Promise<{ top: number; bottom: number }> {
const geometry = await panel.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
windowHeight: window.innerHeight,
scrolls: element.scrollHeight > element.clientHeight + 1,
};
});
expect(
geometry.top,
`${label}: ${JSON.stringify(geometry)}`
).toBeGreaterThanOrEqual(0);
expect(
geometry.bottom,
`${label}: ${JSON.stringify(geometry)}`
).toBeLessThanOrEqual(geometry.windowHeight);
expect(geometry.scrolls, `${label}: ${JSON.stringify(geometry)}`).toBe(
false
);
return geometry;
}
async function settleAnimations(menu: Locator): Promise<void> {
await menu.evaluate((element) =>
Promise.all(
@@ -210,12 +261,8 @@ test.describe('Electron catalog sort menus', () => {
// A rating threshold shows the second chip next to the sort one.
let menu = await openRefineMenu(page);
await menu
.getByRole('group')
.nth(1)
.getByRole('menuitemradio')
.nth(5)
.click();
const thresholds = await openRatingSubmenu(page);
await thresholds.getByRole('menuitemradio').nth(5).click();
await expect(menu).toBeHidden();
for (const locale of locales) {
@@ -252,16 +299,33 @@ test.describe('Electron catalog sort menus', () => {
menu = await openRefineMenu(page);
const groups = menu.getByRole('group');
await expect(groups).toHaveCount(2);
await expect(groups).toHaveCount(1);
await expectRadioGroup(groups.nth(0), 5, `${locale} sort`);
await expectRadioGroup(groups.nth(1), 5, `${locale} rating`);
const lefts = (await radioRows(menu)).map(
(row) => row.labelLeft
);
// The thresholds row starts its label on the sort labels'
// edge and names the active threshold.
const ratingRow = menu.locator('.refine-rating-trigger');
await expect(ratingRow).toContainText('5.0+');
const lefts = [
...(await radioRows(menu)).map((row) => row.labelLeft),
Math.round(
await ratingRow
.locator('.mat-mdc-menu-item-text')
.evaluate(
(text) => text.getBoundingClientRect().left
)
),
];
expect(
[...new Set(lefts)],
`${locale}: both groups`
`${locale}: sort and rating rows`
).toHaveLength(1);
const ratings = await openRatingSubmenu(page);
await expectRadioGroup(
ratings.getByRole('group'),
5,
`${locale} rating`
);
await closeMenu(page, ratings);
await closeMenu(page, menu);
}
} finally {
@@ -269,6 +333,63 @@ test.describe('Electron catalog sort menus', () => {
}
});
test('opens the refine menu whole below its button in a 600px-tall window, with the rating thresholds in a keyboard submenu', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page.setViewportSize({ width: 1280, height: 600 });
await openWorkspaceSection(page, 'Movies');
await expect(
page.locator('app-category-content-view mat-card').first()
).toBeVisible({ timeout: 20_000 });
const trigger = page.locator(
'app-category-content-view .refine-action'
);
await trigger.focus();
await page.keyboard.press('Enter');
const menu = refineMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
const triggerBottom = await trigger.evaluate(
(element) => element.getBoundingClientRect().bottom
);
const menuBox = await expectWholeOnScreen(menu, 'refine menu');
// Below the button, not pushed up over it.
expect(menuBox.top).toBeGreaterThanOrEqual(triggerBottom);
// The last row opens the thresholds with the arrow key.
await page.keyboard.press('ArrowUp');
await expect(menu.locator('.refine-rating-trigger')).toBeFocused();
await page.keyboard.press('ArrowRight');
const thresholds = ratingSubmenuPanel(page);
await expect(thresholds).toBeVisible();
await settleAnimations(thresholds);
await expect(thresholds.getByRole('menuitemradio')).toHaveCount(6);
await expect(
thresholds.getByRole('menuitemradio').first()
).toBeFocused();
await expectWholeOnScreen(thresholds, 'rating submenu');
// Escape steps back out: the submenu, then the menu.
await page.keyboard.press('Escape');
await expect(thresholds).toBeHidden();
await expect(menu.locator('.refine-rating-trigger')).toBeFocused();
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
await expect(trigger).toBeFocused();
} finally {
await closeElectronApp(app);
}
});
test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
dataDir,
request,
@@ -545,8 +545,15 @@ async function expectCategoryCentered(row: Locator): Promise<void> {
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
// Refresh for the active source lives in its switcher row menu.
await page.getByTestId('playlist-switcher-trigger').click();
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
)
.click();
await page
.getByRole('menuitem', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
@@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl(
playlistUrl
);
if (userAgent !== undefined) {
await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent);
await setInputValue(
dialog.getByRole('textbox', { name: 'User agent', exact: true }),
userAgent
);
}
await dialog.getByRole('button', { name: /Add playlist/i }).click();
await dialog.waitFor({ state: 'detached' });
@@ -1187,7 +1190,10 @@ export async function createMutableTextServer(
return;
}
if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) {
if (
options.requiredUserAgent &&
req.headers['user-agent'] !== options.requiredUserAgent
) {
res.writeHead(403);
res.end('User-Agent required');
return;
@@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact(
}
export function sourceRowByTitle(page: Page, title: string): Locator {
return page.locator('app-playlist-item').filter({
return page.locator('app-workspace-sources app-source-row').filter({
hasText: flexibleTextPattern(title),
});
}
export async function getVisibleSourceTitles(page: Page): Promise<string[]> {
return page.locator('app-playlist-item').evaluateAll((elements) =>
elements
.map((element) => {
const titleElement = element.querySelector('.playlist-title');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
return page
.locator('app-workspace-sources app-source-row')
.evaluateAll((elements) =>
elements
.map((element) => {
const titleElement =
element.querySelector('.source-row__name');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
}
export async function selectSourceTypeFilter(
@@ -1497,8 +1506,10 @@ export async function selectSourceSort(
sortLabel:
| 'Date added (Newest first)'
| 'Date added (Oldest first)'
| 'Last updated'
| 'Name (A-Z)'
| 'Name (Z-A)'
| 'Most channels'
| 'Custom order'
): Promise<void> {
const sortTrigger = page.locator('app-workspace-sources .sort-trigger');
@@ -1523,10 +1534,10 @@ export async function dragSourceBefore(
sourceTitle: string,
targetTitle: string
): Promise<void> {
const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon');
const target = sourceRowByTitle(page, targetTitle).locator(
'.playlist-item'
const source = sourceRowByTitle(page, sourceTitle).locator(
'.source-row__handle'
);
const target = sourceRowByTitle(page, targetTitle).locator('.source-row');
await expect(source.first()).toBeVisible();
await expect(target.first()).toBeVisible();
@@ -1560,15 +1571,18 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.edit-btn').click();
await row.hover();
await row.locator('.source-row__edit').click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
// Material applies its initial focus after the opening transition.
await dialog.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) =>
animation.finished.catch(() => undefined)
));
await Promise.all(
element
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined))
);
});
return dialog;
}
@@ -1678,12 +1692,24 @@ export async function expectSourceDialogValues(
}
}
/**
* Deletes a source from its ⋯ menu. The row leaves at once; the delete
* itself runs when the Undo toast closes, so this waits for the toast to go
* before the caller checks persisted state.
*/
export async function deleteSource(page: Page, title: string): Promise<void> {
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.delete-btn').click();
await confirmDialog(page, 'Remove playlist');
await row.hover();
await row.locator('.source-row__more').click();
await page
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.click();
const toast = page.locator('mat-snack-bar-container.app-toast');
await expect(toast).toContainText('Removed');
await expect(toast).toBeHidden({ timeout: 15000 });
}
export async function refreshSource(
@@ -1697,7 +1723,8 @@ export async function refreshSource(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.refresh-btn').click();
await row.hover();
await row.locator('.source-row__refresh').click();
if (confirm) {
await confirmDialog(page, 'Refresh playlist');
@@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await expect(row.locator('.busy-state__message')).toHaveCount(0, {
await expect(row.locator('.source-row__busy')).toHaveCount(0, {
timeout: 60000,
});
await expect(row.locator('.action-spinner')).toHaveCount(0, {
await expect(row.locator('mat-spinner')).toHaveCount(0, {
timeout: 60000,
});
}
@@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => {
});
await openSources(app.mainWindow);
await expect(
app.mainWindow.locator('app-playlist-item')
app.mainWindow.locator('app-workspace-sources app-source-row')
).toHaveCount(2, { timeout: 30000 });
} finally {
await closeElectronApp(app);
@@ -435,6 +435,7 @@ async function openMoviesWithRatingFilter(page: Page): Promise<void> {
timeout: 20_000,
});
await view.locator('.refine-action').click();
await page.locator('.refine-rating-trigger').click();
await page
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
.click();
@@ -550,19 +551,21 @@ test.describe('Keyboard focus ring', () => {
// Sources: the type filters, then the list's row actions.
await openSources(page);
await expect(
page.locator('app-playlist-item').first()
page.locator('app-workspace-sources app-source-row').first()
).toBeVisible();
// Add, the Type and Status rows, Clean up, Sort, then the first
// row and its actions; Check again lives in the row's menu.
await walk(
'sources list',
page.locator('app-workspace-shell'),
page
.locator('app-workspace-sources-filters-panel button')
.first(),
12
16
);
expect(
report['sources list (dark)'].map((stop) => stop.label)
).toContain('Check again');
).toContain('Playlist actions');
// The live EPG list: its rows are Tab stops (`role="button"`)
// with their own inset ring, then the row's info button.
@@ -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);
}
});
});
@@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel',
assert.equal(options.splashId, 'initial-splash');
assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
assert.match(options.cardSelector, /app-playlist-item/);
assert.match(options.cardSelector, /app-source-row/);
assert.deepEqual(options.settle, {
capMs: JOURNEY_SETTLE_CAP_MS,
quietMs: JOURNEY_SETTLE_QUIET_MS,
@@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route',
options.startClick?.selector ?? '',
/dashboard-recent-sources-rail-card/
);
assert.match(options.startClick?.selector ?? '', /app-playlist-item/);
assert.match(options.startClick?.selector ?? '', /app-source-row/);
assert.equal(options.routeFragment, '/workspace/xtreams/');
assert.match(options.cardSelector, /app-grid-list mat-card/);
assert.match(options.cardSelector, /channel-item/);
@@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR =
export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */
export const JOURNEY_OPEN_SOURCE_START_SELECTOR =
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item';
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row';
/**
* Bridge method used for the sentinels. The preload emits the trace event
* before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id
@@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions(
): JourneyRendererProbeOptions {
return {
cardSelector:
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row',
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
...(idleWindowMs === null
? {}
@@ -262,7 +262,7 @@ async function runIteration(
await renderer.markOperationStart();
const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first();
await row.locator('.refresh-btn').click();
await row.locator('.source-row__refresh').click();
await renderer.waitForCancelClick();
await Promise.all([
renderer.waitForTerminal(),
@@ -301,7 +301,7 @@ async function installRendererProbe(
const epoch = (): number =>
performance.timeOrigin + performance.now();
const sourceRow = (): Element | undefined =>
Array.from(document.querySelectorAll('app-playlist-item')).find(
Array.from(document.querySelectorAll('app-source-row')).find(
(element) => element.textContent?.includes(title)
);
const state = {
@@ -330,7 +330,7 @@ async function installRendererProbe(
const row = sourceRow();
const message =
row
?.querySelector('.busy-state__message')
?.querySelector('.source-row__busy-message')
?.textContent?.trim()
.toLowerCase() ?? '';
for (const phase of ['fetch', 'pars', 'sav']) {
@@ -345,9 +345,10 @@ async function installRendererProbe(
state.cancelClickEpochMs !== null &&
state.uiSettledEpochMs === null &&
row !== undefined &&
row.querySelector('.busy-state__message') === null &&
row.querySelector('.action-spinner') === null &&
row.querySelector('.cancel-btn') === null
row.querySelector('.source-row__busy-message') === null &&
row.querySelector('.source-row__refresh mat-spinner') ===
null &&
row.querySelector('.source-row__cancel') === null
) {
state.uiSettledEpochMs = epoch();
state.uiPaintFrameRequestId = requestAnimationFrame(() => {
@@ -421,7 +422,9 @@ async function installRendererProbe(
event.phase === 'parsing' &&
state.cancelClickEpochMs === null
) {
const button = sourceRow()?.querySelector('.cancel-btn');
const button = sourceRow()?.querySelector(
'.source-row__cancel'
);
state.cancelButtonFound =
button instanceof HTMLButtonElement;
if (button instanceof HTMLButtonElement) {
@@ -152,11 +152,11 @@ export async function installXtreamRendererProbe(
);
const sourceRowAbsent = (): boolean =>
!Array.from(
document.querySelectorAll('app-playlist-item')
document.querySelectorAll('app-source-row')
).some(
(row) =>
row
.querySelector('.playlist-title')
.querySelector('.source-row__name')
?.textContent?.trim() === state.sourceTitle
);
const terminalDomGate = (): boolean => {
@@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => {
"'#serverUrl'",
"'#username'",
"'#password'",
"'app-playlist-item'",
"'.refresh-btn'",
"'.delete-btn'",
"'app-workspace-sources app-source-row'",
"'.source-row__refresh'",
"'.source-row__more'",
"'Refresh playlist'",
"'Remove playlist'",
"'.busy-state__message'",
"'.action-spinner'",
"'.cancel-btn'",
"'.source-row__busy'",
"'mat-spinner'",
"'.source-row__cancel'",
"'.workspace-loading-overlay'",
]) {
assert.ok(ui.includes(selector), `missing ${selector}`);
@@ -68,6 +68,7 @@ describe('Xtream scenario driver source wiring', () => {
);
assert.ok(driver.includes('installXtreamCancellationObserver'));
assert.ok(driver.includes('installXtreamOperationWindow'));
assert.ok(driver.includes("'delete-playlist'"));
assert.ok(driver.includes('runBackgroundProbe'));
assert.doesNotMatch(combined, /waitForTimeout\s*\(/);
assert.doesNotMatch(combined, /setTimeout\s*\(/);
@@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => {
});
it('does not require a misleading portal-status badge before refresh or delete', async () => {
for (const [scenarioId, selector, confirmLabel] of [
for (const [scenarioId, selector, confirmClick] of [
[
XTREAM_SCENARIO_ID.REFRESH_LARGE,
'.refresh-btn',
'Refresh playlist',
'.source-row__refresh',
'name=Refresh playlist;exact=true',
],
[
XTREAM_SCENARIO_ID.DELETE_LARGE,
'.source-row__more',
'.source-removal-toast__close',
],
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'],
] as const) {
const confirmClick = `name=${confirmLabel};exact=true`;
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
fake.asPage(),
@@ -188,6 +191,54 @@ describe('Xtream Playwright scenario driver', () => {
}
});
it('arms the playlist delete before its commit click and ends on its completion', async () => {
const fake = new FakePage();
const completed = {
active: false,
operation: 'delete-playlist',
operationId: 'delete-playlist-1',
playlistId: 'playlist-1',
startedEpochMs: 100,
terminalEpochMs: 150,
terminalStatus: 'completed',
};
fake.operationSnapshot = completed;
const prepared = await prepareXtreamScenario(
fake.asPage(),
scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE)
);
// Preparation stops at the open menu: Remove would start the Undo
// window, which commits on its own before a slow capture setup ends.
assert.equal(fake.clicksMatching('filter(Remove playlist)'), 0);
await prepared.trigger();
assert.equal(fake.clicksMatching('filter(Remove playlist)'), 1);
const armIndex = fake.log.indexOf('evaluate:install-operation');
const clickIndex = fake.log.findIndex(
(entry) =>
entry.startsWith('click:') &&
entry.includes('.source-removal-toast__close')
);
assert.ok(armIndex >= 0, 'the delete window is armed');
assert.ok(armIndex < clickIndex, 'before the commit click');
await prepared.waitForTerminal();
assert.ok(
fake.log.indexOf('waitForFunction:operation-terminal') > clickIndex
);
const failed = new FakePage();
failed.operationSnapshot = { ...completed, terminalStatus: 'error' };
const failing = await prepareXtreamScenario(
failed.asPage(),
scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE)
);
await failing.trigger();
await assert.rejects(
() => failing.waitForTerminal(),
/xtream-delete-did-not-complete/
);
});
it('arms cancellation after preparation and immediately before the final Add trigger', async () => {
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
@@ -19,7 +19,8 @@ import {
prepareXtreamSourceActionTrigger,
waitForXtreamCancellationTerminal,
waitForXtreamCatalogTerminal,
waitForXtreamDeleteTerminal,
removeXtreamSource,
waitForXtreamSourceRowGone,
} from './xtream-scenario-ui';
import { assertXtreamMockOrigin } from './xtream-control-client';
import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe';
@@ -106,6 +107,7 @@ export async function prepareXtreamScenario(
return createPreparedScenario({
backgroundOperation,
cancellationObserver: null,
deleteOperation: null,
options,
page,
trigger,
@@ -119,6 +121,8 @@ export async function prepareXtreamScenario(
interface PreparedState {
readonly backgroundOperation: XtreamOperationWindow | null;
cancellationObserver: XtreamCancellationObserver | null;
/** The playlist delete, armed before its commit click. */
deleteOperation: XtreamOperationWindow<'delete-playlist'> | null;
readonly options: PrepareXtreamScenarioOptions;
readonly page: Page;
readonly trigger: Locator;
@@ -136,6 +140,7 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario {
await Promise.allSettled([
state.cancellationObserver?.dispose(),
state.backgroundOperation?.dispose(),
state.deleteOperation?.dispose(),
]);
};
const prepared: PreparedXtreamScenario = {
@@ -189,8 +194,21 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario {
state.cancellationObserver =
await installXtreamCancellationObserver(state.page);
}
if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) {
// A fast delete can bring its row back and drop it again
// before a UI wait starts; the operation's own events cannot
// be missed once armed.
state.deleteOperation = await installXtreamOperationWindow(
state.page,
'delete-playlist'
);
}
let commit = state.trigger;
if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) {
commit = await removeXtreamSource(state.page, state.trigger);
}
const triggerEpochMs = Date.now();
await state.trigger.click();
await commit.click();
return {
scenarioId: state.options.scenarioId,
triggerEpochMs,
@@ -224,12 +242,20 @@ async function waitForScenarioTerminal(
cancellation = await state.cancellationObserver.waitForClick();
await waitForXtreamCancellationTerminal(state.page);
break;
case XTREAM_SCENARIO_ID.DELETE_LARGE:
await waitForXtreamDeleteTerminal(
case XTREAM_SCENARIO_ID.DELETE_LARGE: {
if (!state.deleteOperation) {
throw new Error('xtream-delete-operation-unavailable');
}
const deleted = await state.deleteOperation.waitForTerminal();
if (deleted.terminalStatus !== 'completed') {
throw new Error('xtream-delete-did-not-complete');
}
await waitForXtreamSourceRowGone(
state.page,
state.options.sourceTitle
);
break;
}
case XTREAM_SCENARIO_ID.BACKGROUND_UI:
if (readBackgroundProbe() === null) {
throw new Error('xtream-background-probe-incomplete');
@@ -2,11 +2,16 @@ import type { Page } from '@playwright/test';
const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow';
const DELETE_OPERATION = 'delete-xtream-content';
/** A content delete before a refresh, or a whole playlist's delete. */
export type XtreamObservedOperation =
typeof DELETE_OPERATION | 'delete-playlist';
const OPERATION_TIMEOUT_MS = 120_000;
export interface XtreamOperationWindowSnapshot {
export interface XtreamOperationWindowSnapshot<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
> {
readonly active: boolean;
readonly operation: typeof DELETE_OPERATION;
readonly operation: Operation;
readonly operationId: string;
readonly playlistId: string;
readonly startedEpochMs: number;
@@ -14,16 +19,21 @@ export interface XtreamOperationWindowSnapshot {
readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null;
}
export interface XtreamOperationWindow {
assertActive(): Promise<XtreamOperationWindowSnapshot>;
export interface XtreamOperationWindow<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
> {
assertActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
dispose(): Promise<void>;
waitForActive(): Promise<XtreamOperationWindowSnapshot>;
waitForTerminal(): Promise<XtreamOperationWindowSnapshot>;
waitForActive(): Promise<XtreamOperationWindowSnapshot<Operation>>;
waitForTerminal(): Promise<XtreamOperationWindowSnapshot<Operation>>;
}
export async function installXtreamOperationWindow(
page: Page
): Promise<XtreamOperationWindow> {
export async function installXtreamOperationWindow<
Operation extends XtreamObservedOperation = typeof DELETE_OPERATION,
>(
page: Page,
operation: Operation = DELETE_OPERATION as Operation
): Promise<XtreamOperationWindow<Operation>> {
await page.evaluate(
({ command, operation, stateKey }) => {
interface State {
@@ -104,20 +114,20 @@ export async function installXtreamOperationWindow(
},
{
command: 'install-operation',
operation: DELETE_OPERATION,
operation,
stateKey: OPERATION_STATE_KEY,
}
);
return {
assertActive: () => assertOperationActive(page),
assertActive: () => assertOperationActive(page, operation),
dispose: () => disposeOperationState(page),
async waitForActive() {
await waitForOperationState(page, 'operation-active');
return assertOperationActive(page);
return assertOperationActive(page, operation);
},
async waitForTerminal() {
await waitForOperationState(page, 'operation-terminal');
return readOperationSnapshot(page);
return readOperationSnapshot(page, operation);
},
};
}
@@ -144,19 +154,21 @@ async function waitForOperationState(
);
}
async function assertOperationActive(
page: Page
): Promise<XtreamOperationWindowSnapshot> {
const snapshot = await readOperationSnapshot(page);
async function assertOperationActive<Operation extends XtreamObservedOperation>(
page: Page,
operation: Operation
): Promise<XtreamOperationWindowSnapshot<Operation>> {
const snapshot = await readOperationSnapshot(page, operation);
if (!snapshot.active || snapshot.terminalEpochMs !== null) {
throw new Error('xtream-background-action-outside-delete-window');
}
return snapshot;
}
async function readOperationSnapshot(
page: Page
): Promise<XtreamOperationWindowSnapshot> {
async function readOperationSnapshot<Operation extends XtreamObservedOperation>(
page: Page,
operation: Operation
): Promise<XtreamOperationWindowSnapshot<Operation>> {
const value = await page.evaluate(
({ stateKey }) => {
const target = globalThis as unknown as Record<string, unknown>;
@@ -183,7 +195,7 @@ async function readOperationSnapshot(
},
{ command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY }
);
if (!isOperationSnapshot(value)) {
if (!isOperationSnapshot(value, operation)) {
throw new Error('xtream-operation-window-invalid');
}
return value;
@@ -202,16 +214,17 @@ async function disposeOperationState(page: Page): Promise<void> {
);
}
function isOperationSnapshot(
value: unknown
): value is XtreamOperationWindowSnapshot {
function isOperationSnapshot<Operation extends XtreamObservedOperation>(
value: unknown,
operation: Operation
): value is XtreamOperationWindowSnapshot<Operation> {
if (!isRecord(value)) return false;
const active = value['active'];
const startedEpochMs = value['startedEpochMs'];
const terminalEpochMs = value['terminalEpochMs'];
const terminalStatus = value['terminalStatus'];
return (
value['operation'] === DELETE_OPERATION &&
value['operation'] === operation &&
typeof active === 'boolean' &&
finitePositive(startedEpochMs) &&
nonEmptyString(value['operationId']) &&
@@ -68,28 +68,54 @@ export async function prepareXtreamSourceActionTrigger(
const row = xtreamSourceRow(page, sourceTitle);
await row.waitFor({ state: 'visible' });
for (const selector of [
'.busy-state__message',
'.action-spinner',
'.cancel-btn',
'.source-row__busy',
'mat-spinner',
'.source-row__cancel',
]) {
await row.locator(selector).waitFor({ state: 'detached' });
}
const actionSelector =
action === 'refresh' ? '.refresh-btn' : '.delete-btn';
const actionButton = row.locator(actionSelector);
await assertTriggerReady(actionButton, `xtream-source-${action}`);
await actionButton.click();
if (action === 'refresh') {
const refresh = row.locator('.source-row__refresh');
await assertTriggerReady(refresh, 'xtream-source-refresh');
await refresh.click();
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', { name: 'Refresh playlist', exact: true })
.last();
await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm');
return confirmation;
}
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', {
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist',
exact: true,
})
.last();
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`);
return confirmation;
// Remove starts the Undo window, which commits on its own after four
// seconds: preparation stops at the open menu, and the trigger removes
// and commits at once (removeXtreamSource).
const more = row.locator('.source-row__more');
await assertTriggerReady(more, 'xtream-source-delete-menu');
await more.click();
const remove = page
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.first();
await assertTriggerReady(remove, 'xtream-source-delete');
return remove;
}
/**
* Clicks Remove and returns the Undo toast's close button, which commits the
* delete without waiting out the Undo window: the measured delete starts
* with that click.
*/
export async function removeXtreamSource(
page: Page,
remove: Locator
): Promise<Locator> {
await remove.click();
const commit = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__close'
);
await assertTriggerReady(commit, 'xtream-source-delete-confirm');
return commit;
}
export async function waitForXtreamCatalogTerminal(page: Page): Promise<void> {
@@ -125,7 +151,11 @@ export async function waitForXtreamCancellationTerminal(
);
}
export async function waitForXtreamDeleteTerminal(
/**
* After the database reports the delete complete: the row, back as
* "Removing…" while it ran or never shown again for a fast delete, leaves.
*/
export async function waitForXtreamSourceRowGone(
page: Page,
sourceTitle: string
): Promise<void> {
@@ -157,9 +187,9 @@ async function selectXtreamMethod(dialog: Locator): Promise<void> {
function xtreamSourceRow(page: Page, sourceTitle: string): Locator {
const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u');
return page
.locator('app-playlist-item')
.locator('app-workspace-sources app-source-row')
.filter({
has: page.locator('.playlist-title', {
has: page.locator('.source-row__name', {
hasText: exactTitle,
}),
})
@@ -178,7 +178,7 @@ class SearchCompletionFakePage {
}
this.searchGateObserved =
/searchParams\.get\((['"])q\1\)/u.test(source) &&
source.includes('app-playlist-item') &&
source.includes('app-source-row') &&
source.includes('no-results-state');
if (this.searchGateObserved) {
this.searchResultsApplied = true;
@@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions(
);
const resultRows = Array.from(
document.querySelectorAll(
'app-workspace-sources app-playlist-item'
'app-workspace-sources app-source-row'
)
);
const resultPainted =
resultRows.length > 0
? resultRows.every((row) =>
(
row.querySelector('.playlist-title')
row.querySelector('.source-row__name')
?.textContent ?? ''
)
.trim()
@@ -245,44 +245,41 @@ test.describe('Electron Playlist Switcher', () => {
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-recent$/
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(
channelItemByTitle(app.mainWindow, localAChannelName)
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, localBChannelName)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
@@ -319,7 +316,9 @@ test.describe('Electron Playlist Switcher', () => {
username: xtreamStressUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
const overlay = app.mainWindow.locator(
'.workspace-loading-overlay'
);
await expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
@@ -428,17 +427,172 @@ test.describe('Electron Playlist Switcher', () => {
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
await expect(
app.mainWindow.locator('.xtream-content-gate')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('runs from the keyboard: Cmd/Ctrl+P, filter, arrows, Enter and Escape', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Switcher Local A',
url: 'https://streams.example.test/switcher-local-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: 'Switcher Local B',
url: 'https://streams.example.test/switcher-local-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await addXtreamPortal(page, { name: xtreamAName });
await waitForXtreamCatalog(page);
const trigger = page.getByTestId('playlist-switcher-trigger');
const panel = page.locator('app-playlist-switcher-panel');
const search = panel.locator('input[role="combobox"]');
await expect(playlistSwitcherTitle(page)).toContainText(
xtreamAName
);
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
// No list shows a credential: the Xtream row names the account
// on purpose, never its password.
await expect(panel).not.toContainText(defaultXtreamPassword);
await expect(panel).not.toContainText('password=');
await page.keyboard.type('switcher-local');
await expect(switcherOptions(page)).toHaveCount(2);
// A search highlights its first match; ↓ moves on, ↑ wraps.
const options = switcherOptions(page);
const firstId = (await options.nth(0).getAttribute('id')) ?? '';
const secondId = (await options.nth(1).getAttribute('id')) ?? '';
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
await page.keyboard.press('ArrowDown');
await expect(search).toHaveAttribute(
'aria-activedescendant',
firstId
);
await page.keyboard.press('ArrowUp');
await expect(search).toHaveAttribute(
'aria-activedescendant',
secondId
);
const targetName =
(
await options
.nth(1)
.locator('.source-row__name')
.textContent()
)?.trim() ?? '';
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
await waitForM3uCatalog(page);
await expect(playlistSwitcherTitle(page)).toContainText(targetName);
// Escape closes and puts focus back on the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
// In a short window only the list shrinks and scrolls; the
// search field and the type filter keep their height.
const viewport = page.viewportSize();
await page.setViewportSize({
width: viewport?.width ?? 1280,
height: 320,
});
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
const chrome = await panel.evaluate((host) => {
const height = (element: Element | null) =>
element?.getBoundingClientRect().height ?? 0;
const list = host.querySelector('.switcher__list');
return {
search: height(host.querySelector('.switcher__search')),
toggles: Array.from(
host.querySelectorAll('.mat-button-toggle'),
height
),
listScrolls:
!!list && list.scrollHeight > list.clientHeight,
};
});
expect(chrome.search, JSON.stringify(chrome)).toBeCloseTo(36, 0);
expect(chrome.toggles.length, JSON.stringify(chrome)).toBe(3);
for (const toggle of chrome.toggles) {
expect(toggle, JSON.stringify(chrome)).toBeGreaterThanOrEqual(
30
);
}
expect(chrome.listScrolls, JSON.stringify(chrome)).toBe(true);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
if (viewport) await page.setViewportSize(viewport);
// A delete from the keyboard: the popover closes, since its
// focus trap would keep Tab from Undo; Undo takes the focus and
// gives it back to the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Shift+F10');
const rowMenu = page.locator('.mat-mdc-menu-panel');
await expect(rowMenu).toBeVisible();
await page.keyboard.press('End');
await expect(
rowMenu.getByRole('menuitem', { name: /Remove/ })
).toBeFocused();
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
const undo = page.locator('.source-removal-toast__undo');
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(undo).toHaveCount(0);
await expect(trigger).toBeFocused();
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(3);
await expect(
page.locator('app-workspace-sources')
).not.toContainText(defaultXtreamPassword);
} finally {
await closeElectronApp(app);
}
@@ -446,12 +600,12 @@ test.describe('Electron Playlist Switcher', () => {
});
async function openPlaylistSwitcher(page: Page): Promise<void> {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first()
).toBeVisible();
await page.getByTestId('playlist-switcher-trigger').click();
await expect(switcherOptions(page).first()).toBeVisible();
}
function switcherOptions(page: Page) {
return page.locator('app-playlist-switcher-panel [role="option"]');
}
async function switchPlaylistFromHeader(
@@ -459,16 +613,11 @@ async function switchPlaylistFromHeader(
title: string
): Promise<void> {
await openPlaylistSwitcher(page);
const item = page
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
const item = switcherOptions(page).filter({ hasText: title }).first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
0
);
await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
@@ -503,16 +652,21 @@ async function selectSettingsOption(
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({
timeout: 20000,
});
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -31,7 +31,7 @@ type RowRing = {
function readRowRing(page: Page): Promise<RowRing> {
return page.evaluate(() => {
const focused = document.activeElement as HTMLElement;
const row = focused.closest('.playlist-item') as HTMLElement;
const row = focused.closest('.source-row') as HTMLElement;
const probe = document.createElement('span');
probe.style.color = 'var(--app-focus-ring)';
row.append(probe);
@@ -93,16 +93,19 @@ test.describe('Electron Sources keyboard', () => {
timeout: 30_000,
});
await openSources(page);
await expect(page.locator('app-playlist-item')).toHaveCount(2, {
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(2, {
timeout: 30_000,
});
// Bravo opened last, so it is the active source; open the other.
const row = sourceRowByTitle(page, 'keyboard-alpha').first();
await expect(row.locator('.playlist-item')).not.toHaveClass(
/\bselected\b/
await expect(row.locator('.source-row__body')).not.toHaveAttribute(
'aria-current',
'true'
);
const title = (
await row.locator('.playlist-title').textContent()
await row.locator('.source-row__name').textContent()
)?.trim();
expect(title).toBeTruthy();
@@ -153,7 +156,7 @@ test.describe('Electron Sources keyboard', () => {
await expect(open).not.toBeFocused();
expect(
await row
.locator('.playlist-item')
.locator('.source-row')
.evaluate((el) => getComputedStyle(el).outlineStyle)
).toBe('none');
await page.keyboard.press('Shift+Tab');
@@ -172,4 +175,69 @@ test.describe('Electron Sources keyboard', () => {
await closeElectronApp(app);
}
});
test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({
dataDir,
}) => {
const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [
{
groupTitle: 'News',
name: 'Undo Alpha News',
url: 'https://streams.example.test/undo-alpha.m3u8',
},
]);
const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [
{
groupTitle: 'Sports',
name: 'Undo Bravo Sports',
url: 'https://streams.example.test/undo-bravo.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
appArgs: [alpha, bravo],
});
const page = app.mainWindow;
const rows = page.locator('app-workspace-sources app-source-row');
try {
await page.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30_000,
});
await openSources(page);
await expect(rows).toHaveCount(2, { timeout: 30_000 });
const before = await rows
.locator('.source-row__name')
.allTextContents();
// Focus the first row's ⋯ and open its menu from the keyboard.
const row = rows.first();
await row.locator('.source-row__more').focus();
await page.keyboard.press('Enter');
const remove = page.getByRole('menuitem', {
name: 'Remove playlist',
});
await expect(remove).toBeVisible();
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
// The row leaves at once; Undo takes the focus the row had.
await expect(rows).toHaveCount(1);
const undo = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__undo'
);
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(rows).toHaveCount(2);
expect(
await rows.locator('.source-row__name').allTextContents()
).toEqual(before);
await expect(
page.locator('mat-snack-bar-container.app-toast')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
+27 -16
View File
@@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator(
'app-source-health-indicator [data-state]'
'.source-tile__dot[data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator(
'app-source-health-indicator [data-state]'
'.source-tile__dot[data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
await expect(
@@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8
try {
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
await waitForM3uCatalog(app.mainWindow);
// Refresh for the active source lives in its switcher row menu.
await app.mainWindow.getByTestId('playlist-switcher-trigger').click();
const currentRow = app.mainWindow.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"]'
);
await currentRow.locator('.source-row__more').click();
await expect(
app.mainWindow.locator(
'app-playlist-switcher .trigger-refresh-button'
)
app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' })
).toBeVisible();
await app.mainWindow.keyboard.press('Escape');
await app.mainWindow.keyboard.press('Escape');
await expect(
app.mainWindow.locator('app-playlist-switcher-panel')
).toHaveCount(0);
await goToDashboard(app.mainWindow);
const sourceCard = app.mainWindow
@@ -822,8 +831,8 @@ https://streams.example.test/original-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
.first()
.locator('.refresh-btn')
).toBeVisible();
.locator('.source-row__refresh')
).toBeAttached();
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
{
@@ -865,8 +874,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
.first()
.locator('.meta')
).toContainText('Updated:');
.locator('.source-row__meta')
).toContainText('updated');
await waitForSourceRowIdle(
app.mainWindow,
'refresh-url-source.m3u'
@@ -876,9 +885,11 @@ https://streams.example.test/refreshed-url.m3u8
'refresh-url-source.m3u'
).first();
await expect(
refreshedUrlRow.locator('.playlist-item')
).not.toHaveClass(/is-busy/);
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled();
refreshedUrlRow.locator('.source-row')
).not.toHaveClass(/source-row--busy/);
await expect(
refreshedUrlRow.locator('.source-row__refresh')
).toBeEnabled();
await refreshedUrlRow
.getByText('refresh-url-source.m3u', { exact: true })
.click();
@@ -904,8 +915,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
.first()
.locator('.meta')
).toContainText('Updated:');
.locator('.source-row__meta')
).toContainText('updated');
} finally {
await closeElectronApp(app);
await urlServer.close();
@@ -0,0 +1,132 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
/**
* Leaving a portal section for the dashboard must replace the routed page
* in one task. With the @angular/animations renderer the router inserted the
* dashboard at once but the old page (and the context panel) left only at
* the end of the next, rAF-coalesced change detection: in between Chromium
* laid out the dashboard below the portal page, at y=720 in a 720 px window,
* and scored its jump into view as a layout shift of 0.337 at 1280 px.
*/
type RouteSwapProbe = {
overlaps: string[][];
shiftSources: string[];
};
declare global {
interface Window {
__routeSwapProbe?: RouteSwapProbe;
}
}
/** Sources whose shift the old page's late removal caused. */
const SWAP_SHIFT_SOURCES = [
'lib-workspace-dashboard-rails',
'main.workspace-content',
];
async function armRouteSwapProbe(page: Page): Promise<void> {
await page.evaluate(() => {
const content = document.querySelector('main.workspace-content');
if (!content) {
throw new Error('workspace content pane is missing');
}
const probe: RouteSwapProbe = { overlaps: [], shiftSources: [] };
window.__routeSwapProbe = probe;
// Mutation callbacks run at the microtask checkpoint after the
// router's activation, before any frame or deferred change detection.
new MutationObserver(() => {
const pages = [...content.children]
.filter((child) => child.tagName !== 'ROUTER-OUTLET')
.map((child) => child.tagName.toLowerCase());
if (pages.length > 1) {
probe.overlaps.push(pages);
}
}).observe(content, { childList: true });
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const sources =
(entry as PerformanceEntry & {
sources?: { node?: Node | null }[];
}).sources ?? [];
for (const { node } of sources) {
if (node instanceof Element) {
const className = node.classList.contains(
'workspace-content'
)
? '.workspace-content'
: '';
probe.shiftSources.push(
node.tagName.toLowerCase() + className
);
}
}
}
}).observe({ type: 'layout-shift' });
});
}
async function waitForTwoFrames(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => setTimeout(resolve, 0))
)
)
);
}
test.describe('Workspace route swap', () => {
test('replaces a portal page with the dashboard without a frame showing both', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setContentSize(1280, 720);
});
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod/);
await expect(
page.locator('app-workspace-shell-context-sidebar')
).toBeVisible();
await armRouteSwapProbe(page);
await goToDashboard(page);
await expect(
page.locator('lib-workspace-dashboard-rails')
).toBeVisible();
await expect(
page.locator('app-workspace-shell-context-sidebar')
).toHaveCount(0);
await waitForTwoFrames(page);
const probe = await page.evaluate(() => window.__routeSwapProbe);
expect(probe?.overlaps).toEqual([]);
expect(
probe?.shiftSources.filter((source) =>
SWAP_SHIFT_SOURCES.includes(source)
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
});
});
@@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20';
const dbWorkerRowsPerTransaction = '100';
function playlistRowByName(name: string) {
return `app-playlist-item:has-text("${name}")`;
return `app-workspace-sources app-source-row:has-text("${name}")`;
}
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
@@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => {
);
await expect(playlistRow).toBeVisible({ timeout: 20000 });
await playlistRow.locator('.delete-btn').click();
const confirmDialog = app.mainWindow.locator(
'mat-dialog-container'
);
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole('button', { name: 'Remove playlist', exact: true })
// Delete from the row menu; the delete starts when the Undo
// toast closes, and the row comes back to show its progress.
await playlistRow.hover();
await playlistRow.locator('.source-row__more').click();
await app.mainWindow
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.click();
await waitForDbOperationEvent(app.mainWindow, {
@@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => {
timeoutMs: 20000,
});
await expect(
playlistRow.locator('.busy-state__message')
).toBeVisible({ timeout: 20000 });
const cancel = playlistRow.locator('.cancel-btn');
await expect(playlistRow.locator('.source-row__busy')).toBeVisible({
timeout: 20000,
});
const cancel = playlistRow.locator('.source-row__cancel');
await expect(cancel).toBeVisible();
// The row's action buttons inherit its color; the cancel action
// must still read as destructive on the selected row.
+2 -6
View File
@@ -111,16 +111,12 @@ test('@a11y icon-only buttons are named in Sources and the playlist info dialog'
}) => {
await addM3uUrlPlaylist(page, M3U_TITLE);
// Sources list: refresh, details and remove on each row.
// Sources list: refresh, edit and the actions menu on each row.
await openSources(page);
const row = sourceRowByTitle(page, M3U_TITLE);
await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover();
for (const name of [
'Refresh playlist',
'Show playlist details',
'Remove playlist',
]) {
for (const name of ['Refresh playlist', 'Edit…', 'Playlist actions']) {
await expect(row.getByRole('button', { name })).toBeAttached();
}
await expectNamedButtons(page, 3);
@@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise<string> {
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
await expect(page.getByText('1 channels')).toBeVisible();
await expect(page.getByText('1 channel', { exact: false })).toBeVisible();
return page.url();
}
+1 -1
View File
@@ -259,7 +259,7 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
channel = 'Refreshed UA Channel';
const row = sourceRowByTitle(page, 'Protected M3U');
await row.hover();
await row.locator('.refresh-btn').click();
await row.locator('.source-row__refresh').click();
await expect(
page.locator('.mat-mdc-snack-bar-label').last()
).toContainText('updated');
+5 -2
View File
@@ -190,7 +190,10 @@ export async function openSources(page: Page): Promise<void> {
}
export function sourceRowByTitle(page: Page, title: string): Locator {
return page.locator('app-playlist-item').filter({ hasText: title }).first();
return page
.locator('app-workspace-sources app-source-row')
.filter({ hasText: title })
.first();
}
export async function openSourceEditor(
@@ -200,7 +203,7 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover();
await row.locator('.edit-btn').click();
await row.locator('.source-row__edit').click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
return dialog;
+7 -4
View File
@@ -182,12 +182,15 @@ test('@stalker account info dialog shows subscription facts from the portal', as
}) => {
await addStalkerPortal(page);
// Open the header playlist switcher and its "Account info" entry for
// the active stalker portal.
await page.locator('.playlist-switcher-trigger').click();
// Open the header playlist switcher and the "Account info" entry in the
// active stalker portal's row menu.
await page.locator('[data-test-id="playlist-switcher-trigger"]').click();
await page
.locator('.context-action-item', { hasText: 'Account info' })
.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more'
)
.click();
await page.getByRole('menuitem', { name: 'Account info' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
+4 -7
View File
@@ -61,18 +61,15 @@ async function openPlaylistDetailsDialog(page: Page, title: string) {
await playlistSettingsButton.click();
} else {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.locator('[data-test-id="playlist-switcher-trigger"]')
.click();
const switcherMenu = page.getByRole('menu').filter({ hasText: title });
const sourceRow = switcherMenu
.locator('.playlist-item')
const sourceRow = page
.locator('app-playlist-switcher-panel [role="option"]')
.filter({ hasText: title })
.first();
await expect(sourceRow).toBeVisible();
await sourceRow
.getByRole('button', { name: 'Playlist actions' })
.click();
await sourceRow.locator('.source-row__more').click();
await page.getByRole('menuitem', { name: 'Playlist info' }).click();
}
@@ -7,7 +7,6 @@ import {
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
@@ -23,7 +22,7 @@ import { SERVER_SIGN_IN_PATH } from '@iptvnator/services';
@Component({
selector: 'app-startup-status',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatProgressSpinnerModule, TranslatePipe],
imports: [MatButtonModule, TranslatePipe],
template: `
@if (!complete()) {
<section
@@ -49,7 +48,40 @@ import { SERVER_SIGN_IN_PATH } from '@iptvnator/services';
/>
</svg>
@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>
{{
@@ -125,6 +157,148 @@ import { SERVER_SIGN_IN_PATH } from '@iptvnator/services';
margin-top: 8px;
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 {
@@ -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 { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateNotificationCardComponent } from './app-update-notification-card.component';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
@@ -62,9 +69,34 @@ describe('AppUpdateNotificationPanelComponent', () => {
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();
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(
fixture.nativeElement.querySelector(
@@ -73,10 +105,27 @@ describe('AppUpdateNotificationPanelComponent', () => {
).not.toBeNull();
});
it('opens release notes without dismissing the notification', async () => {
statusHandler?.(availableStatus);
it('hides the notification when it is dismissed', async () => {
await showUpdate(availableStatus);
(
fixture.nativeElement.querySelector(
'.app-update-notification__header button'
) as HTMLButtonElement
).click();
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(
'[data-test-id="app-update-notification-release-notes"]'
@@ -104,12 +153,12 @@ describe('AppUpdateNotificationPanelComponent', () => {
const error = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
fixture.componentInstance.loadReleaseNotesDialog = () =>
await showUpdate(availableStatus);
const notification = card();
notification.loadReleaseNotesDialog = () =>
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(open).toHaveBeenCalledWith(
@@ -123,8 +172,7 @@ describe('AppUpdateNotificationPanelComponent', () => {
});
it('starts downloading the update from the notification action', async () => {
statusHandler?.(availableStatus);
fixture.detectChanges();
await showUpdate(availableStatus);
(
fixture.nativeElement.querySelector(
@@ -134,15 +182,47 @@ describe('AppUpdateNotificationPanelComponent', () => {
await fixture.whenStable();
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);
statusHandler?.({
await showUpdate({
...availableStatus,
supportedSelfUpdate: false,
});
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification-download"]'
@@ -1,204 +1,78 @@
import {
ChangeDetectionStrategy,
Component,
computed,
OnDestroy,
OnInit,
signal,
inject,
ChangeDetectionStrategy,
} 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';
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({
selector: 'app-update-notification-panel',
imports: [MatButtonModule, MatIconModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AppUpdateNotificationCardComponent, TranslatePipe],
template: `
@if (isVisible(); as visible) {
<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)="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>
}
@defer (when visibleStatus()) {
@if (visibleStatus(); as current) {
<app-update-notification-card
[status]="current"
(statusChange)="status.set($event)"
(dismissed)="dismiss()"
/>
}
} @error {
@if (visibleStatus()) {
<div class="app-update-notification-unavailable" role="alert">
{{ 'SETTINGS.APP_UPDATE_PANEL_UNAVAILABLE' | translate }}
</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);
styles: `
.app-update-notification-unavailable {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 901;
width: 320px;
max-width: calc(100vw - 40px);
padding: 10px 12px;
border: 1px solid var(--app-separator);
border-radius: 12px;
background: var(--app-widget-bg);
color: var(--app-body-color);
font-size: 0.88rem;
line-height: 1.4;
}
@media (max-width: 560px) {
.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 {
private readonly dialog = inject(MatDialog);
private readonly installService = inject(AppUpdateInstallService);
private unsubscribeStatus: (() => void) | null = null;
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
readonly status = signal<ElectronBridgeAppUpdateStatus | 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(() => {
const status = this.status();
@@ -216,6 +90,10 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
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 {
if (!window.electron?.getAppUpdateStatus) {
@@ -237,74 +115,6 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
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 {
this.dismissedVersion.set(this.status()?.latestVersion ?? null);
}
+33
View File
@@ -0,0 +1,33 @@
import { ANIMATION_MODULE_TYPE, RendererFactory2 } from '@angular/core';
type ProviderEntry = { provide?: unknown };
function flattenProviders(providers: readonly unknown[]): ProviderEntry[] {
return providers.flatMap((provider) => {
if (Array.isArray(provider)) {
return flattenProviders(provider);
}
// makeEnvironmentProviders() wraps its list in ɵproviders.
const wrapped = (provider as { ɵproviders?: unknown[] } | null)
?.ɵproviders;
return wrapped
? flattenProviders(wrapped)
: [provider as ProviderEntry];
});
}
describe('appConfig', () => {
// The @angular/animations renderer queues every DOM removal until the
// end of the next change detection, while the router inserts a routed
// page at once: leaving a portal for the dashboard laid out both pages
// until the coalesced tick and scored the swap as a layout shift.
it('keeps the default DOM renderer, so routed pages swap in one task', async () => {
const { appConfig } = await import('./app.config');
const tokens = flattenProviders(appConfig.providers).map(
(provider) => provider?.provide
);
expect(tokens).not.toContain(RendererFactory2);
expect(tokens).not.toContain(ANIMATION_MODULE_TYPE);
});
});
+6 -7
View File
@@ -9,8 +9,6 @@ import {
OverlayContainer,
} from '@angular/cdk/overlay';
import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
import { provideEffects } from '@ngrx/effects';
@@ -107,7 +105,12 @@ export const appConfig: ApplicationConfig = {
providers: [
...changeDetectionProviders,
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
// <body>, which the browser hides behind an element in DOM fullscreen.
// This container follows the fullscreen element instead, so the
@@ -162,9 +165,5 @@ export const appConfig: ApplicationConfig = {
...provideParentalLockPrompt(),
provideParentalLockPlaylistCleanup(),
...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) {
this.updateM3uPlaylistFromFile(
// Settles when the refresh does, so callers keep the source busy.
await this.updateM3uPlaylistFromFile(
payload as {
id: string;
filePath?: string;
@@ -1,22 +1,22 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { firstValueFrom } from 'rxjs';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPrompt,
ParentalLockPromptRequest,
} from '@iptvnator/services';
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
/**
* Application implementation of the parental lock prompt: the shared PIN
* dialog from `@iptvnator/ui/components`, which the data-access lock
* service cannot import itself. The dialog is loaded on first use (the
* service is constructed at startup, the dialog is not needed until a PIN
* is asked for).
* service cannot import itself. The dialog, and Material's dialog service
* with it, is loaded on first use (the service is constructed at startup,
* the dialog is not needed until a PIN is asked for).
*/
@Injectable({ providedIn: 'root' })
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. */
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
@@ -25,8 +25,12 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
request: ParentalLockPromptRequest
): Promise<string | null> {
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
let dialog: Awaited<ReturnType<LazyMatDialog['resolve']>>;
try {
module = await this.loadPinDialog();
[dialog, module] = await Promise.all([
this.lazyDialog.resolve(),
this.loadPinDialog(),
]);
} catch (error) {
// A failed chunk load reads as a cancelled prompt: the gate it
// guards stays closed.
@@ -36,17 +40,14 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
);
return null;
}
const dialogRef = module.ParentalLockPinDialogComponent.open(
this.dialog,
{
mode: request.mode,
verify: request.verify,
throttle: request.throttle,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
submitKey: request.submitKey,
}
);
const dialogRef = module.ParentalLockPinDialogComponent.open(dialog, {
mode: request.mode,
verify: request.verify,
throttle: request.throttle,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
submitKey: request.submitKey,
});
const pin = await firstValueFrom(dialogRef.afterClosed());
return typeof pin === 'string' ? pin : null;
}
+10 -6
View File
@@ -1,5 +1,4 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DataService } from '@iptvnator/services';
import {
ExternalPlayerSession,
@@ -10,13 +9,14 @@ import {
VideoPlayer,
} 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';
@Injectable({
providedIn: 'root',
})
export class PlayerService {
private dialog = inject(MatDialog);
private lazyDialog = inject(LazyMatDialog);
private dataService = inject(DataService);
private settingsStore = inject(SettingsStore);
@@ -85,12 +85,16 @@ export class PlayerService {
/**
* 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
* 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 {
this.loadExternalPlayerInfoDialog()
.then(({ ExternalPlayerInfoDialogComponent }) =>
this.dialog.open(ExternalPlayerInfoDialogComponent)
Promise.all([
this.lazyDialog.resolve(),
this.loadExternalPlayerInfoDialog(),
])
.then(([dialog, { ExternalPlayerInfoDialogComponent }]) =>
dialog.open(ExternalPlayerInfoDialogComponent)
)
.catch((error: unknown) => {
// Informational only: playback is already starting.
@@ -206,6 +206,55 @@ describe('PwaService', () => {
}
});
it('settles a playlist refresh only once the backend has answered', async () => {
let settled = false;
const pending = Promise.resolve(
service.sendIpcEvent(PLAYLIST_UPDATE, {
id: 'playlist-1',
url: 'https://provider.example/list.m3u',
})
).then(() => {
settled = true;
});
http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({
targetId: 'target-1',
});
await new Promise((resolve) => setTimeout(resolve));
// The caller keeps the source busy while the parse is in flight.
expect(settled).toBe(false);
http.expectOne((req) => req.url.endsWith('/parse')).flush({
_id: 'playlist-1',
});
await pending;
expect(settled).toBe(true);
expect(TestBed.inject(Store).dispatch).toHaveBeenCalled();
});
it('settles a failed playlist refresh after reporting it', async () => {
const pending = service.sendIpcEvent(PLAYLIST_UPDATE, {
id: 'playlist-1',
url: 'https://provider.example/list.m3u',
});
http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({
targetId: 'target-1',
});
await new Promise((resolve) => setTimeout(resolve));
http.expectOne((req) => req.url.endsWith('/parse')).flush(
{ message: 'failed', status: 500 },
{ status: 500, statusText: 'Internal Server Error' }
);
await expect(pending).resolves.toBeUndefined();
expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith(
expect.any(String),
'CLOSE',
{ duration: 5000 }
);
});
it('appends the proxy network code to the playlist-refresh failure toast', async () => {
const unhandled: unknown[] = [];
const previousHandler = rxjsConfig.onUnhandledError;
+36 -26
View File
@@ -146,8 +146,10 @@ export class PwaService extends DataService {
}
if (type === PLAYLIST_UPDATE) {
this.refreshPlaylist(payload as Partial<Playlist & { id: string }>);
return undefined as T;
// Settles when the refresh does, so callers keep the source busy.
return this.refreshPlaylist(
payload as Partial<Playlist & { id: string }>
) as T;
}
if (type === XTREAM_REQUEST) {
@@ -231,16 +233,39 @@ export class PwaService extends DataService {
}
}
refreshPlaylist(payload?: Partial<Playlist & { id: string }>) {
/** Resolves once the backend has answered, whether it succeeded or not. */
refreshPlaylist(
payload?: Partial<Playlist & { id: string }>
): Promise<void> {
if (!payload?.url || !payload?.id) {
return;
return Promise.resolve();
}
const playlistId = payload.id;
const { url, userAgent } = payload;
this.getPlaylistFromUrl(payload.url, payload.userAgent)
.pipe(
catchError((error) => {
return new Promise<void>((resolve) => {
this.getPlaylistFromUrl(url, userAgent).subscribe({
next: (playlist: Playlist) => {
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist,
playlistId,
refreshEpg: true,
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
},
error: (error: unknown) => {
// Redacted: the error can carry the playlist URL.
this.logger.error('Playlist refresh error:', error);
this.snackBar.open(
this.appendProviderErrorCode(
this.getPlaylistRefreshErrorMessage(error),
@@ -251,26 +276,11 @@ export class PwaService extends DataService {
duration: 5000,
}
);
return throwError(() => error);
})
)
.subscribe((playlist: Playlist) => {
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist,
playlistId,
refreshEpg: true,
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
resolve();
},
complete: () => resolve(),
});
});
}
private getPlaylistRefreshErrorMessage(error: unknown): string {
@@ -1,7 +1,8 @@
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 { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util/tokens';
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceAccountInfoData,
@@ -10,15 +11,21 @@ import {
WorkspaceStalkerAccountInfoData,
} 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' })
export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
private readonly dialog = inject(MatDialog);
private readonly lazyDialog = inject(LazyMatDialog);
private readonly router = inject(Router);
openAddPlaylistDialog(type?: WorkspacePlaylistType): void {
void import('@iptvnator/playlist/import/feature').then(
({ AddPlaylistDialogComponent }) => {
this.dialog.open(AddPlaylistDialogComponent, {
this.openDialog(
import('@iptvnator/playlist/import/feature'),
(dialog, { AddPlaylistDialogComponent }) => {
dialog.open(AddPlaylistDialogComponent, {
// Width sized for the 5-card method picker plus the
// selected method's form below. Matches the v0.22
// mockup; falls back to viewport-clamped width on
@@ -44,9 +51,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
}
openAccountInfo(data: WorkspaceAccountInfoData): void {
void import('@iptvnator/portal/xtream/feature').then(
({ AccountInfoComponent }) => {
this.dialog.open(AccountInfoComponent, {
this.openDialog(
import('@iptvnator/portal/xtream/feature'),
(dialog, { AccountInfoComponent }) => {
dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
@@ -57,9 +65,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
}
openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void {
void import('@iptvnator/portal/stalker/feature').then(
({ StalkerAccountInfoComponent }) => {
this.dialog.open(StalkerAccountInfoComponent, {
this.openDialog(
import('@iptvnator/portal/stalker/feature'),
(dialog, { StalkerAccountInfoComponent }) => {
dialog.open(StalkerAccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
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[] {
@@ -20,6 +20,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { UpdateChannelOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface SettingsUpdateStatusChip {
readonly labelKey: string;
@@ -28,6 +29,7 @@ interface SettingsUpdateStatusChip {
@Component({
selector: 'app-settings-about-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -23,9 +23,11 @@ import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { TranslateModule } from '@ngx-translate/core';
import { EpgViewModeOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-epg-section',
providers: [provideFormFieldDefaults()],
imports: [
EpgSourceStatusComponent,
MatButtonModule,
@@ -20,9 +20,11 @@ import {
ThemeOption,
} from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-general-section',
providers: [provideFormFieldDefaults()],
imports: [
CommonModule,
MatFormFieldModule,
@@ -9,9 +9,11 @@ import {
} from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-parental-lock-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -22,9 +22,11 @@ import {
} from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-playback-section',
providers: [provideFormFieldDefaults()],
imports: [
CommonModule,
MatButtonModule,
@@ -15,9 +15,11 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-settings-remote-control-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -17,11 +17,13 @@ import { TranslateModule } from '@ngx-translate/core';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
@Component({
selector: 'app-settings-tmdb-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)"
"CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)",
"UPDATED": "آخر تحديث",
"CHANNELS": "الأكثر قنوات",
"FAILING_FIRST": "غير المستجيبة أولاً"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "لم يتم العثور على ملاحظات الإصدار {{version}} على GitHub. ربما تمت إزالة الإصدار أو لم يُنشر بعد.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "تعذّر تحميل ملاحظات الإصدار.",
"APP_UPDATE_PANEL_UNAVAILABLE": "تعذّر عرض إشعار التحديث. تحقّق من التحديثات في إعدادات التطبيق.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار غير متاحة في هذه النسخة من التطبيق.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات",
"APP_UPDATE_CHANNEL": "قناة التحديث",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
"IMPORT_PROGRESS": "استيراد EPG",
"IMPORT_PANEL_UNAVAILABLE": "تعذّر تحميل لوحة استيراد EPG. أعد تشغيل التطبيق لمتابعة تقدّم الاستيراد؛ تبقى حالة كل مصدر في إعدادات EPG.",
"CHANNELS": "قنوات",
"PROGRAMS": "برامج",
"LOADING": "جارٍ التحميل",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "قوائم Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل واحدة",
"PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل"
"PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل",
"ROW": {
"TYPE_M3U_FILE": "M3U · ملف",
"ADDED": "أُضيف {{date}}",
"UPDATED": "حُدِّث {{date}}",
"UNREACHABLE": "لا يستجيب",
"UNREACHABLE_FOR": "لا يستجيب · {{duration}}",
"EXPIRED": "منتهي",
"EDIT": "تعديل…",
"COPY_URL": "نسخ الرابط",
"OPEN": "فتح",
"CHANNELS_ONE": "قناة واحدة"
},
"STATUS": "الحالة",
"STATUS_ANY": "أي حالة",
"STATUS_OK": "تستجيب",
"STATUS_BAD": "لا تستجيب",
"NO_MATCHES": "لا توجد قوائم تطابق عوامل التصفية هذه",
"RESET_FILTERS": "إعادة تعيين عوامل التصفية",
"REMOVED": "تمت إزالة «{{name}}»",
"REMOVED_SWITCHED": "تمت إزالة «{{name}}»، والانتقال إلى «{{next}}»",
"REMOVE_FAILED": "تعذّرت إزالة «{{name}}»",
"ADD_SOURCE": "إضافة مصدر"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "نقل تحديد لوحة الأوامر",
"COMMAND_PALETTE_RUN": "تشغيل عنصر لوحة الأوامر المحدد",
"CLOSE_DIALOGS": "إغلاق مربع الحوار أو إخفاء التراكب",
"DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة"
"DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة",
"OPEN_PLAYLIST_SWITCHER": "تبديل قائمة التشغيل"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "أضف قائمة تشغيل وسيظهر سجل المشاهدة هنا.",
"NO_ITEMS_TITLE": "لا توجد مشاهدات بعد",
"NO_ITEMS_BODY": "ابدأ بمشاهدة أي محتوى من مكتبتك وسيظهر هنا."
},
"SWITCHER": {
"SEARCH": "ابحث عن قائمة تشغيل…",
"RECENT": "الأخيرة",
"NO_MATCHES": "لم يُعثر على شيء",
"ALL_SOURCES": "كل المصادر",
"HINT_SELECT": "تحديد",
"HINT_OPEN": "فتح",
"HINT_CLOSE": "إغلاق"
}
},
"LOADING": "جارٍ التحميل",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)"
"CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)",
"UPDATED": "آخر تحديث",
"CHANNELS": "اللي فيها كتر القنوات",
"FAILING_FIRST": "اللي ما كيجاوبوش اللولين"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "ما لقيناش ملاحظات الإصدار {{version}} فـ GitHub. يمكن الإصدار تحيد ولا مازال ما تنشرش.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "ما قدرناش نحمّلو ملاحظات الإصدار.",
"APP_UPDATE_PANEL_UNAVAILABLE": "ما قدرناش نعرضو إشعار التحديث. تأكد من التحديثات فإعدادات التطبيق.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار ما متوفراش فهاد النسخة.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات",
"APP_UPDATE_CHANNEL": "قناة التحديث",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG تجلب بنجاح",
"IMPORT_PROGRESS": "استيراد EPG",
"IMPORT_PANEL_UNAVAILABLE": "ما قدرناش نحمّلو لوحة استيراد EPG. عاود شغّل التطبيق باش تشوف تقدّم الاستيراد؛ حالة كل مصدر كتبقى فإعدادات EPG.",
"CHANNELS": "قنوات",
"PROGRAMS": "برامج",
"LOADING": "جاري التشريج",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "قوائم تشغيل Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل وحدة",
"PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل"
"PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل",
"ROW": {
"TYPE_M3U_FILE": "M3U · فيشي",
"ADDED": "تزاد {{date}}",
"UPDATED": "تحدّث {{date}}",
"UNREACHABLE": "ما كيجاوبش",
"UNREACHABLE_FOR": "ما كيجاوبش · {{duration}}",
"EXPIRED": "سالات",
"EDIT": "بدّل…",
"COPY_URL": "نسخ الرابط",
"OPEN": "حلّ",
"CHANNELS_ONE": "قناة وحدة"
},
"STATUS": "الحالة",
"STATUS_ANY": "أي حالة",
"STATUS_OK": "كيجاوبو",
"STATUS_BAD": "ما كيجاوبوش",
"NO_MATCHES": "حتى ليستة ما كتوافق هاد الفلاتر",
"RESET_FILTERS": "رجّع الفلاتر",
"REMOVED": "تحيّدات «{{name}}»",
"REMOVED_SWITCHED": "تحيّدات «{{name}}»، دوّزنا لـ «{{next}}»",
"REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»",
"ADD_SOURCE": "زيد مصدر"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "حرك الاختيار فلوحة الأوامر",
"COMMAND_PALETTE_RUN": "شغل العنصر المختار فلوحة الأوامر",
"CLOSE_DIALOGS": "سد النافذة ولا خبي الطبقة",
"DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة"
"DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة",
"OPEN_PLAYLIST_SWITCHER": "بدّل الليستة"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "زيد قائمة تشغيل وسجل التفرج ديالك غادي يبان هنا.",
"NO_ITEMS_TITLE": "ما تشاف حتى شي حاجة دابا",
"NO_ITEMS_BODY": "بدا تفرج فشي حاجة من المكتبة ديالك وغادي تبان هنا."
},
"SWITCHER": {
"SEARCH": "قلّب على ليستة…",
"RECENT": "الجداد",
"NO_MATCHES": "ما لقينا والو",
"ALL_SOURCES": "كاع المصادر",
"HINT_SELECT": "ختار",
"HINT_OPEN": "حلّ",
"HINT_CLOSE": "سدّ"
}
},
"LOADING": "كيتشارجا",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Назва (Я-А)",
"NEWEST": "Дата дадання (спачатку новыя)",
"OLDEST": "Дата дадання (спачатку старыя)",
"CUSTOM_ORDER": "Уласны парадак (перацягванне)"
"CUSTOM_ORDER": "Уласны парадак (перацягванне)",
"UPDATED": "Апошняе абнаўленне",
"CHANNELS": "Колькасць каналаў",
"FAILING_FIRST": "Спачатку тыя, што не адказваюць"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (лакальны, URL, тэкст)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Навейшая версія",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заўвагі да версіі {{version}} на GitHub не знойдзены. Рэліз мог быць выдалены або яшчэ не апублікаваны.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не ўдалося загрузіць заўвагі да версіі.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Не ўдалося паказаць апавяшчэнне пра абнаўленне. Праверце наяўнасць абнаўленняў у наладах праграмы.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заўвагі да версіі недаступныя ў гэтай зборцы.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Адкрыць старонку рэлізаў",
"APP_UPDATE_CHANNEL": "Канал абнаўленняў",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG быў паспяхова загружаны",
"IMPORT_PROGRESS": "Імпарт EPG",
"IMPORT_PANEL_UNAVAILABLE": "Не ўдалося загрузіць панэль імпарту EPG. Перазапусціце праграму, каб бачыць ход імпарту; стан кожнай крыніцы застаецца ў наладах EPG.",
"CHANNELS": "каналаў",
"PROGRAMS": "перадач",
"LOADING": "Загрузка",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker плэйлісты",
"ALL_PLAYLISTS": "Усе плэйлісты",
"PLAYLIST_COUNT_ONE": "1 плэйліст",
"PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў"
"PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў",
"ROW": {
"TYPE_M3U_FILE": "M3U · файл",
"ADDED": "дададзены {{date}}",
"UPDATED": "абноўлены {{date}}",
"UNREACHABLE": "Не адказвае",
"UNREACHABLE_FOR": "Не адказвае · {{duration}}",
"EXPIRED": "Скончыўся",
"EDIT": "Змяніць…",
"COPY_URL": "Скапіяваць URL",
"OPEN": "Адкрыць",
"CHANNELS_ONE": "1 канал"
},
"STATUS": "Статус",
"STATUS_ANY": "Любы",
"STATUS_OK": "Адказваюць",
"STATUS_BAD": "Не адказваюць",
"NO_MATCHES": "Няма плэйлістоў пад гэтыя фільтры",
"RESET_FILTERS": "Скінуць фільтры",
"REMOVED": "Выдалены «{{name}}»",
"REMOVED_SWITCHED": "Выдалены «{{name}}», адкрыты «{{next}}»",
"REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»",
"ADD_SOURCE": "Дадаць крыніцу"
},
"CONTEXT": {
"CATEGORIES": "Катэгорыі",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Перамясціць выбар у палітры каманд",
"COMMAND_PALETTE_RUN": "Выканаць выбраны элемент палітры каманд",
"CLOSE_DIALOGS": "Закрыць дыялог або схаваць накладку",
"DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы"
"DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы",
"OPEN_PLAYLIST_SWITCHER": "Пераключыць плэйліст"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Дадайце плэйліст, і гісторыя прагляду з’явіцца тут.",
"NO_ITEMS_TITLE": "Праглядаў пакуль няма",
"NO_ITEMS_BODY": "Пачніце глядзець што-небудзь з бібліятэкі, і гэта з’явіцца тут."
},
"SWITCHER": {
"SEARCH": "Знайсці плэйліст…",
"RECENT": "Нядаўнія",
"NO_MATCHES": "Нічога не знойдзена",
"ALL_SOURCES": "Усе крыніцы",
"HINT_SELECT": "выбар",
"HINT_OPEN": "адкрыць",
"HINT_CLOSE": "закрыць"
}
},
"LOADING": "Загрузка",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Name (Z-A)",
"NEWEST": "Hinzugefügt (Neueste zuerst)",
"OLDEST": "Hinzugefügt (Älteste zuerst)",
"CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)"
"CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)",
"UPDATED": "Zuletzt aktualisiert",
"CHANNELS": "Meiste Sender",
"FAILING_FIRST": "Nicht erreichbare zuerst"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokal, URL, Text)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Release-Seite öffnen",
"APP_UPDATE_CHANNEL": "Update-Kanal",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen",
"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",
"PROGRAMS": "Programme",
"LOADING": "Wird geladen",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker-Playlists",
"ALL_PLAYLISTS": "Alle Playlists",
"PLAYLIST_COUNT_ONE": "1 Playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} Playlists"
"PLAYLIST_COUNT_OTHER": "{{count}} Playlists",
"ROW": {
"TYPE_M3U_FILE": "M3U · Datei",
"ADDED": "hinzugefügt am {{date}}",
"UPDATED": "aktualisiert am {{date}}",
"UNREACHABLE": "Antwortet nicht",
"UNREACHABLE_FOR": "Antwortet nicht · {{duration}}",
"EXPIRED": "Abgelaufen",
"EDIT": "Bearbeiten…",
"COPY_URL": "URL kopieren",
"OPEN": "Öffnen",
"CHANNELS_ONE": "1 Sender"
},
"STATUS": "Status (Erreichbarkeit)",
"STATUS_ANY": "Alle",
"STATUS_OK": "Erreichbar",
"STATUS_BAD": "Nicht erreichbar",
"NO_MATCHES": "Keine Playlists passen zu diesen Filtern",
"RESET_FILTERS": "Filter zurücksetzen",
"REMOVED": "„{{name}}“ entfernt",
"REMOVED_SWITCHED": "„{{name}}“ entfernt, zu „{{next}}“ gewechselt",
"REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden",
"ADD_SOURCE": "Quelle hinzufügen"
},
"CONTEXT": {
"CATEGORIES": "Kategorien",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Auswahl in der Befehlspalette verschieben",
"COMMAND_PALETTE_RUN": "Ausgewählten Eintrag der Befehlspalette ausführen",
"CLOSE_DIALOGS": "Dialog schließen oder Overlay ausblenden",
"DOWNLOADS_OPEN_ITEM": "Fokussierten Download-Eintrag in der Bibliothek öffnen"
"DOWNLOADS_OPEN_ITEM": "Fokussierten Download-Eintrag in der Bibliothek öffnen",
"OPEN_PLAYLIST_SWITCHER": "Playlist wechseln"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Füge eine Wiedergabeliste hinzu, und dein Wiedergabeverlauf erscheint hier.",
"NO_ITEMS_TITLE": "Noch nichts angesehen",
"NO_ITEMS_BODY": "Beginne, etwas aus deiner Bibliothek anzusehen, dann erscheint es hier."
},
"SWITCHER": {
"SEARCH": "Playlist suchen…",
"RECENT": "Zuletzt geöffnet",
"NO_MATCHES": "Nichts gefunden",
"ALL_SOURCES": "Alle Quellen",
"HINT_SELECT": "auswählen",
"HINT_OPEN": "öffnen",
"HINT_CLOSE": "schließen"
}
},
"LOADING": "Laden",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Όνομα (Z-A)",
"NEWEST": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)",
"OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)",
"CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)"
"CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)",
"UPDATED": "Τελευταία ενημέρωση",
"CHANNELS": "Περισσότερα κανάλια",
"FAILING_FIRST": "Πρώτα όσα δεν αποκρίνονται"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (τοπικό, διεύθυνση url, κείμενο)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Νεότερη έκδοση",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Δεν βρέθηκαν σημειώσεις έκδοσης για την {{version}} στο GitHub. Η έκδοση μπορεί να αφαιρέθηκε ή να μην έχει δημοσιευτεί ακόμη.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των σημειώσεων έκδοσης.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Δεν ήταν δυνατή η εμφάνιση της ειδοποίησης ενημέρωσης. Ελέγξτε για ενημερώσεις στις ρυθμίσεις της εφαρμογής.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Οι σημειώσεις έκδοσης δεν είναι διαθέσιμες σε αυτή τη δομή.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Άνοιγμα σελίδας εκδόσεων",
"APP_UPDATE_CHANNEL": "Κανάλι ενημερώσεων",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία",
"IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)",
"IMPORT_PANEL_UNAVAILABLE": "Δεν ήταν δυνατή η φόρτωση του πίνακα εισαγωγής EPG. Επανεκκινήστε την εφαρμογή για να παρακολουθήσετε την εισαγωγή· η κατάσταση κάθε πηγής παραμένει στις ρυθμίσεις EPG.",
"CHANNELS": "κανάλια",
"PROGRAMS": "προγράμματα",
"LOADING": "Φόρτωση",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Λίστες αναπαραγωγής Stalker",
"ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής",
"PLAYLIST_COUNT_ONE": "1 λίστα αναπαραγωγής",
"PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής"
"PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής",
"ROW": {
"TYPE_M3U_FILE": "M3U · αρχείο",
"ADDED": "προστέθηκε {{date}}",
"UPDATED": "ενημερώθηκε {{date}}",
"UNREACHABLE": "Δεν αποκρίνεται",
"UNREACHABLE_FOR": "Δεν αποκρίνεται · {{duration}}",
"EXPIRED": "Έληξε",
"EDIT": "Επεξεργασία…",
"COPY_URL": "Αντιγραφή URL",
"OPEN": "Άνοιγμα",
"CHANNELS_ONE": "1 κανάλι"
},
"STATUS": "Κατάσταση",
"STATUS_ANY": "Οποιαδήποτε",
"STATUS_OK": "Αποκρίνονται",
"STATUS_BAD": "Δεν αποκρίνονται",
"NO_MATCHES": "Καμία λίστα δεν ταιριάζει με αυτά τα φίλτρα",
"RESET_FILTERS": "Επαναφορά φίλτρων",
"REMOVED": "Καταργήθηκε το «{{name}}»",
"REMOVED_SWITCHED": "Καταργήθηκε το «{{name}}», μετάβαση στο «{{next}}»",
"REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»",
"ADD_SOURCE": "Προσθήκη πηγής"
},
"CONTEXT": {
"CATEGORIES": "Κατηγορίες",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Μετακίνηση επιλογής παλέτας εντολών",
"COMMAND_PALETTE_RUN": "Εκτέλεση επιλεγμένου στοιχείου παλέτας εντολών",
"CLOSE_DIALOGS": "Κλείσιμο διαλόγου ή απόκρυψη overlay",
"DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη"
"DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη",
"OPEN_PLAYLIST_SWITCHER": "Εναλλαγή λίστας"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Προσθέστε μια λίστα αναπαραγωγής και το ιστορικό προβολής σας θα εμφανιστεί εδώ.",
"NO_ITEMS_TITLE": "Δεν έχει παρακολουθηθεί τίποτα ακόμη",
"NO_ITEMS_BODY": "Ξεκινήστε να παρακολουθείτε οτιδήποτε από τη βιβλιοθήκη σας και θα εμφανιστεί εδώ."
},
"SWITCHER": {
"SEARCH": "Εύρεση λίστας…",
"RECENT": "Πρόσφατα",
"NO_MATCHES": "Δεν βρέθηκε τίποτα",
"ALL_SOURCES": "Όλες οι πηγές",
"HINT_SELECT": "επιλογή",
"HINT_OPEN": "άνοιγμα",
"HINT_CLOSE": "κλείσιμο"
}
},
"LOADING": "Φόρτωση",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Name (Z-A)",
"NEWEST": "Date added (Newest first)",
"OLDEST": "Date added (Oldest first)",
"CUSTOM_ORDER": "Custom order (Drag & Drop)"
"CUSTOM_ORDER": "Custom order (Drag & Drop)",
"UPDATED": "Last updated",
"CHANNELS": "Most channels",
"FAILING_FIRST": "Not responding first"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, text)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Open releases page",
"APP_UPDATE_CHANNEL": "Update channel",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG was fetched successfully",
"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",
"PROGRAMS": "programs",
"LOADING": "Loading",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker Playlists",
"ALL_PLAYLISTS": "All Playlists",
"PLAYLIST_COUNT_ONE": "1 playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} playlists"
"PLAYLIST_COUNT_OTHER": "{{count}} playlists",
"ROW": {
"TYPE_M3U_FILE": "M3U · file",
"ADDED": "added {{date}}",
"UPDATED": "updated {{date}}",
"UNREACHABLE": "Not responding",
"UNREACHABLE_FOR": "Not responding · {{duration}}",
"EXPIRED": "Expired",
"EDIT": "Edit…",
"COPY_URL": "Copy URL",
"OPEN": "Open",
"CHANNELS_ONE": "1 channel"
},
"STATUS": "Status",
"STATUS_ANY": "Any",
"STATUS_OK": "Responding",
"STATUS_BAD": "Not responding",
"NO_MATCHES": "No playlists match these filters",
"RESET_FILTERS": "Reset filters",
"REMOVED": "Removed “{{name}}”",
"REMOVED_SWITCHED": "Removed “{{name}}”, switched to “{{next}}”",
"REMOVE_FAILED": "Could not remove “{{name}}”",
"ADD_SOURCE": "Add a source"
},
"CONTEXT": {
"CATEGORIES": "Categories",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Move command palette selection",
"COMMAND_PALETTE_RUN": "Run selected command palette item",
"CLOSE_DIALOGS": "Close dialog or dismiss overlay",
"DOWNLOADS_OPEN_ITEM": "Open focused download item in the library"
"DOWNLOADS_OPEN_ITEM": "Open focused download item in the library",
"OPEN_PLAYLIST_SWITCHER": "Switch playlist"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Add a playlist and your watching history will appear here.",
"NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
},
"SWITCHER": {
"SEARCH": "Find a playlist…",
"RECENT": "Recent",
"NO_MATCHES": "Nothing found",
"ALL_SOURCES": "All sources",
"HINT_SELECT": "select",
"HINT_OPEN": "open",
"HINT_CLOSE": "close"
}
},
"LOADING": "Loading",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nombre (Z-A)",
"NEWEST": "Fecha de adición (más recientes primero)",
"OLDEST": "Fecha de adición (más antiguas primero)",
"CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)"
"CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)",
"UPDATED": "Última actualización",
"CHANNELS": "Más canales",
"FAILING_FIRST": "Primero las que no responden"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Abrir página de versiones",
"APP_UPDATE_CHANNEL": "Canal de actualizaciones",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG obtenida correctamente",
"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",
"PROGRAMS": "programas",
"LOADING": "Cargando",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Listas Stalker",
"ALL_PLAYLISTS": "Todas las listas",
"PLAYLIST_COUNT_ONE": "1 lista de reproducción",
"PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción"
"PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción",
"ROW": {
"TYPE_M3U_FILE": "M3U · archivo",
"ADDED": "añadido el {{date}}",
"UPDATED": "actualizado el {{date}}",
"UNREACHABLE": "No responde",
"UNREACHABLE_FOR": "No responde · {{duration}}",
"EXPIRED": "Caducada",
"EDIT": "Editar…",
"COPY_URL": "Copiar URL",
"OPEN": "Abrir",
"CHANNELS_ONE": "1 canal"
},
"STATUS": "Estado",
"STATUS_ANY": "Cualquiera",
"STATUS_OK": "Responden",
"STATUS_BAD": "No responden",
"NO_MATCHES": "Ninguna lista coincide con estos filtros",
"RESET_FILTERS": "Restablecer filtros",
"REMOVED": "Se eliminó «{{name}}»",
"REMOVED_SWITCHED": "Se eliminó «{{name}}» y se cambió a «{{next}}»",
"REMOVE_FAILED": "No se pudo eliminar «{{name}}»",
"ADD_SOURCE": "Añadir una fuente"
},
"CONTEXT": {
"CATEGORIES": "Categorías",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Mover la selección de la paleta de comandos",
"COMMAND_PALETTE_RUN": "Ejecutar el elemento seleccionado de la paleta de comandos",
"CLOSE_DIALOGS": "Cerrar diálogo o descartar superposición",
"DOWNLOADS_OPEN_ITEM": "Abrir en la biblioteca el elemento de descarga enfocado"
"DOWNLOADS_OPEN_ITEM": "Abrir en la biblioteca el elemento de descarga enfocado",
"OPEN_PLAYLIST_SWITCHER": "Cambiar de lista"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Añade una lista de reproducción y tu historial de visualización aparecerá aquí.",
"NO_ITEMS_TITLE": "Aún no has visto nada",
"NO_ITEMS_BODY": "Empieza a ver cualquier contenido de tu biblioteca y aparecerá aquí."
},
"SWITCHER": {
"SEARCH": "Buscar una lista…",
"RECENT": "Recientes",
"NO_MATCHES": "No se encontró nada",
"ALL_SOURCES": "Todas las fuentes",
"HINT_SELECT": "elegir",
"HINT_OPEN": "abrir",
"HINT_CLOSE": "cerrar"
}
},
"LOADING": "Cargando",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nom (Z-A)",
"NEWEST": "Date d'ajout (Plus récent)",
"OLDEST": "Date d'ajout (Plus ancien)",
"CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)"
"CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)",
"UPDATED": "Dernière mise à jour",
"CHANNELS": "Plus de chaînes",
"FAILING_FIRST": "Celles qui ne répondent pas d'abord"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, URL, texte)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Ouvrir la page des versions",
"APP_UPDATE_CHANNEL": "Canal de mise à jour",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès",
"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",
"PROGRAMS": "programmes",
"LOADING": "Chargement",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Listes de lecture Stalker",
"ALL_PLAYLISTS": "Toutes les listes de lecture",
"PLAYLIST_COUNT_ONE": "1 liste de lecture",
"PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture"
"PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture",
"ROW": {
"TYPE_M3U_FILE": "M3U · fichier",
"ADDED": "ajouté le {{date}}",
"UPDATED": "mis à jour le {{date}}",
"UNREACHABLE": "Ne répond pas",
"UNREACHABLE_FOR": "Ne répond pas · {{duration}}",
"EXPIRED": "Expiré",
"EDIT": "Modifier…",
"COPY_URL": "Copier l’URL",
"OPEN": "Ouvrir",
"CHANNELS_ONE": "1 chaîne"
},
"STATUS": "État",
"STATUS_ANY": "Tous",
"STATUS_OK": "Répondent",
"STATUS_BAD": "Ne répondent pas",
"NO_MATCHES": "Aucune playlist ne correspond à ces filtres",
"RESET_FILTERS": "Réinitialiser les filtres",
"REMOVED": "« {{name}} » supprimée",
"REMOVED_SWITCHED": "« {{name}} » supprimée, passage à « {{next}} »",
"REMOVE_FAILED": "Impossible de supprimer « {{name}} »",
"ADD_SOURCE": "Ajouter une source"
},
"CONTEXT": {
"CATEGORIES": "Catégories",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Déplacer la sélection dans la palette de commandes",
"COMMAND_PALETTE_RUN": "Exécuter l’élément sélectionné dans la palette de commandes",
"CLOSE_DIALOGS": "Fermer une boîte de dialogue ou masquer une superposition",
"DOWNLOADS_OPEN_ITEM": "Ouvrir l’élément de téléchargement ciblé dans la bibliothèque"
"DOWNLOADS_OPEN_ITEM": "Ouvrir l’élément de téléchargement ciblé dans la bibliothèque",
"OPEN_PLAYLIST_SWITCHER": "Changer de playlist"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Ajoutez une playlist et votre historique de visionnage apparaîtra ici.",
"NO_ITEMS_TITLE": "Rien regardé pour le moment",
"NO_ITEMS_BODY": "Commencez à regarder un contenu de votre bibliothèque et il apparaîtra ici."
},
"SWITCHER": {
"SEARCH": "Trouver une playlist…",
"RECENT": "Récentes",
"NO_MATCHES": "Aucun résultat",
"ALL_SOURCES": "Toutes les sources",
"HINT_SELECT": "choisir",
"HINT_OPEN": "ouvrir",
"HINT_CLOSE": "fermer"
}
},
"LOADING": "Chargement",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Név szerint (Z–A)",
"NEWEST": "Hozzáadás dátuma (legújabb elöl)",
"OLDEST": "Hozzáadás dátuma (legrégebbi elöl)",
"CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)"
"CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)",
"UPDATED": "Legutóbbi frissítés",
"CHANNELS": "Legtöbb csatorna",
"FAILING_FIRST": "Nem válaszolók elöl"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (helyi fájl, URL vagy szöveg)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Kiadások oldalának megnyitása",
"APP_UPDATE_CHANNEL": "Frissítési csatorna",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "Az EPG lekérése sikerült",
"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",
"PROGRAMS": "műsor",
"LOADING": "Betöltés",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker-lejátszási listák",
"ALL_PLAYLISTS": "Összes lejátszási lista",
"PLAYLIST_COUNT_ONE": "1 lejátszási lista",
"PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista"
"PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista",
"ROW": {
"TYPE_M3U_FILE": "M3U · fájl",
"ADDED": "hozzáadva: {{date}}",
"UPDATED": "frissítve: {{date}}",
"UNREACHABLE": "Nem válaszol",
"UNREACHABLE_FOR": "Nem válaszol · {{duration}}",
"EXPIRED": "Lejárt",
"EDIT": "Szerkesztés…",
"COPY_URL": "URL másolása",
"OPEN": "Megnyitás",
"CHANNELS_ONE": "1 csatorna"
},
"STATUS": "Állapot",
"STATUS_ANY": "Bármelyik",
"STATUS_OK": "Válaszolnak",
"STATUS_BAD": "Nem válaszolnak",
"NO_MATCHES": "Egyik lejátszási lista sem felel meg ezeknek a szűrőknek",
"RESET_FILTERS": "Szűrők törlése",
"REMOVED": "„{{name}}” eltávolítva",
"REMOVED_SWITCHED": "„{{name}}” eltávolítva, váltás erre: „{{next}}”",
"REMOVE_FAILED": "„{{name}}” nem távolítható el",
"ADD_SOURCE": "Forrás hozzáadása"
},
"CONTEXT": {
"CATEGORIES": "Kategóriák",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Kijelölés mozgatása a parancspalettán",
"COMMAND_PALETTE_RUN": "A parancspalettán kijelölt művelet végrehajtása",
"CLOSE_DIALOGS": "Párbeszédpanel bezárása vagy fedőréteg eltüntetése",
"DOWNLOADS_OPEN_ITEM": "A fókuszban lévő letöltés megnyitása a médiatárban"
"DOWNLOADS_OPEN_ITEM": "A fókuszban lévő letöltés megnyitása a médiatárban",
"OPEN_PLAYLIST_SWITCHER": "Lejátszási lista váltása"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Adjon hozzá egy lejátszási listát, és megtekintési előzményei itt fognak megjelenni.",
"NO_ITEMS_TITLE": "Még nincs megtekintett tartalom",
"NO_ITEMS_BODY": "Indítson el bármit a médiatárából, és itt meg fog jelenni."
},
"SWITCHER": {
"SEARCH": "Lejátszási lista keresése…",
"RECENT": "Legutóbbiak",
"NO_MATCHES": "Nincs találat",
"ALL_SOURCES": "Összes forrás",
"HINT_SELECT": "kijelölés",
"HINT_OPEN": "megnyitás",
"HINT_CLOSE": "bezárás"
}
},
"LOADING": "Betöltés",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data aggiunta (prima più recenti)",
"OLDEST": "Data aggiunta (prima più vecchi)",
"CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)"
"CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)",
"UPDATED": "Ultimo aggiornamento",
"CHANNELS": "Più canali",
"FAILING_FIRST": "Prima quelle che non rispondono"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (locale, URL, testo)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Apri la pagina delle versioni",
"APP_UPDATE_CHANNEL": "Canale di aggiornamento",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG scaricato correttamente",
"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",
"PROGRAMS": "programmi",
"LOADING": "Caricamento",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Playlist Stalker",
"ALL_PLAYLISTS": "Tutte le playlist",
"PLAYLIST_COUNT_ONE": "1 playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} playlist"
"PLAYLIST_COUNT_OTHER": "{{count}} playlist",
"ROW": {
"TYPE_M3U_FILE": "M3U · file locale",
"ADDED": "aggiunto il {{date}}",
"UPDATED": "aggiornato il {{date}}",
"UNREACHABLE": "Non risponde",
"UNREACHABLE_FOR": "Non risponde · {{duration}}",
"EXPIRED": "Scaduto",
"EDIT": "Modifica…",
"COPY_URL": "Copia URL",
"OPEN": "Apri",
"CHANNELS_ONE": "1 canale"
},
"STATUS": "Stato",
"STATUS_ANY": "Qualsiasi",
"STATUS_OK": "Rispondono",
"STATUS_BAD": "Non rispondono",
"NO_MATCHES": "Nessuna playlist corrisponde a questi filtri",
"RESET_FILTERS": "Reimposta filtri",
"REMOVED": "«{{name}}» rimossa",
"REMOVED_SWITCHED": "«{{name}}» rimossa, passaggio a «{{next}}»",
"REMOVE_FAILED": "Impossibile rimuovere «{{name}}»",
"ADD_SOURCE": "Aggiungi una fonte"
},
"CONTEXT": {
"CATEGORIES": "Categorie",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Sposta selezione della palette comandi",
"COMMAND_PALETTE_RUN": "Esegui elemento selezionato della palette comandi",
"CLOSE_DIALOGS": "Chiudi dialogo o nascondi overlay",
"DOWNLOADS_OPEN_ITEM": "Apri nella libreria l’elemento download selezionato"
"DOWNLOADS_OPEN_ITEM": "Apri nella libreria l’elemento download selezionato",
"OPEN_PLAYLIST_SWITCHER": "Cambia playlist"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Aggiungi una playlist e la tua cronologia di visione apparirà qui.",
"NO_ITEMS_TITLE": "Ancora nessun contenuto guardato",
"NO_ITEMS_BODY": "Inizia a guardare qualsiasi contenuto della tua libreria e apparirà qui."
},
"SWITCHER": {
"SEARCH": "Trova una playlist…",
"RECENT": "Recenti",
"NO_MATCHES": "Nessun risultato",
"ALL_SOURCES": "Tutte le fonti",
"HINT_SELECT": "scegli",
"HINT_OPEN": "apri",
"HINT_CLOSE": "chiudi"
}
},
"LOADING": "Caricamento",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名前(Z-A)",
"NEWEST": "追加日(新しい順)",
"OLDEST": "追加日(古い順)",
"CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)"
"CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)",
"UPDATED": "最終更新",
"CHANNELS": "チャンネル数",
"FAILING_FIRST": "応答なしを先頭に"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(ローカル、URL、テキスト)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "新しいリリース",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "{{version}} のリリースノートが GitHub に見つかりません。リリースが削除されたか、まだ公開されていない可能性があります。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "リリースノートを読み込めませんでした。",
"APP_UPDATE_PANEL_UNAVAILABLE": "更新の通知を表示できませんでした。アプリの設定で更新を確認してください。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "このビルドではリリースノートを利用できません。",
"APP_UPDATE_OPEN_RELEASES_PAGE": "リリースページを開く",
"APP_UPDATE_CHANNEL": "更新チャンネル",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPGの取得に成功しました",
"IMPORT_PROGRESS": "EPGインポート",
"IMPORT_PANEL_UNAVAILABLE": "EPGインポートパネルを読み込めませんでした。インポートの進行状況を確認するにはアプリを再起動してください。各ソースの状態はEPG設定で確認できます。",
"CHANNELS": "チャンネル",
"PROGRAMS": "番組",
"LOADING": "読み込み中",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalkerプレイリスト",
"ALL_PLAYLISTS": "すべてのプレイリスト",
"PLAYLIST_COUNT_ONE": "1 件のプレイリスト",
"PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト"
"PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト",
"ROW": {
"TYPE_M3U_FILE": "M3U · ファイル",
"ADDED": "{{date}}に追加",
"UPDATED": "{{date}}に更新",
"UNREACHABLE": "応答なし",
"UNREACHABLE_FOR": "応答なし · {{duration}}",
"EXPIRED": "期限切れ",
"EDIT": "編集…",
"COPY_URL": "URLをコピー",
"OPEN": "開く",
"CHANNELS_ONE": "1 チャンネル"
},
"STATUS": "状態",
"STATUS_ANY": "すべて",
"STATUS_OK": "応答あり",
"STATUS_BAD": "応答なし",
"NO_MATCHES": "この条件に一致するプレイリストはありません",
"RESET_FILTERS": "フィルターをリセット",
"REMOVED": "「{{name}}」を削除しました",
"REMOVED_SWITCHED": "「{{name}}」を削除し、「{{next}}」に切り替えました",
"REMOVE_FAILED": "「{{name}}」を削除できませんでした",
"ADD_SOURCE": "ソースを追加"
},
"CONTEXT": {
"CATEGORIES": "カテゴリー",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "コマンドパレットの選択を移動",
"COMMAND_PALETTE_RUN": "選択中のコマンドパレット項目を実行",
"CLOSE_DIALOGS": "ダイアログを閉じる、またはオーバーレイを解除",
"DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く"
"DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く",
"OPEN_PLAYLIST_SWITCHER": "プレイリストを切り替え"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "プレイリストを追加すると、視聴履歴がここに表示されます。",
"NO_ITEMS_TITLE": "視聴履歴はまだありません",
"NO_ITEMS_BODY": "ライブラリ内のコンテンツを視聴し始めると、ここに表示されます。"
},
"SWITCHER": {
"SEARCH": "プレイリストを検索…",
"RECENT": "最近",
"NO_MATCHES": "見つかりませんでした",
"ALL_SOURCES": "すべてのソース",
"HINT_SELECT": "選択",
"HINT_OPEN": "開く",
"HINT_CLOSE": "閉じる"
}
},
"LOADING": "読み込み中",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "이름 (Z-A)",
"NEWEST": "추가 날짜 (최신순)",
"OLDEST": "추가 날짜 (오래된순)",
"CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)"
"CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)",
"UPDATED": "최근 업데이트",
"CHANNELS": "채널 많은 순",
"FAILING_FIRST": "응답 없는 항목 먼저"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (로컬, URL, 텍스트)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "이후 릴리스",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub에서 {{version}}의 릴리스 노트를 찾을 수 없습니다. 릴리스가 삭제되었거나 아직 게시되지 않았을 수 있습니다.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "릴리스 노트를 불러올 수 없습니다.",
"APP_UPDATE_PANEL_UNAVAILABLE": "업데이트 알림을 표시할 수 없습니다. 앱 설정에서 업데이트를 확인하세요.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "이 빌드에서는 릴리스 노트를 사용할 수 없습니다.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "릴리스 페이지 열기",
"APP_UPDATE_CHANNEL": "업데이트 채널",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다",
"IMPORT_PROGRESS": "EPG 가져오기",
"IMPORT_PANEL_UNAVAILABLE": "EPG 가져오기 패널을 불러올 수 없습니다. 가져오기 진행 상황을 보려면 앱을 다시 시작하세요. 각 소스의 상태는 EPG 설정에서 확인할 수 있습니다.",
"CHANNELS": "채널",
"PROGRAMS": "프로그램",
"LOADING": "불러오는 중",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker 재생목록",
"ALL_PLAYLISTS": "모든 재생목록",
"PLAYLIST_COUNT_ONE": "1개 재생목록",
"PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록"
"PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록",
"ROW": {
"TYPE_M3U_FILE": "M3U · 파일",
"ADDED": "{{date}} 추가됨",
"UPDATED": "{{date}} 업데이트됨",
"UNREACHABLE": "응답 없음",
"UNREACHABLE_FOR": "응답 없음 · {{duration}}",
"EXPIRED": "만료됨",
"EDIT": "편집…",
"COPY_URL": "URL 복사",
"OPEN": "열기",
"CHANNELS_ONE": "채널 1개"
},
"STATUS": "상태",
"STATUS_ANY": "전체",
"STATUS_OK": "응답함",
"STATUS_BAD": "응답 없음",
"NO_MATCHES": "이 필터와 일치하는 재생목록이 없습니다",
"RESET_FILTERS": "필터 초기화",
"REMOVED": "'{{name}}' 삭제됨",
"REMOVED_SWITCHED": "'{{name}}' 삭제됨, '{{next}}'(으)로 전환됨",
"REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다",
"ADD_SOURCE": "소스 추가"
},
"CONTEXT": {
"CATEGORIES": "카테고리",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "명령 팔레트 선택 이동",
"COMMAND_PALETTE_RUN": "선택한 명령 팔레트 항목 실행",
"CLOSE_DIALOGS": "대화상자 닫기 또는 오버레이 해제",
"DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기"
"DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기",
"OPEN_PLAYLIST_SWITCHER": "재생목록 전환"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "플레이리스트를 추가하면 시청 기록이 여기에 표시됩니다.",
"NO_ITEMS_TITLE": "아직 시청 기록이 없습니다",
"NO_ITEMS_BODY": "라이브러리의 콘텐츠를 시청하기 시작하면 여기에 표시됩니다."
},
"SWITCHER": {
"SEARCH": "재생목록 찾기…",
"RECENT": "최근",
"NO_MATCHES": "찾은 항목 없음",
"ALL_SOURCES": "모든 소스",
"HINT_SELECT": "선택",
"HINT_OPEN": "열기",
"HINT_CLOSE": "닫기"
}
},
"LOADING": "로드 중",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Naam (Z-A)",
"NEWEST": "Datum toegevoegd (Nieuwste eerst)",
"OLDEST": "Datum toegevoegd (Oudste eerst)",
"CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)"
"CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)",
"UPDATED": "Laatst bijgewerkt",
"CHANNELS": "Meeste zenders",
"FAILING_FIRST": "Niet-reagerende eerst"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokaal, URL, tekst)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Releasepagina openen",
"APP_UPDATE_CHANNEL": "Updatekanaal",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "TV-Gids bijgewerkt",
"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",
"PROGRAMS": "programma's",
"LOADING": "Laden",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker-afspeellijsten",
"ALL_PLAYLISTS": "Alle afspeellijsten",
"PLAYLIST_COUNT_ONE": "1 afspeellijst",
"PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten"
"PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten",
"ROW": {
"TYPE_M3U_FILE": "M3U · bestand",
"ADDED": "toegevoegd {{date}}",
"UPDATED": "bijgewerkt {{date}}",
"UNREACHABLE": "Reageert niet",
"UNREACHABLE_FOR": "Reageert niet · {{duration}}",
"EXPIRED": "Verlopen",
"EDIT": "Bewerken…",
"COPY_URL": "URL kopiëren",
"OPEN": "Openen",
"CHANNELS_ONE": "1 zender"
},
"STATUS": "Bereikbaarheid",
"STATUS_ANY": "Alle",
"STATUS_OK": "Reageren",
"STATUS_BAD": "Reageren niet",
"NO_MATCHES": "Geen afspeellijsten voldoen aan deze filters",
"RESET_FILTERS": "Filters wissen",
"REMOVED": "‘{{name}}’ verwijderd",
"REMOVED_SWITCHED": "‘{{name}}’ verwijderd, overgeschakeld naar ‘{{next}}’",
"REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd",
"ADD_SOURCE": "Bron toevoegen"
},
"CONTEXT": {
"CATEGORIES": "Categorieën",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Selectie in opdrachtpalet verplaatsen",
"COMMAND_PALETTE_RUN": "Geselecteerd item in opdrachtpalet uitvoeren",
"CLOSE_DIALOGS": "Dialoogvenster sluiten of overlay verbergen",
"DOWNLOADS_OPEN_ITEM": "Gefocust downloaditem in de bibliotheek openen"
"DOWNLOADS_OPEN_ITEM": "Gefocust downloaditem in de bibliotheek openen",
"OPEN_PLAYLIST_SWITCHER": "Afspeellijst wisselen"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Voeg een afspeellijst toe en uw kijkgeschiedenis verschijnt hier.",
"NO_ITEMS_TITLE": "Nog niets bekeken",
"NO_ITEMS_BODY": "Begin iets uit uw bibliotheek te kijken en het verschijnt hier."
},
"SWITCHER": {
"SEARCH": "Afspeellijst zoeken…",
"RECENT": "Onlangs geopend",
"NO_MATCHES": "Niets gevonden",
"ALL_SOURCES": "Alle bronnen",
"HINT_SELECT": "kiezen",
"HINT_OPEN": "openen",
"HINT_CLOSE": "sluiten"
}
},
"LOADING": "Laden",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nazwa (Z-A)",
"NEWEST": "Data dodania (najnowsze pierwsze)",
"OLDEST": "Data dodania (najstarsze pierwsze)",
"CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)"
"CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)",
"UPDATED": "Ostatnia aktualizacja",
"CHANNELS": "Najwięcej kanałów",
"FAILING_FIRST": "Najpierw nieodpowiadające"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokalny, URL, tekst)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Otwórz stronę wydań",
"APP_UPDATE_CHANNEL": "Kanał aktualizacji",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG zostało pomyślnie pobrane",
"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",
"PROGRAMS": "programów",
"LOADING": "Ładowanie",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Listy odtwarzania Stalker",
"ALL_PLAYLISTS": "Wszystkie listy odtwarzania",
"PLAYLIST_COUNT_ONE": "1 lista odtwarzania",
"PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania"
"PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania",
"ROW": {
"TYPE_M3U_FILE": "M3U · plik",
"ADDED": "dodano {{date}}",
"UPDATED": "zaktualizowano {{date}}",
"UNREACHABLE": "Nie odpowiada",
"UNREACHABLE_FOR": "Nie odpowiada · {{duration}}",
"EXPIRED": "Wygasło",
"EDIT": "Edytuj…",
"COPY_URL": "Kopiuj URL",
"OPEN": "Otwórz",
"CHANNELS_ONE": "1 kanał"
},
"STATUS": "Stan",
"STATUS_ANY": "Dowolny",
"STATUS_OK": "Odpowiadają",
"STATUS_BAD": "Nie odpowiadają",
"NO_MATCHES": "Żadna playlista nie pasuje do tych filtrów",
"RESET_FILTERS": "Wyczyść filtry",
"REMOVED": "Usunięto „{{name}}”",
"REMOVED_SWITCHED": "Usunięto „{{name}}”, przełączono na „{{next}}”",
"REMOVE_FAILED": "Nie udało się usunąć „{{name}}”",
"ADD_SOURCE": "Dodaj źródło"
},
"CONTEXT": {
"CATEGORIES": "Kategorie",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Przenieś zaznaczenie w palecie poleceń",
"COMMAND_PALETTE_RUN": "Uruchom zaznaczoną pozycję palety poleceń",
"CLOSE_DIALOGS": "Zamknij okno dialogowe lub ukryj nakładkę",
"DOWNLOADS_OPEN_ITEM": "Otwórz aktywne pobranie w bibliotece"
"DOWNLOADS_OPEN_ITEM": "Otwórz aktywne pobranie w bibliotece",
"OPEN_PLAYLIST_SWITCHER": "Przełącz playlistę"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Dodaj listę odtwarzania, a historia oglądania pojawi się tutaj.",
"NO_ITEMS_TITLE": "Jeszcze nic nie oglądano",
"NO_ITEMS_BODY": "Zacznij oglądać cokolwiek ze swojej biblioteki, a pojawi się tutaj."
},
"SWITCHER": {
"SEARCH": "Znajdź playlistę…",
"RECENT": "Ostatnie",
"NO_MATCHES": "Nic nie znaleziono",
"ALL_SOURCES": "Wszystkie źródła",
"HINT_SELECT": "wybór",
"HINT_OPEN": "otwórz",
"HINT_CLOSE": "zamknij"
}
},
"LOADING": "Ładowanie",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data de adição (Mais recente primeiro)",
"OLDEST": "Data de adição (Mais antigo primeiro)",
"CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)"
"CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)",
"UPDATED": "Última atualização",
"CHANNELS": "Mais canais",
"FAILING_FIRST": "Primeiro as que não respondem"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Abrir página de versões",
"APP_UPDATE_CHANNEL": "Canal de atualização",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG foi buscado com sucesso",
"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",
"PROGRAMS": "programas",
"LOADING": "Carregando",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Playlists Stalker",
"ALL_PLAYLISTS": "Todas as playlists",
"PLAYLIST_COUNT_ONE": "1 playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} playlists"
"PLAYLIST_COUNT_OTHER": "{{count}} playlists",
"ROW": {
"TYPE_M3U_FILE": "M3U · arquivo",
"ADDED": "adicionado em {{date}}",
"UPDATED": "atualizado em {{date}}",
"UNREACHABLE": "Não responde",
"UNREACHABLE_FOR": "Não responde · {{duration}}",
"EXPIRED": "Expirada",
"EDIT": "Editar…",
"COPY_URL": "Copiar URL",
"OPEN": "Abrir",
"CHANNELS_ONE": "1 canal"
},
"STATUS": "Estado",
"STATUS_ANY": "Qualquer",
"STATUS_OK": "Respondem",
"STATUS_BAD": "Não respondem",
"NO_MATCHES": "Nenhuma lista corresponde a estes filtros",
"RESET_FILTERS": "Repor filtros",
"REMOVED": "«{{name}}» removida",
"REMOVED_SWITCHED": "«{{name}}» removida, mudou para «{{next}}»",
"REMOVE_FAILED": "Não foi possível remover «{{name}}»",
"ADD_SOURCE": "Adicionar uma fonte"
},
"CONTEXT": {
"CATEGORIES": "Categorias",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Mover seleção da paleta de comandos",
"COMMAND_PALETTE_RUN": "Executar item selecionado da paleta de comandos",
"CLOSE_DIALOGS": "Fechar diálogo ou dispensar sobreposição",
"DOWNLOADS_OPEN_ITEM": "Abrir o download em foco na biblioteca"
"DOWNLOADS_OPEN_ITEM": "Abrir o download em foco na biblioteca",
"OPEN_PLAYLIST_SWITCHER": "Mudar de lista"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Adicione uma playlist e seu histórico de visualização aparecerá aqui.",
"NO_ITEMS_TITLE": "Nada assistido ainda",
"NO_ITEMS_BODY": "Comece a assistir qualquer item da sua biblioteca e ele aparecerá aqui."
},
"SWITCHER": {
"SEARCH": "Procurar uma lista…",
"RECENT": "Recentes",
"NO_MATCHES": "Nada encontrado",
"ALL_SOURCES": "Todas as fontes",
"HINT_SELECT": "escolher",
"HINT_OPEN": "abrir",
"HINT_CLOSE": "fechar"
}
},
"LOADING": "A carregar",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Имени (Я-А)",
"NEWEST": "Дате добавления (сначала новые)",
"OLDEST": "Дате добавления (сначала старые)",
"CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)"
"CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)",
"UPDATED": "Последнему обновлению",
"CHANNELS": "Числу каналов",
"FAILING_FIRST": "Сначала неактивные"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (локально, URL, текст)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "Более поздняя версия",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заметки к версии {{version}} на GitHub не найдены. Релиз мог быть удалён или ещё не опубликован.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не удалось загрузить заметки к версии.",
"APP_UPDATE_PANEL_UNAVAILABLE": "Не удалось показать уведомление об обновлении. Проверьте наличие обновлений в настройках приложения.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заметки к версии недоступны в этой сборке.",
"APP_UPDATE_OPEN_RELEASES_PAGE": "Открыть страницу релизов",
"APP_UPDATE_CHANNEL": "Канал обновлений",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена",
"IMPORT_PROGRESS": "Импорт EPG",
"IMPORT_PANEL_UNAVAILABLE": "Не удалось загрузить панель импорта EPG. Перезапустите приложение, чтобы видеть ход импорта; статус каждого источника остаётся в настройках EPG.",
"CHANNELS": "каналов",
"PROGRAMS": "программ",
"LOADING": "Загрузка",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker плейлисты",
"ALL_PLAYLISTS": "Все плейлисты",
"PLAYLIST_COUNT_ONE": "1 плейлист",
"PLAYLIST_COUNT_OTHER": "{{count}} плейлистов"
"PLAYLIST_COUNT_OTHER": "{{count}} плейлистов",
"ROW": {
"TYPE_M3U_FILE": "M3U · файл",
"ADDED": "добавлен {{date}}",
"UPDATED": "обновлён {{date}}",
"UNREACHABLE": "Не отвечает",
"UNREACHABLE_FOR": "Не отвечает · {{duration}}",
"EXPIRED": "Истёк",
"EDIT": "Изменить…",
"COPY_URL": "Копировать URL",
"OPEN": "Открыть",
"CHANNELS_ONE": "1 канал"
},
"STATUS": "Статус",
"STATUS_ANY": "Любой",
"STATUS_OK": "Отвечают",
"STATUS_BAD": "Не отвечают",
"NO_MATCHES": "Нет плейлистов с такими фильтрами",
"RESET_FILTERS": "Сбросить фильтры",
"REMOVED": "Удалён «{{name}}»",
"REMOVED_SWITCHED": "Удалён «{{name}}», открыт «{{next}}»",
"REMOVE_FAILED": "Не удалось удалить «{{name}}»",
"ADD_SOURCE": "Добавить источник"
},
"CONTEXT": {
"CATEGORIES": "Категории",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Переместить выбор в палитре команд",
"COMMAND_PALETTE_RUN": "Выполнить выбранный пункт палитры команд",
"CLOSE_DIALOGS": "Закрыть диалог или скрыть оверлей",
"DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке"
"DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке",
"OPEN_PLAYLIST_SWITCHER": "Переключить плейлист"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Добавьте плейлист, и история просмотра появится здесь.",
"NO_ITEMS_TITLE": "Пока ничего не просмотрено",
"NO_ITEMS_BODY": "Начните смотреть любой контент из библиотеки, и он появится здесь."
},
"SWITCHER": {
"SEARCH": "Найти плейлист…",
"RECENT": "Недавние",
"NO_MATCHES": "Ничего не найдено",
"ALL_SOURCES": "Все источники",
"HINT_SELECT": "выбор",
"HINT_OPEN": "открыть",
"HINT_CLOSE": "закрыть"
}
},
"LOADING": "Загрузка",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "İsim (Z-A)",
"NEWEST": "Eklenme tarihi (En yeni önce)",
"OLDEST": "Eklenme tarihi (En eski önce)",
"CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)"
"CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)",
"UPDATED": "Son güncelleme",
"CHANNELS": "En çok kanal",
"FAILING_FIRST": "Önce yanıt vermeyenler"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (yerel, url, metin)",
@@ -607,6 +610,7 @@
"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_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_OPEN_RELEASES_PAGE": "Sürümler sayfasını aç",
"APP_UPDATE_CHANNEL": "Güncelleme kanalı",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG verileri başarıyla alındı",
"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",
"PROGRAMS": "programlar",
"LOADING": "Yükleniyor",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker Oynatma Listeleri",
"ALL_PLAYLISTS": "Tüm Oynatma Listeleri",
"PLAYLIST_COUNT_ONE": "1 oynatma listesi",
"PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi"
"PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi",
"ROW": {
"TYPE_M3U_FILE": "M3U · dosya",
"ADDED": "{{date}} eklendi",
"UPDATED": "{{date}} güncellendi",
"UNREACHABLE": "Yanıt vermiyor",
"UNREACHABLE_FOR": "Yanıt vermiyor · {{duration}}",
"EXPIRED": "Süresi doldu",
"EDIT": "Düzenle…",
"COPY_URL": "URL'yi kopyala",
"OPEN": "Aç",
"CHANNELS_ONE": "1 kanal"
},
"STATUS": "Durum",
"STATUS_ANY": "Tümü",
"STATUS_OK": "Yanıt veriyor",
"STATUS_BAD": "Yanıt vermiyor",
"NO_MATCHES": "Bu filtrelere uyan oynatma listesi yok",
"RESET_FILTERS": "Filtreleri sıfırla",
"REMOVED": "“{{name}}” kaldırıldı",
"REMOVED_SWITCHED": "“{{name}}” kaldırıldı, “{{next}}” açıldı",
"REMOVE_FAILED": "“{{name}}” kaldırılamadı",
"ADD_SOURCE": "Kaynak ekle"
},
"CONTEXT": {
"CATEGORIES": "Kategoriler",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "Komut paleti seçimini taşı",
"COMMAND_PALETTE_RUN": "Seçili komut paleti öğesini çalıştır",
"CLOSE_DIALOGS": "İletişim kutusunu kapat veya katmanı gizle",
"DOWNLOADS_OPEN_ITEM": "Odaktaki indirme öğesini kitaplıkta aç"
"DOWNLOADS_OPEN_ITEM": "Odaktaki indirme öğesini kitaplıkta aç",
"OPEN_PLAYLIST_SWITCHER": "Oynatma listesini değiştir"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "Bir oynatma listesi ekleyin; izleme geçmişiniz burada görünecek.",
"NO_ITEMS_TITLE": "Henüz hiçbir şey izlenmedi",
"NO_ITEMS_BODY": "Kitaplığınızdaki herhangi bir şeyi izlemeye başlayın, burada görünecek."
},
"SWITCHER": {
"SEARCH": "Oynatma listesi bul…",
"RECENT": "Son açılanlar",
"NO_MATCHES": "Hiçbir şey bulunamadı",
"ALL_SOURCES": "Tüm kaynaklar",
"HINT_SELECT": "seç",
"HINT_OPEN": "aç",
"HINT_CLOSE": "kapat"
}
},
"LOADING": "Yükleniyor",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名称(Z-A)",
"NEWEST": "添加日期(最新优先)",
"OLDEST": "添加日期(最旧优先)",
"CUSTOM_ORDER": "自定义顺序(拖放)"
"CUSTOM_ORDER": "自定义顺序(拖放)",
"UPDATED": "最近更新",
"CHANNELS": "频道数量",
"FAILING_FIRST": "无响应的优先"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(本地、URL、文本)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "较新版本",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上未找到 {{version}} 的更新说明。该版本可能已被移除或尚未发布。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "无法加载更新说明。",
"APP_UPDATE_PANEL_UNAVAILABLE": "无法显示更新通知。请在应用设置中检查更新。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此构建中没有可用的更新说明。",
"APP_UPDATE_OPEN_RELEASES_PAGE": "打开版本发布页面",
"APP_UPDATE_CHANNEL": "更新频道",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG 获取成功",
"IMPORT_PROGRESS": "EPG 导入",
"IMPORT_PANEL_UNAVAILABLE": "无法加载 EPG 导入面板。请重启应用以查看导入进度;每个来源的状态仍可在 EPG 设置中查看。",
"CHANNELS": "频道",
"PROGRAMS": "节目",
"LOADING": "加载中",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker 播放列表",
"ALL_PLAYLISTS": "所有播放列表",
"PLAYLIST_COUNT_ONE": "1 个播放列表",
"PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表"
"PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表",
"ROW": {
"TYPE_M3U_FILE": "M3U · 文件",
"ADDED": "{{date}} 添加",
"UPDATED": "{{date}} 更新",
"UNREACHABLE": "无响应",
"UNREACHABLE_FOR": "无响应 · {{duration}}",
"EXPIRED": "已过期",
"EDIT": "编辑…",
"COPY_URL": "复制 URL",
"OPEN": "打开",
"CHANNELS_ONE": "1 个频道"
},
"STATUS": "状态",
"STATUS_ANY": "全部",
"STATUS_OK": "有响应",
"STATUS_BAD": "无响应",
"NO_MATCHES": "没有符合这些筛选条件的播放列表",
"RESET_FILTERS": "重置筛选",
"REMOVED": "已删除“{{name}}”",
"REMOVED_SWITCHED": "已删除“{{name}}”,已切换到“{{next}}”",
"REMOVE_FAILED": "无法删除“{{name}}”",
"ADD_SOURCE": "添加来源"
},
"CONTEXT": {
"CATEGORIES": "分类",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "移动命令面板选中项",
"COMMAND_PALETTE_RUN": "运行选中的命令面板项目",
"CLOSE_DIALOGS": "关闭对话框或取消覆盖层",
"DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项"
"DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项",
"OPEN_PLAYLIST_SWITCHER": "切换播放列表"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "添加播放列表后,您的观看历史会显示在这里。",
"NO_ITEMS_TITLE": "还没有观看记录",
"NO_ITEMS_BODY": "开始观看媒体库中的任意内容后,它会显示在这里。"
},
"SWITCHER": {
"SEARCH": "查找播放列表…",
"RECENT": "最近",
"NO_MATCHES": "未找到",
"ALL_SOURCES": "所有来源",
"HINT_SELECT": "选择",
"HINT_OPEN": "打开",
"HINT_CLOSE": "关闭"
}
},
"LOADING": "加载中",
+40 -3
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名稱(Z-A)",
"NEWEST": "新增日期(最新優先)",
"OLDEST": "新增日期(最舊優先)",
"CUSTOM_ORDER": "自訂順序(拖放)"
"CUSTOM_ORDER": "自訂順序(拖放)",
"UPDATED": "最近更新",
"CHANNELS": "頻道數量",
"FAILING_FIRST": "無回應的優先"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(本機、網址、文字)",
@@ -607,6 +610,7 @@
"APP_UPDATE_RELEASE_NOTES_NEWER": "較新版本",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上找不到 {{version}} 的更新說明。該版本可能已被移除或尚未發布。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "無法載入更新說明。",
"APP_UPDATE_PANEL_UNAVAILABLE": "無法顯示更新通知。請在應用程式設定中檢查更新。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此版本未提供更新說明。",
"APP_UPDATE_OPEN_RELEASES_PAGE": "開啟版本發布頁面",
"APP_UPDATE_CHANNEL": "更新頻道",
@@ -1024,6 +1028,7 @@
},
"FETCH_SUCCESS": "EPG 已成功擷取",
"IMPORT_PROGRESS": "EPG 匯入",
"IMPORT_PANEL_UNAVAILABLE": "無法載入 EPG 匯入面板。請重新啟動應用程式以查看匯入進度;每個來源的狀態仍可在 EPG 設定中查看。",
"CHANNELS": "頻道",
"PROGRAMS": "節目",
"LOADING": "載入中",
@@ -1948,7 +1953,29 @@
"STALKER_PLAYLISTS": "Stalker 播放清單",
"ALL_PLAYLISTS": "所有播放清單",
"PLAYLIST_COUNT_ONE": "1 個播放清單",
"PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單"
"PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單",
"ROW": {
"TYPE_M3U_FILE": "M3U · 檔案",
"ADDED": "{{date}} 新增",
"UPDATED": "{{date}} 更新",
"UNREACHABLE": "無回應",
"UNREACHABLE_FOR": "無回應 · {{duration}}",
"EXPIRED": "已過期",
"EDIT": "編輯…",
"COPY_URL": "複製 URL",
"OPEN": "開啟",
"CHANNELS_ONE": "1 個頻道"
},
"STATUS": "狀態",
"STATUS_ANY": "全部",
"STATUS_OK": "有回應",
"STATUS_BAD": "無回應",
"NO_MATCHES": "沒有符合這些篩選條件的播放清單",
"RESET_FILTERS": "重設篩選",
"REMOVED": "已移除「{{name}}」",
"REMOVED_SWITCHED": "已移除「{{name}}」,已切換到「{{next}}」",
"REMOVE_FAILED": "無法移除「{{name}}」",
"ADD_SOURCE": "新增來源"
},
"CONTEXT": {
"CATEGORIES": "類別",
@@ -2017,7 +2044,8 @@
"COMMAND_PALETTE_NAVIGATION": "移動命令面板選取項",
"COMMAND_PALETTE_RUN": "執行選取的命令面板項目",
"CLOSE_DIALOGS": "關閉對話框或取消覆蓋層",
"DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目"
"DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目",
"OPEN_PLAYLIST_SWITCHER": "切換播放清單"
}
},
"GLOBAL_FAVORITES": {
@@ -2040,6 +2068,15 @@
"NO_PLAYLISTS_BODY": "新增播放清單後,您的觀看歷史會顯示在這裡。",
"NO_ITEMS_TITLE": "還沒有觀看記錄",
"NO_ITEMS_BODY": "開始觀看媒體庫中的任意內容後,它會顯示在這裡。"
},
"SWITCHER": {
"SEARCH": "尋找播放清單…",
"RECENT": "最近",
"NO_MATCHES": "找不到",
"ALL_SOURCES": "所有來源",
"HINT_SELECT": "選擇",
"HINT_OPEN": "開啟",
"HINT_CLOSE": "關閉"
}
},
"LOADING": "載入中",
+86 -5
View File
@@ -1,4 +1,5 @@
@use 'sass:map';
@use 'sass:meta';
@use '@angular/material' as mat;
@use 'styles';
@@ -57,9 +58,58 @@ $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.
$material-components: (
core,
card,
progress-bar,
tooltip,
form-field,
input,
select,
dialog,
slide-toggle,
slider,
menu,
list,
checkbox,
button,
icon-button,
fab,
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 ────────────────────────────────────────────────────────────
html {
@include mat.all-component-themes($light-theme);
@include component-themes($light-theme);
// The theme is built with the legacy `define-theme` config, whose
// component mixins emit resolved component tokens but never declare the
@@ -169,8 +219,25 @@ html {
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
--app-rating-color: #7a4a00;
// Source lists (Sources page, playlist switcher) and their popovers.
--app-line-strong: rgba(0, 0, 0, 0.12);
--app-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
// A failing source's tag and destructive menu items.
--app-danger-text: #b3261e;
--app-danger-surface: color-mix(in srgb, #d12c2c 12%, transparent);
--app-neutral-tag-surface: rgba(0, 0, 0, 0.06);
// One selected-row look for side lists and the switcher's current row:
// the Settings navigation's soft tint, plus an outline in light theme
// (selection reads as outline + tint here, not a solid slab). Light
// text stays the heading colour: the blue on its own tint is 3.3:1.
--app-list-selected-bg: var(--app-selection-surface);
--app-list-selected-fg: var(--app-heading-color);
--app-list-selected-outline: var(--app-selection-border);
--app-popover-shadow: 0 24px 70px rgba(15, 20, 28, 0.18);
--app-popover-backdrop: rgba(20, 24, 32, 0.28);
.dark-theme {
@include mat.all-component-colors($dark-theme);
@include component-colors($dark-theme);
// Only the color roles differ per theme: the elevation shadows are
// the same black in both, so html's declarations already apply here.
@include mat.system-level-colors($dark-theme);
@@ -274,13 +341,27 @@ html {
--app-cta-hover-bg: #ffffff;
--app-cta-meta-fg: #5a6274;
--app-rating-color: #ffcf5c;
// Source lists and popovers. The selected-row tokens are
// re-declared so they resolve against this context's selection
// colour, not the light values computed on `html`.
--app-line-strong: rgba(255, 255, 255, 0.1);
--app-danger-text: #ff8d9b;
--app-danger-surface: rgba(255, 91, 110, 0.14);
--app-neutral-tag-surface: rgba(255, 255, 255, 0.08);
--app-list-selected-bg: var(--app-selection-surface);
--app-list-selected-fg: var(--app-selection-color);
--app-list-selected-outline: transparent;
--app-popover-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
--app-popover-backdrop: rgba(4, 6, 10, 0.5);
}
}
// ─── Global refined outlined inputs ──────────────────────────────────────────
// Applied app-wide AFTER mat.all-component-themes so these overrides win the
// cascade. Pairs with MAT_FORM_FIELD_DEFAULT_OPTIONS (outline + dynamic) set
// in app.config.ts.
// Applied app-wide AFTER the component themes so these overrides win the
// cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS
// that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each
// component rendering a form field.
$app-refined-input-density: mat.define-theme((
density: (scale: -2),
));
-4
View File
@@ -238,10 +238,6 @@ body.dark-theme {
overflow-x: hidden !important;
}
.mat-drawer-inner-container {
overflow: hidden !important;
}
.cdk-overlay-backdrop {
backdrop-filter: blur(4px);
}
+175 -18
View File
@@ -38,6 +38,12 @@ Use it when changing existing views or introducing new list-based UI in the work
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss`
- Shared list selection style:
`libs/ui/styles/_nav-list.scss`
- Source row (Sources page and playlist switcher):
`libs/playlist/shared/ui/src/lib/source-row/source-row.component.html`
- UI-kit buttons, menus and segmented control:
`libs/ui/styles/_app-controls.scss`
- Side lists (title, group labels, single-choice rows):
`libs/ui/styles/_side-list.scss`
- Theme tokens:
`apps/web/src/m3-theme.scss`
- Settings surfaces:
@@ -67,6 +73,15 @@ in `apps/web/src/m3-theme.scss`):
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
- `--app-success-color` — done/watched status (episode watched toggle)
- `--app-list-selected-bg` / `--app-list-selected-fg` /
`--app-list-selected-outline` — the chosen row of a side list and the
active source's row on Sources and in the switcher (see Selection Pattern)
- `--app-line-strong` — popover and menu borders, a step above
`--app-widget-header-border`
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag and
destructive menu items; `--app-neutral-tag-surface` for a neutral tag
- `--app-font-mono` — URLs and keyboard hints (JetBrains Mono, 400/500)
- `--app-popover-shadow` / `--app-popover-backdrop` — the switcher popover
Angular Material mixins and Material-component overrides may use the tokens
owned by that component. Outside a Material-owned component, prefer the
@@ -88,6 +103,55 @@ tokens, so the retired `--mdc-*` names compile but do nothing;
a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*`
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` and `fab`).
- **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
`<body>`; a "System" choice resolves the OS preference there and nowhere
else. Component styles never use a `prefers-color-scheme` query, which
@@ -167,6 +231,13 @@ Use this pattern for:
Do not add extra badges, left rails, or second selection systems unless there is a strong reason.
Side lists styled with `_side-list.scss` (the Sources filters) and the
active source's row (Sources page and playlist switcher) use the Settings
navigation's variant
instead, through `--app-list-selected-*`: a soft tint with no lift or glow,
outlined in light theme. Its text is the heading colour in light theme (the
selection blue on its own tint is 3.3:1) and the selection colour in dark.
## Single-Choice Menus And Status Chips
A `mat-menu` that picks one value, such as a sort order or a rating
@@ -192,6 +263,15 @@ threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
- A menu with several groups, such as the catalog refine menu, wraps each
group in `role="group"`. The group is named by its section title, and
`role="separator"` sits between groups.
- Keep a menu short enough to open whole below its trigger in a 600px-tall
window. Material 22 sets no max-height on a menu panel: a taller menu is
pushed up over its own trigger, then clipped. Move a secondary group into
a submenu whose row shows the active value, as the refine menu does with
its rating thresholds.
- Material offsets a submenu by the `offsetTop` of the parent menu's first
direct `mat-menu-item`. When the earlier rows sit inside a `role="group"`,
that first direct item is the submenu trigger itself, and the submenu
opens off screen. Wrap such a trigger in `<div role="none">`.
A chip that shows the active state next to its menu trigger, such as the
catalog sort chip, shows its icon and a short value at every width, for
@@ -1033,6 +1113,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
## 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.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears:
@@ -1304,27 +1391,97 @@ A visual change is not done until:
### Source row keyboard access
A Sources row (`app-playlist-item`) opens on a click anywhere on it, and it
also holds a drag handle, a health indicator and actions. Like
`app-content-card`, it opens from the keyboard through a dedicated element
that is a sibling of those controls: the title and meta block
`.playlist-content` (`role="button"`, `tabindex="0"`, named by the source
title, `aria-current` on the active source, `aria-disabled` while busy).
Enter and Space open the source (Space prevents the page scroll), and Tab
reaches each row before its actions. Never put `role="button"` or key
handlers on the whole row: the actions would sit inside a button, and their
Enter and Space would bubble into it. The row draws the ring for that
element, inset (`$offset: -2px`), because the list's scroller would cut a
ring drawn outside the full-width row. `sources-keyboard.e2e.ts` (Electron)
tabs to a row, checks its ring and opens the source with Enter.
A Sources row (`app-source-row`, regular size) opens on a click anywhere on
it, and it also holds a drag handle and its actions. Like `app-content-card`,
it opens from the keyboard through a dedicated element that is a sibling of
those controls: the title and meta block `.source-row__body`
(`role="button"`, `tabindex="0"`, named by the source's display name,
`aria-current` on the active source, `aria-disabled` while busy). Enter and
Space open the source (Space prevents the page scroll), and Tab reaches each
row before its actions. Never put `role="button"` or key handlers on the whole
row: the actions would sit inside a button, and their Enter and Space would
bubble into it. The row draws the ring for that element, inset
(`$offset: -2px`), because the list's scroller would cut a ring drawn outside
the full-width row. `R` on a focused row refreshes it, as its menu item shows.
`sources-keyboard.e2e.ts` (Electron) tabs to a row, checks its ring, opens
the source with Enter, and deletes and undoes a source keyboard-only.
### Sources page and playlist switcher
One `app-source-row` serves both lists: `size="regular"` on the Sources page
(56px, 36px tile, hover actions Refresh, Edit and ⋯; a failing row keeps
Retry in view; touch screens always show the actions) and `size="compact"`
in the switcher (52px, 32px tile, ⋯ only). In both the active source's row
is tinted with the selected-row token; the switcher adds a check. The page
header (title, Clean up, Sort) stays; only the list below it scrolls. The meta line is always type · channels · added · updated · URL, with
empty parts left out; only M3U playlists store a channel count. The helpers
in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`,
`source-list`) own that text, the filters and the counts.
- **No credentials on screen.** Names come from `sourceDisplayName`: a URL, a
URL-derived name such as `get.php?username=…&password=…` or any name holding
the source's password, MAC or URL secrets becomes the host. URLs come from
`sourceDisplayUrl`, built on `stripUrlCredentials` (beside it in
`source-display`, out of the eager logging barrel): no
userinfo, sensitive query or fragment parameters, or Xtream path
credentials; Xtream shows `scheme://host:port · username`. Copy URL copies
the full address on request.
- **Delete with Undo.** A single delete has no confirmation: the row leaves
every list at once (`SourceRemovalService.pendingIds`) and the delete runs
when the four-second Undo window ends. The window is the service's own
timer, not the toast's: another message replacing the toast (a refresh
finishing elsewhere) does not shorten it. The toast's close button commits
at once, and quitting within the four seconds keeps the source. Deleting the active source
switches to the next row; Undo switches back only if no other source was
chosen since. A desktop Xtream delete brings its row back as "Removing…"
with progress and Cancel on the Sources page; the switcher leaves the
source out until the delete ends. When the delete took the row and with
it the focus, the toast's Undo takes the focus. A delete from the
switcher closes the popover first, since its focus trap would keep the
keyboard from Undo: Undo takes the focus and returns it to the trigger
(`SourceRemovalOptions.returnFocusTo`). A source cannot be removed while
a refresh clears and refills it: every refresh (a Sources row, the
switcher, the Xtream flow, the startup auto-refresh), every Xtream import
and every delete holds the source in the root `SourceActivityService`,
which both lists, the removal service and a second refresh consult. The
Xtream route session takes its hold on the refresh's `NavigationEnd`, so
the catalog is never idle between the delete and the re-import. The Sources page shows
a switcher refresh as busy. Bulk cleanup keeps its confirmation dialog.
- **Side list.** Type and, on desktop, Status (Any / Responding / Not
responding) combine and persist (`SourcesViewStateService`); each group
counts within the other's choice. The page checks every source, not only
the rows a filter shows (`keepSourcesChecked`): a saved status filter
would otherwise hide the unchecked sources it is waiting for.
- **Switcher.** The trigger is a 40px secondary button; ⌘P / Ctrl+P opens it
(⌘K stays the command palette). The popover is a CDK connected overlay, so
row menus open above it, not inside its scrolling list. Focus stays in the
search field, which drives a `role="listbox"` through
`aria-activedescendant`: ↑/↓ move and wrap, Enter opens, Shift+F10 opens
the highlighted row's menu, Escape closes and returns focus to the
trigger. The compact row's ⋯ is pointer-only (`aria-hidden`, not
focusable), since an option may not contain a control. "Recent" lists the
three most recently opened sources once there are six or more. The search
field is the shared 36px search field (`--app-search-*`). The popover is a
height-capped column in which only the list shrinks and scrolls: every
other part is `flex: none`. Otherwise a long list (hundreds of sources)
squeezes them in proportion to its size, and the type filter collapses.
### Network source indicators (Electron)
The switcher and source rows use `SourceHealthIndicatorComponent`: green means
available, orange expired, red disabled/access refused, and neutral means
checking or unverified. Accessible tooltip text distinguishes refusal from a
confirmed disabled account and includes the check time. M3U indicators describe
the source URL only. Check again is a separate action, not a playlist refresh.
The status dot on a source tile reads `SourceStatusService`: green answers,
red does not, orange is an expired account, grey is not checked yet; local
files have no dot. On desktop the live `SourceHealthService` result wins; the
last conclusive result per source is kept across restarts
(`SourceHealthHistoryService`, no URL or credential stored, only a 32-bit
fingerprint of the connection), so a source that failed yesterday shows as
failing before it is checked again, with "Not responding · 3 days". An
edited address or account is a new connection: its kept result no longer
counts, and its history starts over. The dot's tooltip and accessible name give the
check's cause (`SourceStatus.reason`: access denied, account disabled,
timeout…, kept with the history) and check time; M3U checks say they
cover the playlist URL, not its channels. Retry and Check
again rerun the check; they are not a playlist refresh. The browser build
keeps the Xtream portal status only.
### Inactive-source cleanup dialog
+9
View File
@@ -1856,6 +1856,15 @@ shared data access. Electron checks Xtream account info, Stalker account/profile
facts through the existing session, and the first 64 KiB of M3U URL responses.
An M3U success describes the playlist URL, not every channel. Local files and
text imports have no network indicator. PWA retains its existing Xtream path.
Lists read one status per source through `SourceStatusService`, which falls
back to the last conclusive result kept by `SourceHealthHistoryService`
(localStorage, by playlist ID: state, check time, the start of a failing
run, the check's cause and a 32-bit fingerprint of the checked connection;
no URL or credential). A result kept for an edited address or account is
ignored, and the next check starts the history over. Deleting a source or
resetting the library clears its entry. The Sources page keeps every
library source checked while it is open (`keepSourcesChecked`), so a status
filter never hides the sources it is waiting for. See the [UI guidelines](iptvnator-ui-guidelines.md#network-source-indicators-electron).
Checks are demand-driven, never a startup readiness barrier: four at a time,
two per origin, shared between surfaces. Known results remain visible during
+18
View File
@@ -1156,6 +1156,24 @@ every build, which only the `development`, `electron-e2e` and
`performance-build-config.spec.ts` keeps it that way. Removing the static
import lowered the counter by 12,565 bytes.
The bundler keeps each module in one chunk, so one eager import of any
export carries the module's whole code: providing
`MAT_FORM_FIELD_DEFAULT_OPTIONS` in `app.config.ts` alone put the 54 KB
form-field chunk on the initial path, although every form field renders in a
lazy route or dialog. Angular Material therefore stays off the startup path by
construction, with the rules and guards in the
[UI guidelines](iptvnator-ui-guidelines.md#material-off-the-startup-path):
form-field defaults are provided per component (#1928), services created with
the app resolve `MatDialog` through `LazyMatDialog`, the panels the shell
mounts on every launch defer their Material views, the theme emits tokens only
for imported components, and the app does not provide the animations module,
which nothing used (Material 22 animates with CSS). Together these lowered the
counter from 1,596,201 to 1,344,067 bytes (-252,134, 15.8%) on a local
production build at the branch point (#1931); the baseline follows the
measurement of the branch merged with `master`, 1,345,807. The snack-bar stays: root
services inject it, and with it Material's button, the icon button chunk and
the CDK overlay (about 190 KB) remain initial.
`--summary` writes the journey summary shape (`journeys.<journey>.counters`)
that the ratchet checker consumes. `--dist <dir>` points the script at another
build output, for example the `electron-performance` configuration.
+6
View File
@@ -432,6 +432,12 @@ Detail metadata rule:
[Embedded Inline Playback](./embedded-inline-playback.md). It runs before
TMDB enrichment, which keeps a non-empty provider rating and release date,
so a surviving stand-in would also block the TMDB value.
- It also runs AFTER enrichment: the series view re-normalizes the store item
on every render, so it carries every `tmdb_*` field over. The key list
(`TMDB_ENRICHMENT_KEYS`) is typed as a `Record` over `StalkerVodInfo`'s
`tmdb_*` keys, so a new enrichment field fails to compile until it is listed
there instead of silently vanishing from the view (as the genre and country
Discover chips once did).
## Stalker Identity Policy
@@ -832,8 +832,10 @@ Contracts worth keeping:
credits and no recommendations carries none of them and reads as
un-enriched. Since the column is never overwritten, getting this wrong is
unfixable after the fact — a real provider date arriving later cannot
correct it. The same marker is what `trustedReleaseYear` (the
recommendations exclusion index) must consult if it ever reads this column.
correct it. Stalker entries never reach this column, but the same marker
rides in their stored `info`, and `trustedReleaseYear` (the recommendations
exclusion index) reads it first: a date TMDB supplied does not gate the
exact tier. `createStalkerInfo()` keeps the marker through re-normalization.
- **The id is stored unvetted.** Every consumer reaches TMDB through
`TmdbEnrichmentService`, whose `detailsForProviderId` runs
`assessProviderId` and lets the title search take over when the years
+41 -3
View File
@@ -179,9 +179,10 @@ navigation (Back, drawer toggle) changes.
ends: `perf:compositing` reads the same layer and mask counts on every
route as before the slides (#1895).
6. **Not covered.** A slide does not change what the route renders. Going
back from a portal to the dashboard still shows one frame with the new
dashboard below the old page before the old page leaves; that jump is a
routing issue, not a layout slide.
back from a portal to the dashboard used to lay the new dashboard out
below the old page before the old page left, a shift a slide cannot
hide. That was the route swap, fixed at its cause (see
[Route swaps](#route-swaps)).
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
@@ -212,6 +213,40 @@ When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
for the template unless the template contract itself intentionally changes.
### Route swaps
The router's activation runs in one task, outside change detection:
deactivation destroys the old routed view, then activation creates the new
one and inserts its host element into `main.workspace-content` at once.
`WorkspaceShellRouteStateService.currentUrl` is set on `NavigationEnd`, which
the router emits synchronously after activation, so the shell's state does
not lag the outlet. The shell re-renders (the context panel leaves, the
content frame slides) in the next change detection. With event coalescing
that runs in the next `requestAnimationFrame`, before that frame paints.
The app does not provide `provideAnimations()`. Nothing uses
`@angular/animations` triggers (Material animates with CSS), and that
package's renderer queues every DOM removal until the end of the next change
detection, while insertions apply at once. Leaving a portal section for the
dashboard therefore inserted the dashboard below the still-present portal
page, and the old page left only in the coalesced tick. In between, Chromium
laid out the document with both pages, the dashboard at y=720 in a 720 px
window, and scored the swap as one layout shift: 0.337 at 1280 px, 0.394 at
900 px, 0.723 at 420 px (measured with a `layout-shift` observer in the
`electron-performance` build). With the default DOM renderer the old page is
removed in the same microtask, before the new one is inserted, and the swap
records no shift. `app.config.spec.ts` keeps the animations renderer out, and
`workspace-route-swap.e2e.ts` asserts that no mutation checkpoint holds both
pages and that neither the dashboard nor the content pane shifts.
Destroy hooks therefore run with the view's DOM already detached; the
[UI guidelines](./iptvnator-ui-guidelines.md) ("No animations module") say
how hooks record geometry and focus instead.
Do not add `provideAnimations()` or `provideAnimationsAsync()` back; animate
with CSS. A leave animation (`animate.leave` included) delays its element's
removal in the same way, so a routed page must not carry one.
## Header Back
The header's leading slot is the workspace's one page-level Back. Pages do not
@@ -883,6 +918,9 @@ Toolchain notes for the Electron 43 baseline:
Those dependencies require standalone PRs so their dedicated package,
worker, playback, and diagnostic-contract validation cannot be hidden by an
unrelated grouped update.
Electron majors are ignored: Electron 44 ships no `linux-armv7l` binary,
and the release still packages armv7l AppImage, DEB and Snap builds. Remove
the ignore together with those targets when 32-bit ARM support is dropped.
4. Electron 42 and newer download their development binary on the first
Electron command instead of during package `postinstall`, so `electron`
must not remain in pnpm's `onlyBuiltDependencies` allowlist. The first local
+3 -5
View File
@@ -61,7 +61,7 @@ must be ticked here.
## Eager components
65 production files, 66 components (`epg-progress-panel.component.ts` holds
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
two); two have since been deleted (the old Sources row and its health dot). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
(or setting OnPush) once its template state is signals, signal inputs or
explicitly marked. The guard spec compares the unticked entries with the
files whose component metadata still sets
@@ -127,7 +127,7 @@ picker), so each marks itself on the form's `events` through
- [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
- [x] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts`
### libs/playlist (14)
### libs/playlist (12)
- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
@@ -140,9 +140,7 @@ picker), so each marks itself on the form's `events` through
- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
@@ -173,7 +171,7 @@ the field a signal (or a `computed`), or writes it through one.
| --- | --- | --- | --- |
| [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
| [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [x] | `libs/portal/shared/data-access/src/lib/source-status.service.ts` `checkPortal` (replaced the old Sources row's `checkPortalStatus`) | the PWA Xtream portal status lands after `await`; it is written to a signal | playlist |
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist |
| [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
-7
View File
@@ -93,10 +93,3 @@ export const FavoritesActions = createActionGroup({
'Hydrate Favorites': props<{ channelIds: string[] }>(),
},
});
export const FilterActions = createActionGroup({
source: 'Filters',
events: {
'Set Selected Filters': props<{ selectedFilters: string[] }>(),
},
});
@@ -10,7 +10,6 @@ export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
selectedId: string;
allPlaylistsLoaded: boolean;
loadFailed: boolean;
selectedFilters: string[];
}
export const initialPlaylistMetaState: PlaylistMetaState =
@@ -18,7 +17,6 @@ export const initialPlaylistMetaState: PlaylistMetaState =
selectedId: '',
allPlaylistsLoaded: false,
loadFailed: false,
selectedFilters: ['m3u', 'xtream', 'stalker'],
});
export const {
@@ -1,18 +0,0 @@
import { on } from '@ngrx/store';
import { FilterActions } from '../actions';
import { PlaylistState } from '../state';
export const filterReducers = [
on(
FilterActions.setSelectedFilters,
(state, { selectedFilters }): PlaylistState => {
return {
...state,
playlists: {
...state.playlists,
selectedFilters,
},
};
}
),
];
+1 -3
View File
@@ -3,7 +3,6 @@ import { initialState, PlaylistState } from '../state';
import { channelReducers } from './channel.reducers';
import { epgReducers } from './epg.reducers';
import { favoritesReducers } from './favorites.reducers';
import { filterReducers } from './filter.reducers';
import { playlistReducers } from './playlist.reducers';
export const playlistReducer = createReducer(
@@ -11,8 +10,7 @@ export const playlistReducer = createReducer(
...epgReducers,
...channelReducers,
...playlistReducers,
...favoritesReducers,
...filterReducers
...favoritesReducers
);
// Selectors
-5
View File
@@ -63,11 +63,6 @@ export const selectAllPlaylistsMeta = createSelector(
fromPlaylistMetaState.getAllPlaylistsMeta
);
export const selectActiveTypeFilters = createSelector(
selectPlaylistsMetaState,
(state) => state.selectedFilters
);
export const selectPlaylistEntity = (id: string) =>
createSelector(selectPlaylistEntities, (entities) => entities[id]);
@@ -20,6 +20,7 @@ import {
ProviderImportKind,
SOURCE_TYPE_ICONS,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
interface CandidateSummaryRow {
labelKey: string;
@@ -145,6 +146,7 @@ function maskUrlQueryPasswords(url: string): string {
*/
@Component({
selector: 'app-auto-import',
providers: [provideFormFieldDefaults()],
templateUrl: './auto-import.component.html',
styleUrl: './auto-import.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -50,6 +50,7 @@ import {
toStalkerImportFeedback,
toStalkerPlaylistIdentityFields,
} from './stalker-import-identity';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
/**
* A MAC and the device IDs that belong to exactly it. Kept together because
@@ -75,6 +76,7 @@ interface StalkerSettledIdentity {
TranslatePipe,
],
selector: 'app-stalker-portal-import',
providers: [provideFormFieldDefaults()],
templateUrl: './stalker-portal-import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
@@ -12,9 +12,11 @@ import {
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-text-import',
providers: [provideFormFieldDefaults()],
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -15,9 +15,11 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
@Component({
selector: 'app-url-upload',
providers: [provideFormFieldDefaults()],
templateUrl: './url-upload.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
@@ -228,6 +228,26 @@ describe('XtreamCodeImportComponent form', () => {
);
}
it('outlines its fields with a dynamic subscript without a root provider', () => {
// The root injector no longer provides MAT_FORM_FIELD_DEFAULT_OPTIONS
// (it pulled Material's form field and @angular/forms into startup);
// the form carries provideFormFieldDefaults() itself.
const { root } = render();
const fields = Array.from(root.querySelectorAll('mat-form-field'));
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field.classList).toContain(
'mat-form-field-appearance-outline'
);
expect(
field.querySelector(
'.mat-mdc-form-field-subscript-dynamic-size'
)
).not.toBeNull();
}
});
it('labels the name field like the other add-source forms', () => {
const { root } = render();
@@ -30,6 +30,7 @@ import {
normalizeXtreamServerUrl,
Playlist,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
function xtreamServerUrlValidator(
control: AbstractControl
@@ -59,6 +60,7 @@ function xtreamServerUrlValidator(
TranslatePipe,
],
selector: 'app-xtream-code-import',
providers: [provideFormFieldDefaults()],
templateUrl: './xtream-code-import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
+4
View File
@@ -1,5 +1,6 @@
export * from './lib/add-playlist-menu/playlist-type';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/playlist-switcher/playlist-switcher-open.service';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token';
export * from './lib/recent-playlists/recent-playlists.component';
@@ -8,3 +9,6 @@ export * from './lib/playlist-refresh-action.service';
export * from './lib/xtream-refresh-flow.service';
export * from './lib/source-health/source-cleanup-dialog.component';
export * from './lib/source-row/source-row.component';
export * from './lib/source-row/source-tile.component';
export * from './lib/source-removal/source-removal.service';
@@ -312,8 +312,15 @@ describe('PlaylistRefreshActionService', () => {
).toBe(true);
});
it('uses the browser URL refresh path when the refresh bridge is unavailable', () => {
it('uses the browser URL refresh path when the refresh bridge is unavailable', async () => {
runtime.supportsPlaylistRefresh = false;
let answer!: () => void;
dataService.sendIpcEvent.mockReturnValueOnce(
new Promise<void>((resolve) => {
answer = resolve;
})
);
const activity = TestBed.inject(SourceActivityService);
const playlist = createPlaylistMeta({
_id: 'playlist-url',
title: 'URL playlist',
@@ -333,6 +340,15 @@ describe('PlaylistRefreshActionService', () => {
userAgent: 'IPTVnator-Test/1.0',
});
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
// Busy, and held for removal and other refreshes, until it answers.
expect(service.isSourceBusy('playlist-url')).toBe(true);
expect(activity.isBusy('playlist-url')).toBe(true);
answer();
await Promise.resolve();
await Promise.resolve();
expect(service.isSourceBusy('playlist-url')).toBe(false);
expect(activity.isBusy('playlist-url')).toBe(false);
});
it('tracks a header M3U refresh by source until the operation settles', async () => {
@@ -349,13 +365,27 @@ describe('PlaylistRefreshActionService', () => {
resolve = r;
})
);
const activity = TestBed.inject(SourceActivityService);
service.refresh(playlist);
expect(service.isSourceBusy('busy')).toBe(true);
expect(service.isSourceBusy('other')).toBe(false);
// One refresh at a time: no other source is offered meanwhile.
const other = createPlaylistMeta({
_id: 'other',
serverUrl: undefined,
username: undefined,
password: undefined,
url: 'https://source.test/other',
});
expect(service.canRefresh(other)).toBe(false);
// The Sources page and the removal guard see it too.
expect(activity.isBusy('busy')).toBe(true);
resolve({ _id: 'busy', playlist: { items: [] } } as Playlist);
await Promise.resolve();
await Promise.resolve();
expect(service.isSourceBusy('busy')).toBe(false);
expect(activity.isBusy('busy')).toBe(false);
expect(service.canRefresh(other)).toBe(true);
});
it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => {
@@ -64,7 +64,13 @@ export class PlaylistRefreshActionService {
readonly refreshPreparation = this.refreshPreparationState.asReadonly();
canRefresh(playlist: PlaylistMeta | null): boolean {
if (!playlist || this.activity.isBusy(playlist._id)) {
// The same guards as refresh(): one refresh runs at a time, so an
// offer made meanwhile would be a silent no-op.
if (
!playlist ||
this.isRefreshing() ||
this.activity.isBusy(playlist._id)
) {
return false;
}
@@ -94,14 +100,32 @@ export class PlaylistRefreshActionService {
) {
void this.refreshM3u(playlist);
} else if (playlist.url) {
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: playlist._id,
title: playlist.title,
url: playlist.url,
...(playlist.userAgent
? { userAgent: playlist.userAgent }
: {}),
void this.refreshViaBackend(playlist, playlist.url);
}
}
/**
* The browser build refreshes through the backend, which reports its own
* success or failure; the source stays busy until it has answered.
*/
private async refreshViaBackend(
item: PlaylistMeta,
url: string
): Promise<void> {
this.m3uRefreshId.set(item._id);
this.isRefreshing.set(true);
const release = this.activity.begin([item._id]);
try {
await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
});
} finally {
this.m3uRefreshId.set(null);
this.isRefreshing.set(false);
release();
}
}
@@ -148,6 +172,8 @@ export class PlaylistRefreshActionService {
this.m3uRefreshId.set(item._id);
this.isRefreshing.set(true);
// Seen by the Sources page and the removal guard as well.
const release = this.activity.begin([item._id]);
if (isActiveM3uRoute) {
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: true })
@@ -214,6 +240,7 @@ export class PlaylistRefreshActionService {
}
this.m3uRefreshId.set(null);
this.isRefreshing.set(false);
release();
}
}
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff. Show more