mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8
|
||||
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator(
|
||||
'app-source-health-indicator [data-state]'
|
||||
'.source-tile__dot[data-state]'
|
||||
)
|
||||
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
|
||||
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator(
|
||||
'app-source-health-indicator [data-state]'
|
||||
'.source-tile__dot[data-state]'
|
||||
)
|
||||
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
|
||||
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
|
||||
|
||||
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
|
||||
await expect(
|
||||
@@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8
|
||||
try {
|
||||
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
// Refresh for the active source lives in its switcher row menu.
|
||||
await app.mainWindow.getByTestId('playlist-switcher-trigger').click();
|
||||
const currentRow = app.mainWindow.locator(
|
||||
'app-playlist-switcher-panel [role="option"][aria-current="true"]'
|
||||
);
|
||||
await currentRow.locator('.source-row__more').click();
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'app-playlist-switcher .trigger-refresh-button'
|
||||
)
|
||||
app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' })
|
||||
).toBeVisible();
|
||||
await app.mainWindow.keyboard.press('Escape');
|
||||
await app.mainWindow.keyboard.press('Escape');
|
||||
await expect(
|
||||
app.mainWindow.locator('app-playlist-switcher-panel')
|
||||
).toHaveCount(0);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
const sourceCard = app.mainWindow
|
||||
@@ -822,8 +831,8 @@ https://streams.example.test/original-url.m3u8
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
|
||||
.first()
|
||||
.locator('.refresh-btn')
|
||||
).toBeVisible();
|
||||
.locator('.source-row__refresh')
|
||||
).toBeAttached();
|
||||
|
||||
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
|
||||
{
|
||||
@@ -865,8 +874,8 @@ https://streams.example.test/refreshed-url.m3u8
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
|
||||
.first()
|
||||
.locator('.meta')
|
||||
).toContainText('Updated:');
|
||||
.locator('.source-row__meta')
|
||||
).toContainText('updated');
|
||||
await waitForSourceRowIdle(
|
||||
app.mainWindow,
|
||||
'refresh-url-source.m3u'
|
||||
@@ -876,9 +885,11 @@ https://streams.example.test/refreshed-url.m3u8
|
||||
'refresh-url-source.m3u'
|
||||
).first();
|
||||
await expect(
|
||||
refreshedUrlRow.locator('.playlist-item')
|
||||
).not.toHaveClass(/is-busy/);
|
||||
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled();
|
||||
refreshedUrlRow.locator('.source-row')
|
||||
).not.toHaveClass(/source-row--busy/);
|
||||
await expect(
|
||||
refreshedUrlRow.locator('.source-row__refresh')
|
||||
).toBeEnabled();
|
||||
await refreshedUrlRow
|
||||
.getByText('refresh-url-source.m3u', { exact: true })
|
||||
.click();
|
||||
@@ -904,8 +915,8 @@ https://streams.example.test/refreshed-url.m3u8
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
|
||||
.first()
|
||||
.locator('.meta')
|
||||
).toContainText('Updated:');
|
||||
.locator('.source-row__meta')
|
||||
).toContainText('updated');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await urlServer.close();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
/**
|
||||
* Leaving a portal section for the dashboard must replace the routed page
|
||||
* in one task. With the @angular/animations renderer the router inserted the
|
||||
* dashboard at once but the old page (and the context panel) left only at
|
||||
* the end of the next, rAF-coalesced change detection: in between Chromium
|
||||
* laid out the dashboard below the portal page, at y=720 in a 720 px window,
|
||||
* and scored its jump into view as a layout shift of 0.337 at 1280 px.
|
||||
*/
|
||||
|
||||
type RouteSwapProbe = {
|
||||
overlaps: string[][];
|
||||
shiftSources: string[];
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__routeSwapProbe?: RouteSwapProbe;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sources whose shift the old page's late removal caused. */
|
||||
const SWAP_SHIFT_SOURCES = [
|
||||
'lib-workspace-dashboard-rails',
|
||||
'main.workspace-content',
|
||||
];
|
||||
|
||||
async function armRouteSwapProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const content = document.querySelector('main.workspace-content');
|
||||
if (!content) {
|
||||
throw new Error('workspace content pane is missing');
|
||||
}
|
||||
const probe: RouteSwapProbe = { overlaps: [], shiftSources: [] };
|
||||
window.__routeSwapProbe = probe;
|
||||
// Mutation callbacks run at the microtask checkpoint after the
|
||||
// router's activation, before any frame or deferred change detection.
|
||||
new MutationObserver(() => {
|
||||
const pages = [...content.children]
|
||||
.filter((child) => child.tagName !== 'ROUTER-OUTLET')
|
||||
.map((child) => child.tagName.toLowerCase());
|
||||
if (pages.length > 1) {
|
||||
probe.overlaps.push(pages);
|
||||
}
|
||||
}).observe(content, { childList: true });
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
const sources =
|
||||
(entry as PerformanceEntry & {
|
||||
sources?: { node?: Node | null }[];
|
||||
}).sources ?? [];
|
||||
for (const { node } of sources) {
|
||||
if (node instanceof Element) {
|
||||
const className = node.classList.contains(
|
||||
'workspace-content'
|
||||
)
|
||||
? '.workspace-content'
|
||||
: '';
|
||||
probe.shiftSources.push(
|
||||
node.tagName.toLowerCase() + className
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).observe({ type: 'layout-shift' });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForTwoFrames(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => setTimeout(resolve, 0))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Workspace route swap', () => {
|
||||
test('replaces a portal page with the dashboard without a frame showing both', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0].setContentSize(1280, 720);
|
||||
});
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod/);
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-context-sidebar')
|
||||
).toBeVisible();
|
||||
|
||||
await armRouteSwapProbe(page);
|
||||
await goToDashboard(page);
|
||||
await expect(
|
||||
page.locator('lib-workspace-dashboard-rails')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-context-sidebar')
|
||||
).toHaveCount(0);
|
||||
await waitForTwoFrames(page);
|
||||
|
||||
const probe = await page.evaluate(() => window.__routeSwapProbe);
|
||||
expect(probe?.overlaps).toEqual([]);
|
||||
expect(
|
||||
probe?.shiftSources.filter((source) =>
|
||||
SWAP_SHIFT_SOURCES.includes(source)
|
||||
)
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20';
|
||||
const dbWorkerRowsPerTransaction = '100';
|
||||
|
||||
function playlistRowByName(name: string) {
|
||||
return `app-playlist-item:has-text("${name}")`;
|
||||
return `app-workspace-sources app-source-row:has-text("${name}")`;
|
||||
}
|
||||
|
||||
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
|
||||
@@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
);
|
||||
await expect(playlistRow).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await playlistRow.locator('.delete-btn').click();
|
||||
|
||||
const confirmDialog = app.mainWindow.locator(
|
||||
'mat-dialog-container'
|
||||
);
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Remove playlist', exact: true })
|
||||
// Delete from the row menu; the delete starts when the Undo
|
||||
// toast closes, and the row comes back to show its progress.
|
||||
await playlistRow.hover();
|
||||
await playlistRow.locator('.source-row__more').click();
|
||||
await app.mainWindow
|
||||
.locator('.cdk-overlay-pane [mat-menu-item]')
|
||||
.filter({ hasText: 'Remove playlist' })
|
||||
.click();
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
@@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
playlistRow.locator('.busy-state__message')
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
const cancel = playlistRow.locator('.cancel-btn');
|
||||
await expect(playlistRow.locator('.source-row__busy')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
const cancel = playlistRow.locator('.source-row__cancel');
|
||||
await expect(cancel).toBeVisible();
|
||||
// The row's action buttons inherit its color; the cancel action
|
||||
// must still read as destructive on the selected row.
|
||||
|
||||
@@ -111,16 +111,12 @@ test('@a11y icon-only buttons are named in Sources and the playlist info dialog'
|
||||
}) => {
|
||||
await addM3uUrlPlaylist(page, M3U_TITLE);
|
||||
|
||||
// Sources list: refresh, details and remove on each row.
|
||||
// Sources list: refresh, edit and the actions menu on each row.
|
||||
await openSources(page);
|
||||
const row = sourceRowByTitle(page, M3U_TITLE);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await row.hover();
|
||||
for (const name of [
|
||||
'Refresh playlist',
|
||||
'Show playlist details',
|
||||
'Remove playlist',
|
||||
]) {
|
||||
for (const name of ['Refresh playlist', 'Edit…', 'Playlist actions']) {
|
||||
await expect(row.getByRole('button', { name })).toBeAttached();
|
||||
}
|
||||
await expectNamedButtons(page, 3);
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise<string> {
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click(),
|
||||
]);
|
||||
await expect(page.getByText('1 channels')).toBeVisible();
|
||||
await expect(page.getByText('1 channel', { exact: false })).toBeVisible();
|
||||
return page.url();
|
||||
}
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
|
||||
channel = 'Refreshed UA Channel';
|
||||
const row = sourceRowByTitle(page, 'Protected M3U');
|
||||
await row.hover();
|
||||
await row.locator('.refresh-btn').click();
|
||||
await row.locator('.source-row__refresh').click();
|
||||
await expect(
|
||||
page.locator('.mat-mdc-snack-bar-label').last()
|
||||
).toContainText('updated');
|
||||
|
||||
@@ -190,7 +190,10 @@ export async function openSources(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export function sourceRowByTitle(page: Page, title: string): Locator {
|
||||
return page.locator('app-playlist-item').filter({ hasText: title }).first();
|
||||
return page
|
||||
.locator('app-workspace-sources app-source-row')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
}
|
||||
|
||||
export async function openSourceEditor(
|
||||
@@ -200,7 +203,7 @@ export async function openSourceEditor(
|
||||
const row = sourceRowByTitle(page, title);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await row.hover();
|
||||
await row.locator('.edit-btn').click();
|
||||
await row.locator('.source-row__edit').click();
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "جارٍ التحميل",
|
||||
|
||||
@@ -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": "كيتشارجا",
|
||||
|
||||
@@ -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": "Загрузка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Φόρτωση",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "読み込み中",
|
||||
|
||||
@@ -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": "로드 중",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Загрузка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "加载中",
|
||||
|
||||
@@ -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": "載入中",
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,7 +61,7 @@ must be ticked here.
|
||||
## Eager components
|
||||
|
||||
65 production files, 66 components (`epg-progress-panel.component.ts` holds
|
||||
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
|
||||
two); two have since been deleted (the old Sources row and its health dot). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
|
||||
(or setting OnPush) once its template state is signals, signal inputs or
|
||||
explicitly marked. The guard spec compares the unticked entries with the
|
||||
files whose component metadata still sets
|
||||
@@ -127,7 +127,7 @@ picker), so each marks itself on the form's `events` through
|
||||
- [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
|
||||
- [x] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts`
|
||||
|
||||
### libs/playlist (14)
|
||||
### libs/playlist (12)
|
||||
|
||||
- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
|
||||
- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
|
||||
@@ -140,9 +140,7 @@ picker), so each marks itself on the form's `events` through
|
||||
- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
|
||||
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
|
||||
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
|
||||
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
|
||||
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
|
||||
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
|
||||
|
||||
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
|
||||
|
||||
@@ -173,7 +171,7 @@ the field a signal (or a `computed`), or writes it through one.
|
||||
| --- | --- | --- | --- |
|
||||
| [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
|
||||
| [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
|
||||
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
|
||||
| [x] | `libs/portal/shared/data-access/src/lib/source-status.service.ts` `checkPortal` (replaced the old Sources row's `checkPortalStatus`) | the PWA Xtream portal status lands after `await`; it is written to a signal | playlist |
|
||||
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
|
||||
| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist |
|
||||
| [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
|
||||
|
||||
@@ -93,10 +93,3 @@ export const FavoritesActions = createActionGroup({
|
||||
'Hydrate Favorites': props<{ channelIds: string[] }>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const FilterActions = createActionGroup({
|
||||
source: 'Filters',
|
||||
events: {
|
||||
'Set Selected Filters': props<{ selectedFilters: string[] }>(),
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,6 @@ export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
|
||||
selectedId: string;
|
||||
allPlaylistsLoaded: boolean;
|
||||
loadFailed: boolean;
|
||||
selectedFilters: string[];
|
||||
}
|
||||
|
||||
export const initialPlaylistMetaState: PlaylistMetaState =
|
||||
@@ -18,7 +17,6 @@ export const initialPlaylistMetaState: PlaylistMetaState =
|
||||
selectedId: '',
|
||||
allPlaylistsLoaded: false,
|
||||
loadFailed: false,
|
||||
selectedFilters: ['m3u', 'xtream', 'stalker'],
|
||||
});
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { on } from '@ngrx/store';
|
||||
import { FilterActions } from '../actions';
|
||||
import { PlaylistState } from '../state';
|
||||
|
||||
export const filterReducers = [
|
||||
on(
|
||||
FilterActions.setSelectedFilters,
|
||||
(state, { selectedFilters }): PlaylistState => {
|
||||
return {
|
||||
...state,
|
||||
playlists: {
|
||||
...state.playlists,
|
||||
selectedFilters,
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
@@ -3,7 +3,6 @@ import { initialState, PlaylistState } from '../state';
|
||||
import { channelReducers } from './channel.reducers';
|
||||
import { epgReducers } from './epg.reducers';
|
||||
import { favoritesReducers } from './favorites.reducers';
|
||||
import { filterReducers } from './filter.reducers';
|
||||
import { playlistReducers } from './playlist.reducers';
|
||||
|
||||
export const playlistReducer = createReducer(
|
||||
@@ -11,8 +10,7 @@ export const playlistReducer = createReducer(
|
||||
...epgReducers,
|
||||
...channelReducers,
|
||||
...playlistReducers,
|
||||
...favoritesReducers,
|
||||
...filterReducers
|
||||
...favoritesReducers
|
||||
);
|
||||
|
||||
// Selectors
|
||||
|
||||
@@ -63,11 +63,6 @@ export const selectAllPlaylistsMeta = createSelector(
|
||||
fromPlaylistMetaState.getAllPlaylistsMeta
|
||||
);
|
||||
|
||||
export const selectActiveTypeFilters = createSelector(
|
||||
selectPlaylistsMetaState,
|
||||
(state) => state.selectedFilters
|
||||
);
|
||||
|
||||
export const selectPlaylistEntity = (id: string) =>
|
||||
createSelector(selectPlaylistEntities, (entities) => entities[id]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -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: [
|
||||
|
||||
+20
@@ -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();
|
||||
|
||||
|
||||
+2
@@ -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: [
|
||||
|
||||
@@ -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
Reference in new issue
Block a user