mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
Merge pull request #955 from 4gray/claude/elegant-hertz-40b11b
Unified color, rebuilt dashboard, polished Settings & Multi-EPG
This commit is contained in:
68 files changed
+3603
-697
No files matched your search
@@ -85,10 +85,17 @@ test.describe('Dashboard Activation', () => {
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
|
||||
await expectDashboardRail(app.mainWindow, 'dashboard-global-favorites-rail');
|
||||
// v0.22 dashboard: the mixed Global Favorites rail was removed.
|
||||
// Favorited live channels are promoted into the favorites-first
|
||||
// Live rail; clicking one still opens the Xtream favorites
|
||||
// collection with the channel playing.
|
||||
await expectDashboardRail(
|
||||
app.mainWindow,
|
||||
'dashboard-live-recent-rail'
|
||||
);
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-global-favorites-rail',
|
||||
'dashboard-live-recent-rail',
|
||||
liveTitle
|
||||
).click();
|
||||
await app.mainWindow.waitForURL(
|
||||
@@ -108,22 +115,16 @@ test.describe('Dashboard Activation', () => {
|
||||
await app.mainWindow.goBack();
|
||||
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
|
||||
|
||||
// Movies and series that were played land in the Continue
|
||||
// Watching rail (formerly "recently-watched"); clicking a card
|
||||
// opens it inline in the global-recent collection detail view.
|
||||
await expectDashboardRail(
|
||||
app.mainWindow,
|
||||
'dashboard-continue-watching-rail'
|
||||
);
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-global-favorites-rail',
|
||||
movieTitle
|
||||
).click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-favorites$/,
|
||||
title: movieTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-recently-watched-rail',
|
||||
'dashboard-continue-watching-rail',
|
||||
movieTitle
|
||||
).click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
@@ -136,7 +137,7 @@ test.describe('Dashboard Activation', () => {
|
||||
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-recently-watched-rail',
|
||||
'dashboard-continue-watching-rail',
|
||||
seriesTitle
|
||||
).click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
|
||||
@@ -273,7 +273,7 @@ export async function importM3uPlaylistFromNativeDialog(
|
||||
await clickDialogCategoryOption(dialog, /^m3u$/i);
|
||||
await clickDialogSubtypeOption(
|
||||
dialog,
|
||||
/add\s+via\s+file\s+upload/i,
|
||||
/m3u\s*file|add\s+via\s+file\s+upload/i,
|
||||
'mat-button-toggle[value="file"]'
|
||||
);
|
||||
const fileInput = dialog.locator('input[type="file"][name="playlist"]');
|
||||
@@ -364,7 +364,7 @@ export async function addXtreamPortal(
|
||||
const dialog = await getActiveDialog(page);
|
||||
await clickDialogCategoryOption(
|
||||
dialog,
|
||||
/^xtream$/i,
|
||||
/xtream(\s+credentials)?/i,
|
||||
'mat-button-toggle[value="xtream"]'
|
||||
);
|
||||
|
||||
@@ -409,57 +409,46 @@ async function setInputValue(input: Locator, value: string): Promise<void> {
|
||||
await expect(input).toHaveValue(value);
|
||||
}
|
||||
|
||||
async function clickDialogCategoryOption(
|
||||
/**
|
||||
* Pick a source method on the Add Playlist dialog. Since v0.22 the dialog
|
||||
* exposes a single flat 5-card radiogroup ("M3U URL", "M3U file", "Xtream
|
||||
* credentials", "Stalker portal", "Raw m3u text") instead of the legacy
|
||||
* 2-level category × subtype tabs/toggles. The helper still falls through
|
||||
* to the old tab/button/legacy-selector lookups so we don't have to rewrite
|
||||
* every call-site on each redesign — but the radio-based picker is the
|
||||
* primary path now.
|
||||
*/
|
||||
async function clickDialogMethodOption(
|
||||
dialog: Locator,
|
||||
label: RegExp,
|
||||
legacySelector?: string
|
||||
): Promise<void> {
|
||||
await clickDialogSegmentedOption(
|
||||
dialog,
|
||||
'Playlist category',
|
||||
label,
|
||||
legacySelector
|
||||
);
|
||||
}
|
||||
|
||||
async function clickDialogSubtypeOption(
|
||||
dialog: Locator,
|
||||
label: RegExp,
|
||||
legacySelector?: string
|
||||
): Promise<void> {
|
||||
await clickDialogSegmentedOption(
|
||||
dialog,
|
||||
'M3U source',
|
||||
label,
|
||||
legacySelector
|
||||
);
|
||||
}
|
||||
|
||||
async function clickDialogSegmentedOption(
|
||||
dialog: Locator,
|
||||
tablistLabel: string,
|
||||
label: RegExp,
|
||||
legacySelector?: string
|
||||
): Promise<void> {
|
||||
const tablist = dialog
|
||||
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
|
||||
const optionByRadio = dialog
|
||||
.getByRole('radio', { name: label })
|
||||
.first();
|
||||
if ((await optionByRadio.count()) > 0) {
|
||||
await optionByRadio.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((await tablist.count()) > 0) {
|
||||
const optionByTabRole = tablist
|
||||
.getByRole('tab', { name: label })
|
||||
for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) {
|
||||
const tablist = dialog
|
||||
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
|
||||
.first();
|
||||
|
||||
if ((await optionByTabRole.count()) > 0) {
|
||||
await optionByTabRole.click();
|
||||
return;
|
||||
if ((await tablist.count()) > 0) {
|
||||
const optionByTabRole = tablist
|
||||
.getByRole('tab', { name: label })
|
||||
.first();
|
||||
if ((await optionByTabRole.count()) > 0) {
|
||||
await optionByTabRole.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const optionByGlobalTabRole = dialog
|
||||
.getByRole('tab', { name: label })
|
||||
.first();
|
||||
|
||||
if ((await optionByGlobalTabRole.count()) > 0) {
|
||||
await optionByGlobalTabRole.click();
|
||||
return;
|
||||
@@ -468,7 +457,6 @@ async function clickDialogSegmentedOption(
|
||||
const optionByButtonRole = dialog
|
||||
.getByRole('button', { name: label })
|
||||
.first();
|
||||
|
||||
if ((await optionByButtonRole.count()) > 0) {
|
||||
await optionByButtonRole.click();
|
||||
return;
|
||||
@@ -476,13 +464,41 @@ async function clickDialogSegmentedOption(
|
||||
|
||||
if (!legacySelector) {
|
||||
throw new Error(
|
||||
`Could not find dialog option matching ${label} in "${tablistLabel}".`
|
||||
`Could not find dialog option matching ${label}.`
|
||||
);
|
||||
}
|
||||
|
||||
await dialog.locator(legacySelector).click();
|
||||
}
|
||||
|
||||
// Backwards-compat shims for the legacy two-step flow. Both helpers now route
|
||||
// through `clickDialogMethodOption` and use the patterns of the new flat
|
||||
// picker. `clickDialogCategoryOption` is a no-op for "M3U" since the new
|
||||
// picker has no parent "M3U" tile — callers immediately follow up with a
|
||||
// `clickDialogSubtypeOption` which picks the concrete M3U URL/file/text card.
|
||||
async function clickDialogCategoryOption(
|
||||
dialog: Locator,
|
||||
label: RegExp,
|
||||
legacySelector?: string
|
||||
): Promise<void> {
|
||||
// The legacy "M3U" category is now implicit — the new picker has no
|
||||
// standalone "M3U" radio; callers always immediately specialise via
|
||||
// `clickDialogSubtypeOption` below. Skip the click to avoid matching
|
||||
// unrelated radios (e.g. "M3U URL" when caller wanted "M3U file").
|
||||
if (/^\^?m3u\$?$/i.test(label.source)) {
|
||||
return;
|
||||
}
|
||||
await clickDialogMethodOption(dialog, label, legacySelector);
|
||||
}
|
||||
|
||||
async function clickDialogSubtypeOption(
|
||||
dialog: Locator,
|
||||
label: RegExp,
|
||||
legacySelector?: string
|
||||
): Promise<void> {
|
||||
await clickDialogMethodOption(dialog, label, legacySelector);
|
||||
}
|
||||
|
||||
export async function addStalkerPortal(
|
||||
page: Page,
|
||||
options: {
|
||||
@@ -501,7 +517,7 @@ export async function addStalkerPortal(
|
||||
const dialog = await getActiveDialog(page);
|
||||
await clickDialogCategoryOption(
|
||||
dialog,
|
||||
/^stalker$/i,
|
||||
/stalker(\s+portal)?/i,
|
||||
'mat-button-toggle[value="stalker"]'
|
||||
);
|
||||
|
||||
@@ -576,7 +592,7 @@ export async function importM3uPlaylistFromUrl(
|
||||
await clickDialogCategoryOption(dialog, /^m3u$/i);
|
||||
await clickDialogSubtypeOption(
|
||||
dialog,
|
||||
/add\s+via\s+url/i,
|
||||
/m3u\s*url|add\s+via\s+url/i,
|
||||
'mat-button-toggle[value="url"]'
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ test('basic test', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: 'Add via file upload' }).click();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /M3U file/i }).click();
|
||||
await page.setInputFiles('input[type="file"]', fixturePath);
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add playlist',
|
||||
|
||||
@@ -36,7 +36,10 @@ async function addXtreamPortal(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: 'Xtream', exact: true }).click();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog
|
||||
.getByRole('radio', { name: /Xtream credentials/i })
|
||||
.click();
|
||||
|
||||
await dialog.locator('#title').fill('Self-hosted Xtream');
|
||||
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_SERVER);
|
||||
@@ -73,7 +76,8 @@ async function addStalkerPortal(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: 'Stalker', exact: true }).click();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('input#title'), 'Self-hosted Stalker');
|
||||
await setInputValue(dialog.locator('input#portalUrl'), STALKER_PORTAL_URL);
|
||||
|
||||
@@ -49,17 +49,26 @@ test.describe('Settings', () => {
|
||||
|
||||
test('Change app theme', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
// v0.22 compact theme picker exposes the segmented control as a
|
||||
// radiogroup with options labelled just "Light"/"Dark"/"System".
|
||||
// Scope to the theme radiogroup so we don't collide with the
|
||||
// identically-labelled cover-size options below.
|
||||
const themeGroup = page.locator(
|
||||
'[data-test-id="select-theme"][role="radiogroup"]'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('radio', { name: 'System theme' })
|
||||
themeGroup.getByRole('radio', { name: 'System', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
await page.getByRole('radio', { name: 'Dark theme' }).click();
|
||||
await themeGroup
|
||||
.getByRole('radio', { name: 'Dark', exact: true })
|
||||
.click();
|
||||
|
||||
await saveSettings(page);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('radio', { name: 'Dark theme' })
|
||||
themeGroup.getByRole('radio', { name: 'Dark', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
|
||||
@@ -126,7 +126,8 @@ async function addStalkerPortal(
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: 'Stalker', exact: true }).click();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('input#title'), name);
|
||||
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
|
||||
|
||||
@@ -78,7 +78,10 @@ async function addXtreamPortal(
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: 'Xtream', exact: true }).click();
|
||||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||||
await dialog
|
||||
.getByRole('radio', { name: /Xtream credentials/i })
|
||||
.click();
|
||||
|
||||
await dialog.locator('#title').fill(name);
|
||||
await dialog.locator('#serverUrl').fill(MOCK_SERVER);
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
.settings-container {
|
||||
--settings-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--settings-action-bar-offset: 24px;
|
||||
--settings-action-bar-space: 112px;
|
||||
// Action bar is a full-width sticky footer, so it sits flush against
|
||||
// the bottom edge of the scroll container — no hover offset. The
|
||||
// previous 24px gap was tuned for the old floating-chip layout where
|
||||
// the bar visually hovered above the bottom.
|
||||
--settings-action-bar-offset: 0px;
|
||||
// 0 by default — the action bar is now the last child of the form
|
||||
// and acts as the natural bottom edge, so no padding-bottom is
|
||||
// needed below it. The previous 112px/80px reservation existed to
|
||||
// push content above the OLD floating-chip layout, but in the new
|
||||
// sticky-footer model that just added dead scrollable space below
|
||||
// the last setting row. The bar's own padding (12px) and the
|
||||
// section title bottom-borders already provide visual breathing
|
||||
// room when scrolled to the end.
|
||||
--settings-action-bar-space: 0px;
|
||||
--settings-page-header-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 94%,
|
||||
|
||||
@@ -18,7 +18,12 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
void import('@iptvnator/playlist/import/feature').then(
|
||||
({ AddPlaylistDialogComponent }) => {
|
||||
this.dialog.open(AddPlaylistDialogComponent, {
|
||||
width: '560px',
|
||||
// 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
|
||||
// narrow screens so the grid can collapse via the
|
||||
// SCSS responsive breakpoints.
|
||||
width: '780px',
|
||||
maxWidth: '92vw',
|
||||
data: type ? { type } : {},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
import { buildSettingsSectionNavItems } from './settings-options';
|
||||
|
||||
/**
|
||||
* Every nav-item id must match the `id="..."` attribute on its
|
||||
* corresponding section component template, otherwise clicking the nav
|
||||
* link silently no-ops (the scroll directive runs `document.getElementById`
|
||||
* and falls through when no element is found).
|
||||
*
|
||||
* This bug bit the Remote section once already — its nav id was set to
|
||||
* the Nx library name (`@iptvnator/ui/remote-control`) instead of the
|
||||
* template's `id="remote-control"`. The guard below catches future
|
||||
* rename/copy-paste regressions before they ship.
|
||||
*/
|
||||
describe('buildSettingsSectionNavItems', () => {
|
||||
// Anchor on the Nx workspace root (Jest runs from there); avoids
|
||||
// depending on __dirname which isn't defined under the project's
|
||||
// ESM Jest preset.
|
||||
const settingsDir = resolve(
|
||||
process.cwd(),
|
||||
'apps/web/src/app/settings'
|
||||
);
|
||||
|
||||
function collectSectionTemplateIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const fileName of readdirSync(settingsDir)) {
|
||||
if (!/^settings-.+-section\.component\.html$/.test(fileName)) {
|
||||
continue;
|
||||
}
|
||||
const html = readFileSync(
|
||||
resolve(settingsDir, fileName),
|
||||
'utf-8'
|
||||
);
|
||||
// Match the FIRST `id="…"` on the <section> root only —
|
||||
// descendant elements (form controls, anchors) also use id=
|
||||
// attributes and would pollute the set.
|
||||
const rootMatch = /<section[^>]*\sid="([^"]+)"/i.exec(html);
|
||||
if (rootMatch) {
|
||||
ids.add(rootMatch[1]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
it('exposes desktop-only items only when isDesktop is true', () => {
|
||||
const desktopItems = buildSettingsSectionNavItems(true);
|
||||
const pwaItems = buildSettingsSectionNavItems(false);
|
||||
|
||||
expect(desktopItems.map((item) => item.id)).toEqual(
|
||||
expect.arrayContaining(['epg', 'remote-control'])
|
||||
);
|
||||
expect(pwaItems.find((item) => item.id === 'epg')?.visible).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
pwaItems.find((item) => item.id === 'remote-control')?.visible
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('every nav id matches an existing section template id (regression: remote-control nav no longer maps to the Nx lib name)', () => {
|
||||
const navIds = new Set(
|
||||
buildSettingsSectionNavItems(true).map((item) => item.id)
|
||||
);
|
||||
const templateIds = collectSectionTemplateIds();
|
||||
|
||||
// Every nav id must exist as a section root id.
|
||||
const orphans = [...navIds].filter((id) => !templateIds.has(id));
|
||||
expect(orphans).toEqual([]);
|
||||
|
||||
// And every section template id must be reachable from the nav
|
||||
// (catches the opposite drift — a new section added without a nav
|
||||
// entry would never get scrolled to).
|
||||
const unreachable = [...templateIds].filter((id) => !navIds.has(id));
|
||||
expect(unreachable).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,12 @@ export function buildSettingsSectionNavItems(
|
||||
visible: isDesktop,
|
||||
},
|
||||
{
|
||||
id: '@iptvnator/ui/remote-control',
|
||||
// Must match the section's HTML id (`remote-control`) so the
|
||||
// settings-section-scroll directive can resolve the anchor.
|
||||
// Was previously '@iptvnator/ui/remote-control' (the NX lib
|
||||
// name), which meant clicking the nav item silently no-op'd
|
||||
// because document.getElementById of that string returned null.
|
||||
id: 'remote-control',
|
||||
label: 'SETTINGS.NAV_REMOTE',
|
||||
icon: 'smartphone',
|
||||
visible: isDesktop,
|
||||
|
||||
@@ -45,43 +45,14 @@ export class SettingsSectionScrollDirective
|
||||
{ injector: this.injector }
|
||||
);
|
||||
|
||||
effect(
|
||||
(onCleanup) => {
|
||||
const activeSectionId = this.settingsCtx.activeSection();
|
||||
const activeSectionElement =
|
||||
this.elementRef.nativeElement.querySelector(
|
||||
`#${activeSectionId}`
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!activeSectionElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = activeSectionElement.animate(
|
||||
[
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 260,
|
||||
easing: 'ease-out',
|
||||
}
|
||||
);
|
||||
|
||||
onCleanup(() => animation.cancel());
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
// The previous active-section change handler ran a 260ms box-shadow
|
||||
// animation (inset 1px ring + soft glow) on whichever section
|
||||
// became active during scroll. With the new flat layout (no card
|
||||
// chrome, no static active ring) that pulse drew a brief 1px
|
||||
// border around each block as the user scrolled past — what the
|
||||
// user reported as "short border kind of highlight around blocks".
|
||||
// The rail's left active state already announces the current
|
||||
// section, so the inline pulse is redundant. Removed entirely.
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
|
||||
@@ -14,18 +14,20 @@
|
||||
class="settings-layout"
|
||||
novalidate
|
||||
>
|
||||
<!-- Page-level "Settings" h1 intentionally removed: the left rail
|
||||
already labels the page "Settings" (sg-nav-title), and each
|
||||
section below provides its own h3 + subtitle. Stacking another
|
||||
h1 in the right pane reproduced the audit's "vertical ladder
|
||||
of identical labels" complaint. -->
|
||||
@if (!isDialog) {
|
||||
<header
|
||||
class="settings-page-header"
|
||||
<!-- Reserved hook so the page test selector still resolves,
|
||||
even though there's no visible chrome here. -->
|
||||
<span
|
||||
class="visually-hidden"
|
||||
data-test-id="settings-page-header"
|
||||
>
|
||||
<div class="settings-page-header__text">
|
||||
<h1 data-test-id="settings-page-title">
|
||||
{{ 'SETTINGS.TITLE' | translate }}
|
||||
</h1>
|
||||
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
{{ 'SETTINGS.TITLE' | translate }}
|
||||
</span>
|
||||
}
|
||||
|
||||
<div class="settings-content">
|
||||
|
||||
@@ -64,36 +64,33 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// Flat section — no panel chrome. The v0.22 mockup treats Settings as a
|
||||
// single scroll column with each section anchored by a large title +
|
||||
// bottom-border separator. The previous card frame (border-radius 22px,
|
||||
// filled bg, box-shadow, active-state ring + icon-circle) made every
|
||||
// section look like a feature card, which fought the inline form rhythm.
|
||||
.settings-group {
|
||||
position: relative;
|
||||
background: var(--settings-group-bg);
|
||||
overflow: hidden;
|
||||
scroll-margin-top: 24px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
border: 1px solid var(--settings-group-border);
|
||||
border-radius: 22px;
|
||||
box-shadow: var(--settings-group-shadow);
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
margin-top: 24px;
|
||||
scroll-margin-top: 24px;
|
||||
transition: none;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The active section no longer gets a ring/shadow — the rail's active
|
||||
// state on the left already announces "you are here". Title still
|
||||
// colours to the accent so users scanning the right pane don't lose the
|
||||
// anchor.
|
||||
.settings-group--active {
|
||||
background: var(--settings-group-active-bg);
|
||||
border-color: var(--settings-group-active-ring);
|
||||
box-shadow: var(--settings-group-active-shadow);
|
||||
|
||||
.settings-group__header-icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface-strong) 70%,
|
||||
var(--mat-sys-surface-container-high)
|
||||
);
|
||||
color: var(--app-selection-color);
|
||||
border-color: var(--settings-group-active-ring);
|
||||
}
|
||||
|
||||
.settings-group__header-text h3 {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
@@ -101,46 +98,33 @@
|
||||
|
||||
.settings-group__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 16px 18px 14px;
|
||||
padding: 4px 0 18px;
|
||||
margin: 0 0 8px;
|
||||
border-bottom: 1px solid var(--settings-item-border);
|
||||
|
||||
// Icon-circle removed per UX-audit recommendation (the section title
|
||||
// is the anchor, not a card). Hidden rather than ripped out of every
|
||||
// section template so the existing markup stays untouched.
|
||||
.settings-group__header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 74%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 78%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-group__header-text {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.2;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.84rem;
|
||||
margin: 6px 0 0;
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
@@ -151,15 +135,23 @@
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
}
|
||||
|
||||
// Row layout — flat list aligned to the section title (no inset). The
|
||||
// previous `margin: 0 18px` came from the card chrome; now that each
|
||||
// section is a flat block, rows sit at the same gutter as the title.
|
||||
.setting-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(220px, 340px);
|
||||
gap: 14px;
|
||||
margin: 0 18px;
|
||||
padding: 14px 0;
|
||||
margin: 0;
|
||||
padding: 18px 0;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
background: transparent;
|
||||
|
||||
&:first-of-type {
|
||||
border-top: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -235,50 +227,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Compact segmented control — replaces the previous 3 buttons each ~78px
|
||||
// tall. Per the UX audit, "Light/Dark/System" was reading as the most
|
||||
// important setting on the page because of its sheer footprint. Now the
|
||||
// row reads at the same weight as the other compact rows (Language,
|
||||
// Startup behavior, etc.), restoring the visual hierarchy.
|
||||
.theme-switcher {
|
||||
width: min(100%, 540px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
width: min(100%, 360px);
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 80%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.theme-switcher__option {
|
||||
flex: 1 1 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 54px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent);
|
||||
border-radius: 16px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 88%,
|
||||
transparent
|
||||
);
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
color 0.18s ease;
|
||||
background 0.16s ease,
|
||||
color 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 86%,
|
||||
var(--mat-sys-surface-container-highest) 70%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface);
|
||||
@@ -286,31 +287,19 @@
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--app-selection-color) 18%, transparent);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--app-selection-color) 50%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-switcher__option--selected {
|
||||
border-color: var(--settings-group-active-ring);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface-strong) 72%,
|
||||
var(--mat-sys-surface-container-high)
|
||||
),
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface) 72%,
|
||||
var(--mat-sys-surface-container)
|
||||
)
|
||||
);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface));
|
||||
color: var(--app-selection-color);
|
||||
font-weight: 600;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--app-selection-border) 54%, transparent),
|
||||
0 10px 18px -22px var(--settings-group-active-glow);
|
||||
color-mix(in srgb, var(--app-selection-border) 36%, transparent);
|
||||
}
|
||||
|
||||
.epg-sources {
|
||||
@@ -708,21 +697,31 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Sticky-footer Save bar — full content width so it never overlaps form
|
||||
// rows. The previous chip-style bar (border-radius:18px, margin-left:auto)
|
||||
// floated in the bottom-right corner and clipped fields underneath; the
|
||||
// UX audit called this out as the worst of three available patterns.
|
||||
// Now the bar acts as a proper footer: spans the column, separator line
|
||||
// on top, sits flush with the bottom edge while still sticking on scroll.
|
||||
.action-bar {
|
||||
position: sticky;
|
||||
bottom: calc(
|
||||
var(--settings-action-bar-offset) + var(--settings-safe-bottom)
|
||||
);
|
||||
margin-left: auto;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--settings-action-bar-border);
|
||||
border-radius: 18px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--settings-action-bar-border);
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-radius: 0;
|
||||
background: var(--settings-action-bar-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: var(--settings-action-bar-shadow);
|
||||
@@ -735,16 +734,21 @@
|
||||
box-shadow: none;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--settings-action-bar-border);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.settings-save-button.mat-mdc-button-base {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
// Use the unified blue primary (--app-selection-color) instead of the
|
||||
// old hand-rolled #3b82f6 gradient that didn't match the rest of the
|
||||
// app's CTAs after the color unification commit.
|
||||
background: var(--app-selection-color);
|
||||
color: var(--app-selection-on-color, #fff);
|
||||
font-weight: 500;
|
||||
padding: 0 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.28);
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--app-selection-color) 28%, transparent);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
@@ -756,7 +760,8 @@
|
||||
|
||||
&:hover:not([disabled]) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.38);
|
||||
box-shadow: 0 6px 18px
|
||||
color-mix(in srgb, var(--app-selection-color) 38%, transparent);
|
||||
}
|
||||
|
||||
&:active:not([disabled]) {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! تم تحديث قوائم التشغيل بنجاح",
|
||||
"AUTO_REFRESH_ENABLED": "تم تفعيل التحديث التلقائي"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "اختر طريقة الاتصال، ويمكنك إعادة تسميتها لاحقًا.",
|
||||
"METHOD_URL_LABEL": "رابط M3U",
|
||||
"METHOD_URL_SUB": "ألصق رابطًا لملف .m3u أو .m3u8 بعيد",
|
||||
"METHOD_FILE_LABEL": "ملف M3U",
|
||||
"METHOD_FILE_SUB": "حمّل قائمة تشغيل من جهازك",
|
||||
"METHOD_XTREAM_LABEL": "بيانات اعتماد Xtream",
|
||||
"METHOD_XTREAM_SUB": "اتصل باستخدام المضيف واسم المستخدم وكلمة المرور",
|
||||
"METHOD_STALKER_LABEL": "بوابة Stalker",
|
||||
"METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة",
|
||||
"METHOD_TEXT_LABEL": "نص m3u الخام",
|
||||
"METHOD_TEXT_SUB": "للقوائم العابرة أو عندما يتوفر لديك النص فقط"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "اسحب وأفلت الملفات هنا",
|
||||
"OR": "أو",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "السمة الداكنة",
|
||||
"LIGHT_THEME": "السمة الفاتحة",
|
||||
"SYSTEM_THEME": "سمة النظام"
|
||||
"DARK_THEME": "داكن",
|
||||
"LIGHT_THEME": "فاتح",
|
||||
"SYSTEM_THEME": "النظام"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "تشغيل",
|
||||
"PAUSE": "إيقاف مؤقت",
|
||||
"PREVIOUS_STATION": "المحطة السابقة",
|
||||
"NEXT_STATION": "المحطة التالية",
|
||||
"MUTE": "كتم الصوت",
|
||||
"UNMUTE": "إلغاء كتم الصوت",
|
||||
"VOLUME": "مستوى الصوت"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "الدليل الإلكتروني للبرامج",
|
||||
"NEXT_DAY": "اليوم التالي",
|
||||
"PREVIOUS_DAY": "اليوم السابق",
|
||||
"ZOOM_IN": "تكبير",
|
||||
"ZOOM_OUT": "تصغير",
|
||||
"FILTER_CHANNELS": "تصفية القنوات",
|
||||
"CLOSE_FILTER": "إغلاق التصفية",
|
||||
"SEARCH_PROGRAMS": "البحث في البرامج",
|
||||
"CLOSE_PROGRAM_SEARCH": "إغلاق بحث البرامج",
|
||||
"LIVE_NOW": "يُبث الآن",
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"TIMESHIFT_AVAILABLE": "التحريك الزمني متاح، انقر للتشغيل",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
||||
"LIVE_FAVORITES": "يُبث الآن على المفضلة لديك",
|
||||
"LIVE_RECENT": "متابعة البث المباشر",
|
||||
"REMAINING_SECONDS": "متبقٍ {{seconds}} ث",
|
||||
"REMAINING_MINUTES": "متبقٍ {{minutes}} د",
|
||||
"REMAINING_HOURS": "متبقٍ {{hours}} س",
|
||||
"REMAINING_HOURS_MINUTES": "متبقٍ {{hours}} س {{minutes}} د",
|
||||
"PERCENT_WATCHED": "تمت مشاهدة {{value}}%",
|
||||
"UNTITLED_SOURCE": "مصدر بدون عنوان",
|
||||
"GLOBAL_FAVORITES": "المفضلة العامة",
|
||||
"REMOVE_FROM_HISTORY": "إزالة من السجل",
|
||||
"RECENT_SOURCES": "المصادر المستخدمة مؤخرًا",
|
||||
"MANAGE_ALL": "إدارة الكل",
|
||||
"SEE_ALL": "عرض الكل",
|
||||
"SEE_ALL_COUNT": "عرض الكل ({{count}})",
|
||||
"RECENTLY_WATCHED": "ما تمت مشاهدته مؤخرًا",
|
||||
"XTREAM_RECENTLY_ADDED": "أُضيفت مؤخرًا على Xtream",
|
||||
"NO_HISTORY": "لم يتم العثور على عناصر لهذا الفلتر.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "مباشر",
|
||||
"TYPE_MOVIE": "فيلم",
|
||||
"TYPE_SERIES": "مسلسل",
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "لم تتم المزامنة بعد"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! قوائم التشغيل تحدثات بنجاح",
|
||||
"AUTO_REFRESH_ENABLED": "التحديث التلقائي مفعل"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "اختار كيفاش بغيتي تربط. تقدر تبدل الاسم من بعد.",
|
||||
"METHOD_URL_LABEL": "رابط M3U",
|
||||
"METHOD_URL_SUB": "لصق رابط ل ملف .m3u ولا .m3u8 بعيد",
|
||||
"METHOD_FILE_LABEL": "ملف M3U",
|
||||
"METHOD_FILE_SUB": "زيد قائمة تشغيل من الجهاز ديالك",
|
||||
"METHOD_XTREAM_LABEL": "بيانات Xtream",
|
||||
"METHOD_XTREAM_SUB": "ربط بالخادم، اسم المستخدم وكلمة المرور",
|
||||
"METHOD_STALKER_LABEL": "بوابة Stalker",
|
||||
"METHOD_STALKER_SUB": "عنوان MAC ورابط البوابة",
|
||||
"METHOD_TEXT_LABEL": "نص m3u خام",
|
||||
"METHOD_TEXT_SUB": "للقوائم المؤقتة ولا إلى عندك غير النص"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "جر وأفلت الملفات هنا",
|
||||
"OR": "أو",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "المظهر الداكن",
|
||||
"LIGHT_THEME": "المظهر الفاتح",
|
||||
"SYSTEM_THEME": "مظهر النظام"
|
||||
"DARK_THEME": "داكن",
|
||||
"LIGHT_THEME": "فاتح",
|
||||
"SYSTEM_THEME": "النظام"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "شغل",
|
||||
"PAUSE": "وقّف",
|
||||
"PREVIOUS_STATION": "المحطة السابقة",
|
||||
"NEXT_STATION": "المحطة التالية",
|
||||
"MUTE": "كتم الصوت",
|
||||
"UNMUTE": "رجّع الصوت",
|
||||
"VOLUME": "الصوت"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "دليل البرامج الإلكتروني",
|
||||
"NEXT_DAY": "اليوم التالي",
|
||||
"PREVIOUS_DAY": "اليوم السابق",
|
||||
"ZOOM_IN": "كبر",
|
||||
"ZOOM_OUT": "صغر",
|
||||
"FILTER_CHANNELS": "فلتر القنوات",
|
||||
"CLOSE_FILTER": "سد الفلتر",
|
||||
"SEARCH_PROGRAMS": "قلب على البرامج",
|
||||
"CLOSE_PROGRAM_SEARCH": "سد البحث على البرامج",
|
||||
"LIVE_NOW": "مباشر الآن",
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"TIMESHIFT_AVAILABLE": "التأجيل الزمني متوفر، ضغط باش تلعب",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "كمل المشاهدة",
|
||||
"LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك",
|
||||
"LIVE_RECENT": "كمل مع المباشر",
|
||||
"REMAINING_SECONDS": "باقي {{seconds}} ث",
|
||||
"REMAINING_MINUTES": "باقي {{minutes}} د",
|
||||
"REMAINING_HOURS": "باقي {{hours}} س",
|
||||
"REMAINING_HOURS_MINUTES": "باقي {{hours}} س {{minutes}} د",
|
||||
"PERCENT_WATCHED": "تشاهد {{value}}%",
|
||||
"UNTITLED_SOURCE": "مصدر بلا عنوان",
|
||||
"GLOBAL_FAVORITES": "المفضلة العامة",
|
||||
"REMOVE_FROM_HISTORY": "حيد من السجل",
|
||||
"RECENT_SOURCES": "المصادر المستعملة مؤخراً",
|
||||
"MANAGE_ALL": "تدبير الكل",
|
||||
"SEE_ALL": "شوف الكل",
|
||||
"SEE_ALL_COUNT": "شوف الكل ({{count}})",
|
||||
"RECENTLY_WATCHED": "متشافين مؤخراً",
|
||||
"XTREAM_RECENTLY_ADDED": "تزادو مؤخراً على Xtream",
|
||||
"NO_HISTORY": "ما تلقاو حتى عناصر لهاد الفلتر.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "مباشر",
|
||||
"TYPE_MOVIE": "فيلم",
|
||||
"TYPE_SERIES": "سلسلة",
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "مازال ما تزامنش"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Гатова! Плэйлісты былі паспяхова абноўлены",
|
||||
"AUTO_REFRESH_ENABLED": "Аўтаабнаўленне ўключана"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Выберыце, як вы хочаце падключыцца. Перайменаваць можна пазней.",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "Устаўце спасылку на выдалены .m3u або .m3u8",
|
||||
"METHOD_FILE_LABEL": "M3U-файл",
|
||||
"METHOD_FILE_SUB": "Загрузіце плэйліст са свайго камп'ютара",
|
||||
"METHOD_XTREAM_LABEL": "Xtream уліковыя даныя",
|
||||
"METHOD_XTREAM_SUB": "Падключэнне праз хост, імя карыстальніка і пароль",
|
||||
"METHOD_STALKER_LABEL": "Stalker партал",
|
||||
"METHOD_STALKER_SUB": "MAC-адрас і URL партала",
|
||||
"METHOD_TEXT_LABEL": "Тэкст M3U",
|
||||
"METHOD_TEXT_SUB": "Для разавых спісаў або калі ў вас ёсць толькі тэкст"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Перацягніце файл у гэтую вобласць",
|
||||
"OR": "або",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Цёмная тэма",
|
||||
"LIGHT_THEME": "Светлая тэма",
|
||||
"SYSTEM_THEME": "Сістэмная тэма"
|
||||
"DARK_THEME": "Цёмная",
|
||||
"LIGHT_THEME": "Светлая",
|
||||
"SYSTEM_THEME": "Сістэмная"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Прайграць",
|
||||
"PAUSE": "Паўза",
|
||||
"PREVIOUS_STATION": "Папярэдняя станцыя",
|
||||
"NEXT_STATION": "Наступная станцыя",
|
||||
"MUTE": "Адключыць гук",
|
||||
"UNMUTE": "Уключыць гук",
|
||||
"VOLUME": "Гучнасць"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Электронная праграма перадач",
|
||||
"NEXT_DAY": "Наступны дзень",
|
||||
"PREVIOUS_DAY": "Папярэдні дзень",
|
||||
"ZOOM_IN": "Павялічыць маштаб",
|
||||
"ZOOM_OUT": "Паменшыць маштаб",
|
||||
"FILTER_CHANNELS": "Фільтраваць каналы",
|
||||
"CLOSE_FILTER": "Закрыць фільтр",
|
||||
"SEARCH_PROGRAMS": "Пошук перадач",
|
||||
"CLOSE_PROGRAM_SEARCH": "Закрыць пошук перадач",
|
||||
"LIVE_NOW": "Цяпер",
|
||||
"LIVE_STREAM": "Прамая трансляцыя",
|
||||
"TIMESHIFT_AVAILABLE": "Даступная функцыя архіва (timeshift). Націсніце на перадачу для прайгравання.",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
||||
"LIVE_FAVORITES": "Цяпер у эфіры на выбраных",
|
||||
"LIVE_RECENT": "Працягнуць прагляд эфіру",
|
||||
"REMAINING_SECONDS": "Засталося {{seconds}} с",
|
||||
"REMAINING_MINUTES": "Засталося {{minutes}} хв",
|
||||
"REMAINING_HOURS": "Засталося {{hours}} г",
|
||||
"REMAINING_HOURS_MINUTES": "Засталося {{hours}} г {{minutes}} хв",
|
||||
"PERCENT_WATCHED": "Прагледжана {{value}}%",
|
||||
"UNTITLED_SOURCE": "Крыніца без назвы",
|
||||
"GLOBAL_FAVORITES": "Усе фаварыты",
|
||||
"REMOVE_FROM_HISTORY": "Выдаліць з гісторыі",
|
||||
"RECENT_SOURCES": "Нядаўна выкарыстаныя крыніцы",
|
||||
"MANAGE_ALL": "Кіраваць усімі",
|
||||
"SEE_ALL": "Паказаць усё",
|
||||
"SEE_ALL_COUNT": "Паказаць усё ({{count}})",
|
||||
"RECENTLY_WATCHED": "Нядаўна прагледжаныя",
|
||||
"XTREAM_RECENTLY_ADDED": "Нядаўна дададзеныя на Xtream",
|
||||
"NO_HISTORY": "Не знойдзена элементаў для гэтага фільтра.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Прамы эфір",
|
||||
"TYPE_MOVIE": "Фільм",
|
||||
"TYPE_SERIES": "Серыял",
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Яшчэ не сінхранізавана"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Erfolg! Die Playlists wurden erfolgreich aktualisiert",
|
||||
"AUTO_REFRESH_ENABLED": "Auto-Refresh aktiviert"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Wählen Sie aus, wie Sie sich verbinden möchten. Sie können den Namen später ändern.",
|
||||
"METHOD_URL_LABEL": "M3U-URL",
|
||||
"METHOD_URL_SUB": "Fügen Sie einen Link zu einer .m3u- oder .m3u8-Datei ein",
|
||||
"METHOD_FILE_LABEL": "M3U-Datei",
|
||||
"METHOD_FILE_SUB": "Laden Sie eine Wiedergabeliste von Ihrem Computer hoch",
|
||||
"METHOD_XTREAM_LABEL": "Xtream-Zugangsdaten",
|
||||
"METHOD_XTREAM_SUB": "Verbinden mit Host, Benutzername und Passwort",
|
||||
"METHOD_STALKER_LABEL": "Stalker-Portal",
|
||||
"METHOD_STALKER_SUB": "MAC-Adresse und Portal-URL",
|
||||
"METHOD_TEXT_LABEL": "M3U-Text",
|
||||
"METHOD_TEXT_SUB": "Für einmalige Listen oder wenn Sie nur Text haben"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Ziehen Sie Dateien per Drag & Drop",
|
||||
"OR": "oder",
|
||||
@@ -366,6 +379,15 @@
|
||||
"LIGHT_THEME": "Hell",
|
||||
"SYSTEM_THEME": "System"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Wiedergabe",
|
||||
"PAUSE": "Pause",
|
||||
"PREVIOUS_STATION": "Vorheriger Sender",
|
||||
"NEXT_STATION": "Nächster Sender",
|
||||
"MUTE": "Stummschalten",
|
||||
"UNMUTE": "Stummschaltung aufheben",
|
||||
"VOLUME": "Lautstärke"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Elektronischer Programmführer",
|
||||
"NEXT_DAY": "Zum nächsten Tag",
|
||||
"PREVIOUS_DAY": "Zum vorherigen Tag",
|
||||
"ZOOM_IN": "Vergrößern",
|
||||
"ZOOM_OUT": "Verkleinern",
|
||||
"FILTER_CHANNELS": "Sender filtern",
|
||||
"CLOSE_FILTER": "Filter schließen",
|
||||
"SEARCH_PROGRAMS": "Sendungen suchen",
|
||||
"CLOSE_PROGRAM_SEARCH": "Sendungssuche schließen",
|
||||
"LIVE_NOW": "Live jetzt",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Weiter schauen",
|
||||
"LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten",
|
||||
"LIVE_RECENT": "Mit Live-TV weiterschauen",
|
||||
"REMAINING_SECONDS": "Noch {{seconds}} s",
|
||||
"REMAINING_MINUTES": "Noch {{minutes}} Min.",
|
||||
"REMAINING_HOURS": "Noch {{hours}} Std.",
|
||||
"REMAINING_HOURS_MINUTES": "Noch {{hours}} Std. {{minutes}} Min.",
|
||||
"PERCENT_WATCHED": "{{value}}% angesehen",
|
||||
"UNTITLED_SOURCE": "Unbenannte Quelle",
|
||||
"GLOBAL_FAVORITES": "Globale Favoriten",
|
||||
"REMOVE_FROM_HISTORY": "Aus Verlauf entfernen",
|
||||
"RECENT_SOURCES": "Zuletzt verwendete Quellen",
|
||||
"MANAGE_ALL": "Alle verwalten",
|
||||
"SEE_ALL": "Alle anzeigen",
|
||||
"SEE_ALL_COUNT": "Alle {{count}} anzeigen",
|
||||
"RECENTLY_WATCHED": "Zuletzt angesehen",
|
||||
"XTREAM_RECENTLY_ADDED": "Neu auf Xtream",
|
||||
"NO_HISTORY": "Keine Elemente für diesen Filter gefunden.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Live",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "St{{season}} · F{{episode}}",
|
||||
"NOT_YET_SYNCED": "Noch nicht synchronisiert"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Επιτυχία! Οι λίστες αναπαραγωγής ενημερώθηκαν με επιτυχία",
|
||||
"AUTO_REFRESH_ENABLED": "Η αυτόματη ανανέωση είναι ενεργοποιημένη"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Επιλέξτε τρόπο σύνδεσης. Μπορείτε να τη μετονομάσετε αργότερα.",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "Επικολλήστε έναν σύνδεσμο σε απομακρυσμένο .m3u ή .m3u8",
|
||||
"METHOD_FILE_LABEL": "Αρχείο M3U",
|
||||
"METHOD_FILE_SUB": "Ανεβάστε μια λίστα αναπαραγωγής από τον υπολογιστή σας",
|
||||
"METHOD_XTREAM_LABEL": "Διαπιστευτήρια Xtream",
|
||||
"METHOD_XTREAM_SUB": "Σύνδεση με διακομιστή, όνομα χρήστη και κωδικό πρόσβασης",
|
||||
"METHOD_STALKER_LABEL": "Πύλη Stalker",
|
||||
"METHOD_STALKER_SUB": "MAC address και URL πύλης",
|
||||
"METHOD_TEXT_LABEL": "Ακατέργαστο κείμενο m3u",
|
||||
"METHOD_TEXT_SUB": "Για λίστες μίας χρήσης ή όταν έχετε μόνο κείμενο"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Σύρετε και αποθέστε αρχεία εδώ",
|
||||
"OR": "or",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Σκούρο θέμα",
|
||||
"LIGHT_THEME": "Ανοιχτόχρωμο θέμα",
|
||||
"SYSTEM_THEME": "Θέμα συστήματος"
|
||||
"DARK_THEME": "Σκούρο",
|
||||
"LIGHT_THEME": "Ανοιχτό",
|
||||
"SYSTEM_THEME": "Σύστημα"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Αναπαραγωγή",
|
||||
"PAUSE": "Παύση",
|
||||
"PREVIOUS_STATION": "Προηγούμενος σταθμός",
|
||||
"NEXT_STATION": "Επόμενος σταθμός",
|
||||
"MUTE": "Σίγαση",
|
||||
"UNMUTE": "Κατάργηση σίγασης",
|
||||
"VOLUME": "Ένταση"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Ηλεκτρονικός Οδηγός Προγράμματος",
|
||||
"NEXT_DAY": "Επόμενη μέρα",
|
||||
"PREVIOUS_DAY": "Προηγούμενη ημέρα",
|
||||
"ZOOM_IN": "Μεγέθυνση",
|
||||
"ZOOM_OUT": "Σμίκρυνση",
|
||||
"FILTER_CHANNELS": "Φιλτράρισμα καναλιών",
|
||||
"CLOSE_FILTER": "Κλείσιμο φίλτρου",
|
||||
"SEARCH_PROGRAMS": "Αναζήτηση προγραμμάτων",
|
||||
"CLOSE_PROGRAM_SEARCH": "Κλείσιμο αναζήτησης προγραμμάτων",
|
||||
"LIVE_NOW": "Ζωντανά τώρα",
|
||||
"LIVE_STREAM": "Ζωντανή ροή",
|
||||
"TIMESHIFT_AVAILABLE": "Η χρονική μετατόπιση είναι διαθέσιμη, κάντε κλικ για αναπαραγωγή.",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
|
||||
"LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας",
|
||||
"LIVE_RECENT": "Συνέχιση με ζωντανή TV",
|
||||
"REMAINING_SECONDS": "Απομένουν {{seconds}} δ",
|
||||
"REMAINING_MINUTES": "Απομένουν {{minutes}} λ",
|
||||
"REMAINING_HOURS": "Απομένουν {{hours}} ώ",
|
||||
"REMAINING_HOURS_MINUTES": "Απομένουν {{hours}} ώ {{minutes}} λ",
|
||||
"PERCENT_WATCHED": "Προβλήθηκε {{value}}%",
|
||||
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
|
||||
"GLOBAL_FAVORITES": "Βασικά Αγαπημένα",
|
||||
"REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό",
|
||||
"RECENT_SOURCES": "Πρόσφατα χρησιμοποιημένες πηγές",
|
||||
"MANAGE_ALL": "Διαχείριση όλων",
|
||||
"SEE_ALL": "Δείτε όλα",
|
||||
"SEE_ALL_COUNT": "Δείτε όλα {{count}}",
|
||||
"RECENTLY_WATCHED": "Πρόσφατα προβληθέντα",
|
||||
"XTREAM_RECENTLY_ADDED": "Πρόσφατα προστέθηκαν στο Xtream",
|
||||
"NO_HISTORY": "Δεν βρέθηκαν στοιχεία για αυτό το φίλτρο.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Ζωντανά",
|
||||
"TYPE_MOVIE": "Ταινία",
|
||||
"TYPE_SERIES": "Σειρά",
|
||||
"SEASON_EPISODE_BADGE": "Σ{{season}}·Ε{{episode}}",
|
||||
"NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated",
|
||||
"AUTO_REFRESH_ENABLED": "Auto-refresh enabled"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Pick how you want to connect. You can rename it later.",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "Paste a link to a remote .m3u or .m3u8",
|
||||
"METHOD_FILE_LABEL": "M3U file",
|
||||
"METHOD_FILE_SUB": "Upload a playlist from your computer",
|
||||
"METHOD_XTREAM_LABEL": "Xtream credentials",
|
||||
"METHOD_XTREAM_SUB": "Connect with host, username and password",
|
||||
"METHOD_STALKER_LABEL": "Stalker portal",
|
||||
"METHOD_STALKER_SUB": "MAC address and portal URL",
|
||||
"METHOD_TEXT_LABEL": "Raw m3u text",
|
||||
"METHOD_TEXT_SUB": "For one-off lists or when you only have text"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Drop an .m3u or .m3u8 file here",
|
||||
"OR": "or",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dark theme",
|
||||
"LIGHT_THEME": "Light theme",
|
||||
"SYSTEM_THEME": "System theme"
|
||||
"DARK_THEME": "Dark",
|
||||
"LIGHT_THEME": "Light",
|
||||
"SYSTEM_THEME": "System"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Play",
|
||||
"PAUSE": "Pause",
|
||||
"PREVIOUS_STATION": "Previous station",
|
||||
"NEXT_STATION": "Next station",
|
||||
"MUTE": "Mute",
|
||||
"UNMUTE": "Unmute",
|
||||
"VOLUME": "Volume"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Electronic Program Guide",
|
||||
"NEXT_DAY": "Next day",
|
||||
"PREVIOUS_DAY": "Previous day",
|
||||
"ZOOM_IN": "Zoom in",
|
||||
"ZOOM_OUT": "Zoom out",
|
||||
"FILTER_CHANNELS": "Filter channels",
|
||||
"CLOSE_FILTER": "Close filter",
|
||||
"SEARCH_PROGRAMS": "Search programs",
|
||||
"CLOSE_PROGRAM_SEARCH": "Close program search",
|
||||
"LIVE_NOW": "Live now",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift is available, click to play",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Sort categories",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Continue Watching",
|
||||
"LIVE_FAVORITES": "Live now on your favorites",
|
||||
"LIVE_RECENT": "Continue with live TV",
|
||||
"REMAINING_SECONDS": "{{seconds}}s left",
|
||||
"REMAINING_MINUTES": "{{minutes}}m left",
|
||||
"REMAINING_HOURS": "{{hours}}h left",
|
||||
"REMAINING_HOURS_MINUTES": "{{hours}}h {{minutes}}m left",
|
||||
"PERCENT_WATCHED": "{{value}}% watched",
|
||||
"UNTITLED_SOURCE": "Untitled source",
|
||||
"GLOBAL_FAVORITES": "Global Favorites",
|
||||
"REMOVE_FROM_HISTORY": "Remove from history",
|
||||
"RECENT_SOURCES": "Recently Used Sources",
|
||||
"MANAGE_ALL": "Manage all",
|
||||
"SEE_ALL": "See all",
|
||||
"SEE_ALL_COUNT": "See all {{count}}",
|
||||
"RECENTLY_WATCHED": "Recently Watched",
|
||||
"XTREAM_RECENTLY_ADDED": "Recently Added on Xtream",
|
||||
"NO_HISTORY": "No items found for this filter.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Live",
|
||||
"TYPE_MOVIE": "Movie",
|
||||
"TYPE_SERIES": "Series",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Not yet synced"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "¡Listo! Las listas de reproducción se actualizaron correctamente",
|
||||
"AUTO_REFRESH_ENABLED": "Auto-actualización activada"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Elige cómo quieres conectar. Puedes renombrarla más tarde.",
|
||||
"METHOD_URL_LABEL": "URL de M3U",
|
||||
"METHOD_URL_SUB": "Pega un enlace a un archivo .m3u o .m3u8 remoto",
|
||||
"METHOD_FILE_LABEL": "Archivo M3U",
|
||||
"METHOD_FILE_SUB": "Sube una lista de reproducción desde tu computadora",
|
||||
"METHOD_XTREAM_LABEL": "Credenciales de Xtream",
|
||||
"METHOD_XTREAM_SUB": "Conecta con host, usuario y contraseña",
|
||||
"METHOD_STALKER_LABEL": "Portal Stalker",
|
||||
"METHOD_STALKER_SUB": "Dirección MAC y URL del portal",
|
||||
"METHOD_TEXT_LABEL": "Texto m3u sin formato",
|
||||
"METHOD_TEXT_SUB": "Para listas puntuales o cuando solo tienes texto"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Arrastre & suelte archivos aquí",
|
||||
"OR": "o",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Tema oscuro",
|
||||
"LIGHT_THEME": "Tema claro",
|
||||
"SYSTEM_THEME": "Tema del sistema"
|
||||
"DARK_THEME": "Oscuro",
|
||||
"LIGHT_THEME": "Claro",
|
||||
"SYSTEM_THEME": "Sistema"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Reproducir",
|
||||
"PAUSE": "Pausar",
|
||||
"PREVIOUS_STATION": "Emisora anterior",
|
||||
"NEXT_STATION": "Emisora siguiente",
|
||||
"MUTE": "Silenciar",
|
||||
"UNMUTE": "Activar sonido",
|
||||
"VOLUME": "Volumen"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Guía electrónica de programación",
|
||||
"NEXT_DAY": "Día siguiente",
|
||||
"PREVIOUS_DAY": "Día anterior",
|
||||
"ZOOM_IN": "Acercar",
|
||||
"ZOOM_OUT": "Alejar",
|
||||
"FILTER_CHANNELS": "Filtrar canales",
|
||||
"CLOSE_FILTER": "Cerrar filtro",
|
||||
"SEARCH_PROGRAMS": "Buscar programas",
|
||||
"CLOSE_PROGRAM_SEARCH": "Cerrar búsqueda de programas",
|
||||
"LIVE_NOW": "Ahora en vivo",
|
||||
"LIVE_STREAM": "Transmisión en vivo",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift está disponible, clic para reproducir",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Ordenar categorías",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Continuar viendo",
|
||||
"LIVE_FAVORITES": "Ahora en vivo en tus favoritos",
|
||||
"LIVE_RECENT": "Continuar con TV en vivo",
|
||||
"REMAINING_SECONDS": "Quedan {{seconds}} s",
|
||||
"REMAINING_MINUTES": "Quedan {{minutes}} min",
|
||||
"REMAINING_HOURS": "Quedan {{hours}} h",
|
||||
"REMAINING_HOURS_MINUTES": "Quedan {{hours}} h {{minutes}} min",
|
||||
"PERCENT_WATCHED": "{{value}}% visto",
|
||||
"UNTITLED_SOURCE": "Fuente sin título",
|
||||
"GLOBAL_FAVORITES": "Favoritos globales",
|
||||
"REMOVE_FROM_HISTORY": "Quitar del historial",
|
||||
"RECENT_SOURCES": "Fuentes usadas recientemente",
|
||||
"MANAGE_ALL": "Gestionar todo",
|
||||
"SEE_ALL": "Ver todo",
|
||||
"SEE_ALL_COUNT": "Ver todos ({{count}})",
|
||||
"RECENTLY_WATCHED": "Vistos recientemente",
|
||||
"XTREAM_RECENTLY_ADDED": "Añadidos recientemente en Xtream",
|
||||
"NO_HISTORY": "No se encontraron elementos para este filtro.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "En vivo",
|
||||
"TYPE_MOVIE": "Película",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Aún no sincronizado"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Succès ! Les listes de lecture ont été mises à jour avec succès",
|
||||
"AUTO_REFRESH_ENABLED": "Actualisation automatique activée"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Choisissez comment vous souhaitez vous connecter. Vous pourrez renommer la liste plus tard.",
|
||||
"METHOD_URL_LABEL": "URL M3U",
|
||||
"METHOD_URL_SUB": "Collez un lien vers un fichier .m3u ou .m3u8 distant",
|
||||
"METHOD_FILE_LABEL": "Fichier M3U",
|
||||
"METHOD_FILE_SUB": "Téléversez une liste de lecture depuis votre ordinateur",
|
||||
"METHOD_XTREAM_LABEL": "Identifiants Xtream",
|
||||
"METHOD_XTREAM_SUB": "Connectez-vous avec hôte, nom d'utilisateur et mot de passe",
|
||||
"METHOD_STALKER_LABEL": "Portail Stalker",
|
||||
"METHOD_STALKER_SUB": "Adresse MAC et URL du portail",
|
||||
"METHOD_TEXT_LABEL": "Texte m3u brut",
|
||||
"METHOD_TEXT_SUB": "Pour les listes ponctuelles ou quand vous n'avez que du texte"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Glissez un fichier ici",
|
||||
"OR": "ou",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Thème foncé",
|
||||
"LIGHT_THEME": "Thème clair",
|
||||
"SYSTEM_THEME": "Thème du système"
|
||||
"DARK_THEME": "Sombre",
|
||||
"LIGHT_THEME": "Clair",
|
||||
"SYSTEM_THEME": "Système"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Lecture",
|
||||
"PAUSE": "Pause",
|
||||
"PREVIOUS_STATION": "Station précédente",
|
||||
"NEXT_STATION": "Station suivante",
|
||||
"MUTE": "Muet",
|
||||
"UNMUTE": "Activer le son",
|
||||
"VOLUME": "Volume"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Guide électronique des programmes",
|
||||
"NEXT_DAY": "Jour suivant",
|
||||
"PREVIOUS_DAY": "Jour précédent",
|
||||
"ZOOM_IN": "Zoom avant",
|
||||
"ZOOM_OUT": "Zoom arrière",
|
||||
"FILTER_CHANNELS": "Filtrer les chaînes",
|
||||
"CLOSE_FILTER": "Fermer le filtre",
|
||||
"SEARCH_PROGRAMS": "Rechercher des programmes",
|
||||
"CLOSE_PROGRAM_SEARCH": "Fermer la recherche de programmes",
|
||||
"LIVE_NOW": "En direct",
|
||||
"LIVE_STREAM": "Diffusion en direct",
|
||||
"TIMESHIFT_AVAILABLE": "Contrôle du direct disponible, cliquez pour lire",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Trier les catégories",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Reprendre la lecture",
|
||||
"LIVE_FAVORITES": "En direct sur vos favoris",
|
||||
"LIVE_RECENT": "Reprendre la TV en direct",
|
||||
"REMAINING_SECONDS": "Il reste {{seconds}} s",
|
||||
"REMAINING_MINUTES": "Il reste {{minutes}} min",
|
||||
"REMAINING_HOURS": "Il reste {{hours}} h",
|
||||
"REMAINING_HOURS_MINUTES": "Il reste {{hours}} h {{minutes}} min",
|
||||
"PERCENT_WATCHED": "{{value}}% regardé",
|
||||
"UNTITLED_SOURCE": "Source sans titre",
|
||||
"GLOBAL_FAVORITES": "Favoris globaux",
|
||||
"REMOVE_FROM_HISTORY": "Retirer de l'historique",
|
||||
"RECENT_SOURCES": "Sources récemment utilisées",
|
||||
"MANAGE_ALL": "Tout gérer",
|
||||
"SEE_ALL": "Voir tout",
|
||||
"SEE_ALL_COUNT": "Voir les {{count}}",
|
||||
"RECENTLY_WATCHED": "Récemment regardés",
|
||||
"XTREAM_RECENTLY_ADDED": "Récemment ajoutés sur Xtream",
|
||||
"NO_HISTORY": "Aucun élément trouvé pour ce filtre.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Direct",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Série",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Pas encore synchronisé"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Le playlist sono state aggiornate correttamente",
|
||||
"AUTO_REFRESH_ENABLED": "Aggiornamento automatico attivo"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Scegli come connetterti. Puoi rinominarla in seguito.",
|
||||
"METHOD_URL_LABEL": "URL M3U",
|
||||
"METHOD_URL_SUB": "Incolla un link a un file .m3u o .m3u8 remoto",
|
||||
"METHOD_FILE_LABEL": "File M3U",
|
||||
"METHOD_FILE_SUB": "Carica una playlist dal tuo computer",
|
||||
"METHOD_XTREAM_LABEL": "Credenziali Xtream",
|
||||
"METHOD_XTREAM_SUB": "Connetti con host, nome utente e password",
|
||||
"METHOD_STALKER_LABEL": "Portale Stalker",
|
||||
"METHOD_STALKER_SUB": "Indirizzo MAC e URL del portale",
|
||||
"METHOD_TEXT_LABEL": "Testo m3u grezzo",
|
||||
"METHOD_TEXT_SUB": "Per liste occasionali o quando hai solo il testo"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Trascina e rilascia qui i file",
|
||||
"OR": "o",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Tema scuro",
|
||||
"LIGHT_THEME": "Tema chiaro",
|
||||
"SYSTEM_THEME": "Tema di sistema"
|
||||
"DARK_THEME": "Scuro",
|
||||
"LIGHT_THEME": "Chiaro",
|
||||
"SYSTEM_THEME": "Sistema"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Riproduci",
|
||||
"PAUSE": "Pausa",
|
||||
"PREVIOUS_STATION": "Stazione precedente",
|
||||
"NEXT_STATION": "Stazione successiva",
|
||||
"MUTE": "Disattiva audio",
|
||||
"UNMUTE": "Attiva audio",
|
||||
"VOLUME": "Volume"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Guida elettronica programmi",
|
||||
"NEXT_DAY": "Giorno successivo",
|
||||
"PREVIOUS_DAY": "Giorno precedente",
|
||||
"ZOOM_IN": "Ingrandisci",
|
||||
"ZOOM_OUT": "Riduci",
|
||||
"FILTER_CHANNELS": "Filtra canali",
|
||||
"CLOSE_FILTER": "Chiudi filtro",
|
||||
"SEARCH_PROGRAMS": "Cerca programmi",
|
||||
"CLOSE_PROGRAM_SEARCH": "Chiudi ricerca programmi",
|
||||
"LIVE_NOW": "Live attuale",
|
||||
"LIVE_STREAM": "Stream live",
|
||||
"TIMESHIFT_AVAILABLE": "Disponibile il timeshift, fai clic per riprodurre",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Ordina categorie",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Continua a guardare",
|
||||
"LIVE_FAVORITES": "Live ora sui tuoi preferiti",
|
||||
"LIVE_RECENT": "Continua con la TV live",
|
||||
"REMAINING_SECONDS": "Mancano {{seconds}} s",
|
||||
"REMAINING_MINUTES": "Mancano {{minutes}} min",
|
||||
"REMAINING_HOURS": "Mancano {{hours}} h",
|
||||
"REMAINING_HOURS_MINUTES": "Mancano {{hours}} h {{minutes}} min",
|
||||
"PERCENT_WATCHED": "{{value}}% visto",
|
||||
"UNTITLED_SOURCE": "Sorgente senza titolo",
|
||||
"GLOBAL_FAVORITES": "Preferiti globali",
|
||||
"REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia",
|
||||
"RECENT_SOURCES": "Sorgenti usate di recente",
|
||||
"MANAGE_ALL": "Gestisci tutto",
|
||||
"SEE_ALL": "Vedi tutto",
|
||||
"SEE_ALL_COUNT": "Vedi tutti ({{count}})",
|
||||
"RECENTLY_WATCHED": "Visti di recente",
|
||||
"XTREAM_RECENTLY_ADDED": "Aggiunti di recente su Xtream",
|
||||
"NO_HISTORY": "Nessun elemento corrisponde a questo filtro.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Live",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Non ancora sincronizzato"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!プレイリストが正常に更新されました",
|
||||
"AUTO_REFRESH_ENABLED": "自動更新が有効です"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "接続方法を選択してください。名前は後から変更できます。",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "リモートの.m3uまたは.m3u8へのリンクを貼り付け",
|
||||
"METHOD_FILE_LABEL": "M3Uファイル",
|
||||
"METHOD_FILE_SUB": "コンピューターからプレイリストをアップロード",
|
||||
"METHOD_XTREAM_LABEL": "Xtream認証情報",
|
||||
"METHOD_XTREAM_SUB": "ホスト、ユーザー名、パスワードで接続",
|
||||
"METHOD_STALKER_LABEL": "Stalkerポータル",
|
||||
"METHOD_STALKER_SUB": "MACアドレスとポータルURL",
|
||||
"METHOD_TEXT_LABEL": "M3Uテキスト",
|
||||
"METHOD_TEXT_SUB": "一度限りのリストやテキストしかない場合に"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "ここにファイルをドラッグ&ドロップ",
|
||||
"OR": "または",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "ダークテーマ",
|
||||
"LIGHT_THEME": "ライトテーマ",
|
||||
"SYSTEM_THEME": "システムテーマ"
|
||||
"DARK_THEME": "ダーク",
|
||||
"LIGHT_THEME": "ライト",
|
||||
"SYSTEM_THEME": "システム"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "再生",
|
||||
"PAUSE": "一時停止",
|
||||
"PREVIOUS_STATION": "前の局",
|
||||
"NEXT_STATION": "次の局",
|
||||
"MUTE": "ミュート",
|
||||
"UNMUTE": "ミュート解除",
|
||||
"VOLUME": "音量"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "電子番組表",
|
||||
"NEXT_DAY": "翌日",
|
||||
"PREVIOUS_DAY": "前日",
|
||||
"ZOOM_IN": "拡大",
|
||||
"ZOOM_OUT": "縮小",
|
||||
"FILTER_CHANNELS": "チャンネルをフィルター",
|
||||
"CLOSE_FILTER": "フィルターを閉じる",
|
||||
"SEARCH_PROGRAMS": "番組を検索",
|
||||
"CLOSE_PROGRAM_SEARCH": "番組検索を閉じる",
|
||||
"LIVE_NOW": "現在放送中",
|
||||
"LIVE_STREAM": "ライブストリーム",
|
||||
"TIMESHIFT_AVAILABLE": "タイムシフトが利用可能です。クリックして再生",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "視聴を再開",
|
||||
"LIVE_FAVORITES": "お気に入りで現在放送中",
|
||||
"LIVE_RECENT": "ライブTVの視聴を再開",
|
||||
"REMAINING_SECONDS": "残り{{seconds}}秒",
|
||||
"REMAINING_MINUTES": "残り{{minutes}}分",
|
||||
"REMAINING_HOURS": "残り{{hours}}時間",
|
||||
"REMAINING_HOURS_MINUTES": "残り{{hours}}時間{{minutes}}分",
|
||||
"PERCENT_WATCHED": "{{value}}% 視聴済み",
|
||||
"UNTITLED_SOURCE": "無題のソース",
|
||||
"GLOBAL_FAVORITES": "グローバルお気に入り",
|
||||
"REMOVE_FROM_HISTORY": "履歴から削除",
|
||||
"RECENT_SOURCES": "最近使用したソース",
|
||||
"MANAGE_ALL": "すべて管理",
|
||||
"SEE_ALL": "すべて表示",
|
||||
"SEE_ALL_COUNT": "すべて表示 ({{count}})",
|
||||
"RECENTLY_WATCHED": "最近視聴した項目",
|
||||
"XTREAM_RECENTLY_ADDED": "Xtreamに最近追加された項目",
|
||||
"NO_HISTORY": "このフィルターに該当する項目はありません。",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "ライブ",
|
||||
"TYPE_MOVIE": "映画",
|
||||
"TYPE_SERIES": "シリーズ",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}・E{{episode}}",
|
||||
"NOT_YET_SYNCED": "未同期"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "성공! 재생목록이 성공적으로 업데이트되었습니다",
|
||||
"AUTO_REFRESH_ENABLED": "자동 새로 고침 활성화됨"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "연결 방식을 선택하세요. 이름은 나중에 변경할 수 있습니다.",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "원격 .m3u 또는 .m3u8 링크를 붙여넣으세요",
|
||||
"METHOD_FILE_LABEL": "M3U 파일",
|
||||
"METHOD_FILE_SUB": "내 컴퓨터에서 재생목록을 업로드하세요",
|
||||
"METHOD_XTREAM_LABEL": "Xtream 자격 증명",
|
||||
"METHOD_XTREAM_SUB": "호스트, 사용자 이름, 비밀번호로 연결하세요",
|
||||
"METHOD_STALKER_LABEL": "Stalker 포털",
|
||||
"METHOD_STALKER_SUB": "MAC 주소와 포털 URL",
|
||||
"METHOD_TEXT_LABEL": "원본 m3u 텍스트",
|
||||
"METHOD_TEXT_SUB": "일회성 목록이나 텍스트만 있을 때 사용하세요"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "파일을 드래그 앤 드롭",
|
||||
"OR": "또는",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "어두운 테마",
|
||||
"LIGHT_THEME": "밝은 테마",
|
||||
"SYSTEM_THEME": "시스템 테마"
|
||||
"DARK_THEME": "어둡게",
|
||||
"LIGHT_THEME": "밝게",
|
||||
"SYSTEM_THEME": "시스템"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "재생",
|
||||
"PAUSE": "일시정지",
|
||||
"PREVIOUS_STATION": "이전 방송국",
|
||||
"NEXT_STATION": "다음 방송국",
|
||||
"MUTE": "음소거",
|
||||
"UNMUTE": "음소거 해제",
|
||||
"VOLUME": "볼륨"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "전자 프로그램 가이드",
|
||||
"NEXT_DAY": "다음날",
|
||||
"PREVIOUS_DAY": "전날",
|
||||
"ZOOM_IN": "확대",
|
||||
"ZOOM_OUT": "축소",
|
||||
"FILTER_CHANNELS": "채널 필터",
|
||||
"CLOSE_FILTER": "필터 닫기",
|
||||
"SEARCH_PROGRAMS": "프로그램 검색",
|
||||
"CLOSE_PROGRAM_SEARCH": "프로그램 검색 닫기",
|
||||
"LIVE_NOW": "Live now",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift를 사용할 수 있습니다, 클릭하여 재생합니다",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "카테고리 정렬",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "이어서 시청",
|
||||
"LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다",
|
||||
"LIVE_RECENT": "라이브 TV 이어서 시청",
|
||||
"REMAINING_SECONDS": "{{seconds}}초 남음",
|
||||
"REMAINING_MINUTES": "{{minutes}}분 남음",
|
||||
"REMAINING_HOURS": "{{hours}}시간 남음",
|
||||
"REMAINING_HOURS_MINUTES": "{{hours}}시간 {{minutes}}분 남음",
|
||||
"PERCENT_WATCHED": "{{value}}% 시청함",
|
||||
"UNTITLED_SOURCE": "제목 없는 소스",
|
||||
"GLOBAL_FAVORITES": "전체 즐겨찾기",
|
||||
"REMOVE_FROM_HISTORY": "기록에서 제거",
|
||||
"RECENT_SOURCES": "최근에 사용한 소스",
|
||||
"MANAGE_ALL": "모두 관리",
|
||||
"SEE_ALL": "모두 보기",
|
||||
"SEE_ALL_COUNT": "{{count}}개 모두 보기",
|
||||
"RECENTLY_WATCHED": "최근에 시청한 항목",
|
||||
"XTREAM_RECENTLY_ADDED": "Xtream에 최근 추가됨",
|
||||
"NO_HISTORY": "이 필터에 해당하는 항목이 없습니다.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "라이브",
|
||||
"TYPE_MOVIE": "영화",
|
||||
"TYPE_SERIES": "시리즈",
|
||||
"SEASON_EPISODE_BADGE": "시즌{{season}} {{episode}}화",
|
||||
"NOT_YET_SYNCED": "아직 동기화되지 않음"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Gelukt! De afspeellijsten zijn succesvol bijgewerkt",
|
||||
"AUTO_REFRESH_ENABLED": "Automatisch vernieuwen ingeschakeld"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Kies hoe je verbinding wilt maken. Je kunt het later hernoemen.",
|
||||
"METHOD_URL_LABEL": "M3U-URL",
|
||||
"METHOD_URL_SUB": "Plak een link naar een externe .m3u of .m3u8",
|
||||
"METHOD_FILE_LABEL": "M3U-bestand",
|
||||
"METHOD_FILE_SUB": "Upload een afspeellijst vanaf je computer",
|
||||
"METHOD_XTREAM_LABEL": "Xtream-inloggegevens",
|
||||
"METHOD_XTREAM_SUB": "Maak verbinding met host, gebruikersnaam en wachtwoord",
|
||||
"METHOD_STALKER_LABEL": "Stalker-portaal",
|
||||
"METHOD_STALKER_SUB": "MAC-adres en portal-URL",
|
||||
"METHOD_TEXT_LABEL": "Ruwe m3u-tekst",
|
||||
"METHOD_TEXT_SUB": "Voor eenmalige lijsten of als je alleen tekst hebt"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Sleep bestanden hierheen",
|
||||
"OR": "of",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Donker thema",
|
||||
"LIGHT_THEME": "Licht thema",
|
||||
"SYSTEM_THEME": "Systeemthema"
|
||||
"DARK_THEME": "Donker",
|
||||
"LIGHT_THEME": "Licht",
|
||||
"SYSTEM_THEME": "Systeem"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Afspelen",
|
||||
"PAUSE": "Pauzeren",
|
||||
"PREVIOUS_STATION": "Vorige zender",
|
||||
"NEXT_STATION": "Volgende zender",
|
||||
"MUTE": "Dempen",
|
||||
"UNMUTE": "Dempen opheffen",
|
||||
"VOLUME": "Volume"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "TV-Gids",
|
||||
"NEXT_DAY": "Volgende dag",
|
||||
"PREVIOUS_DAY": "Vorige dag",
|
||||
"ZOOM_IN": "Inzoomen",
|
||||
"ZOOM_OUT": "Uitzoomen",
|
||||
"FILTER_CHANNELS": "Kanalen filteren",
|
||||
"CLOSE_FILTER": "Filter sluiten",
|
||||
"SEARCH_PROGRAMS": "Programma's zoeken",
|
||||
"CLOSE_PROGRAM_SEARCH": "Programmazoekopdracht sluiten",
|
||||
"LIVE_NOW": "Nu live",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Doorspoelen is beschikbaar, klik om af te spelen",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Verder kijken",
|
||||
"LIVE_FAVORITES": "Nu live op je favorieten",
|
||||
"LIVE_RECENT": "Verder kijken met live tv",
|
||||
"REMAINING_SECONDS": "Nog {{seconds}} sec",
|
||||
"REMAINING_MINUTES": "Nog {{minutes}} min",
|
||||
"REMAINING_HOURS": "Nog {{hours}} u",
|
||||
"REMAINING_HOURS_MINUTES": "Nog {{hours}} u {{minutes}} min",
|
||||
"PERCENT_WATCHED": "{{value}}% bekeken",
|
||||
"UNTITLED_SOURCE": "Naamloze bron",
|
||||
"GLOBAL_FAVORITES": "Algemene favorieten",
|
||||
"REMOVE_FROM_HISTORY": "Verwijderen uit geschiedenis",
|
||||
"RECENT_SOURCES": "Recent gebruikte bronnen",
|
||||
"MANAGE_ALL": "Alles beheren",
|
||||
"SEE_ALL": "Alles bekijken",
|
||||
"SEE_ALL_COUNT": "Alle {{count}} bekijken",
|
||||
"RECENTLY_WATCHED": "Recent bekeken",
|
||||
"XTREAM_RECENTLY_ADDED": "Recent toegevoegd op Xtream",
|
||||
"NO_HISTORY": "Geen items gevonden voor dit filter.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Live",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Series",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·A{{episode}}",
|
||||
"NOT_YET_SYNCED": "Nog niet gesynchroniseerd"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Sukces! Listy odtwarzania zostały pomyślnie zaktualizowane",
|
||||
"AUTO_REFRESH_ENABLED": "Automatyczne odświeżanie włączone"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Wybierz sposób połączenia. Nazwę można zmienić później.",
|
||||
"METHOD_URL_LABEL": "Adres URL M3U",
|
||||
"METHOD_URL_SUB": "Wklej link do zdalnego pliku .m3u lub .m3u8",
|
||||
"METHOD_FILE_LABEL": "Plik M3U",
|
||||
"METHOD_FILE_SUB": "Prześlij listę odtwarzania z komputera",
|
||||
"METHOD_XTREAM_LABEL": "Dane logowania Xtream",
|
||||
"METHOD_XTREAM_SUB": "Połącz przy użyciu hosta, nazwy użytkownika i hasła",
|
||||
"METHOD_STALKER_LABEL": "Portal Stalker",
|
||||
"METHOD_STALKER_SUB": "Adres MAC i URL portalu",
|
||||
"METHOD_TEXT_LABEL": "Surowy tekst m3u",
|
||||
"METHOD_TEXT_SUB": "Do jednorazowych list lub gdy dostępny jest tylko tekst"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Przeciągnij i upuść pliki tutaj",
|
||||
"OR": "lub",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Ciemny motyw",
|
||||
"LIGHT_THEME": "Jasny motyw",
|
||||
"SYSTEM_THEME": "Motyw systemowy"
|
||||
"DARK_THEME": "Ciemny",
|
||||
"LIGHT_THEME": "Jasny",
|
||||
"SYSTEM_THEME": "Systemowy"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Odtwórz",
|
||||
"PAUSE": "Wstrzymaj",
|
||||
"PREVIOUS_STATION": "Poprzednia stacja",
|
||||
"NEXT_STATION": "Następna stacja",
|
||||
"MUTE": "Wycisz",
|
||||
"UNMUTE": "Wyłącz wyciszenie",
|
||||
"VOLUME": "Głośność"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Elektroniczny przewodnik po programach",
|
||||
"NEXT_DAY": "Następny dzień",
|
||||
"PREVIOUS_DAY": "Poprzedni dzień",
|
||||
"ZOOM_IN": "Powiększ",
|
||||
"ZOOM_OUT": "Pomniejsz",
|
||||
"FILTER_CHANNELS": "Filtruj kanały",
|
||||
"CLOSE_FILTER": "Zamknij filtr",
|
||||
"SEARCH_PROGRAMS": "Szukaj programów",
|
||||
"CLOSE_PROGRAM_SEARCH": "Zamknij wyszukiwanie programów",
|
||||
"LIVE_NOW": "Na żywo teraz",
|
||||
"LIVE_STREAM": "Transmisja na żywo",
|
||||
"TIMESHIFT_AVAILABLE": "Dostępne jest przesunięcie czasowe, kliknij, aby odtworzyć",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
|
||||
"LIVE_FAVORITES": "Na żywo w Twoich ulubionych",
|
||||
"LIVE_RECENT": "Kontynuuj oglądanie na żywo",
|
||||
"REMAINING_SECONDS": "Pozostało {{seconds}} s",
|
||||
"REMAINING_MINUTES": "Pozostało {{minutes}} min",
|
||||
"REMAINING_HOURS": "Pozostało {{hours}} godz.",
|
||||
"REMAINING_HOURS_MINUTES": "Pozostało {{hours}} godz. {{minutes}} min",
|
||||
"PERCENT_WATCHED": "Obejrzano {{value}}%",
|
||||
"UNTITLED_SOURCE": "Źródło bez tytułu",
|
||||
"GLOBAL_FAVORITES": "Globalne ulubione",
|
||||
"REMOVE_FROM_HISTORY": "Usuń z historii",
|
||||
"RECENT_SOURCES": "Ostatnio używane źródła",
|
||||
"MANAGE_ALL": "Zarządzaj wszystkimi",
|
||||
"SEE_ALL": "Zobacz wszystkie",
|
||||
"SEE_ALL_COUNT": "Zobacz wszystkie ({{count}})",
|
||||
"RECENTLY_WATCHED": "Ostatnio oglądane",
|
||||
"XTREAM_RECENTLY_ADDED": "Ostatnio dodane w Xtream",
|
||||
"NO_HISTORY": "Brak elementów dla tego filtra.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Na żywo",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serial",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·O{{episode}}",
|
||||
"NOT_YET_SYNCED": "Jeszcze niezsynchronizowane"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Sucesso! As playlists foram atualizadas com sucesso",
|
||||
"AUTO_REFRESH_ENABLED": "Atualização automática ativada"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Escolha como você quer se conectar. Você pode renomeá-la depois.",
|
||||
"METHOD_URL_LABEL": "URL M3U",
|
||||
"METHOD_URL_SUB": "Cole um link para um arquivo .m3u ou .m3u8 remoto",
|
||||
"METHOD_FILE_LABEL": "Arquivo M3U",
|
||||
"METHOD_FILE_SUB": "Envie uma playlist do seu computador",
|
||||
"METHOD_XTREAM_LABEL": "Credenciais Xtream",
|
||||
"METHOD_XTREAM_SUB": "Conecte-se com host, nome de usuário e senha",
|
||||
"METHOD_STALKER_LABEL": "Portal Stalker",
|
||||
"METHOD_STALKER_SUB": "Endereço MAC e URL do portal",
|
||||
"METHOD_TEXT_LABEL": "Texto m3u bruto",
|
||||
"METHOD_TEXT_SUB": "Para listas pontuais ou quando você só tem o texto"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Arraste e solte os arquivos aqui",
|
||||
"OR": "ou",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Tema escuro",
|
||||
"LIGHT_THEME": "Tema claro",
|
||||
"SYSTEM_THEME": "Tema do sistema"
|
||||
"DARK_THEME": "Escuro",
|
||||
"LIGHT_THEME": "Claro",
|
||||
"SYSTEM_THEME": "Sistema"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Reproduzir",
|
||||
"PAUSE": "Pausar",
|
||||
"PREVIOUS_STATION": "Estação anterior",
|
||||
"NEXT_STATION": "Próxima estação",
|
||||
"MUTE": "Silenciar",
|
||||
"UNMUTE": "Ativar som",
|
||||
"VOLUME": "Volume"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Guia Eletrônico de Programação",
|
||||
"NEXT_DAY": "Próximo dia",
|
||||
"PREVIOUS_DAY": "Dia anterior",
|
||||
"ZOOM_IN": "Mais zoom",
|
||||
"ZOOM_OUT": "Menos zoom",
|
||||
"FILTER_CHANNELS": "Filtrar canais",
|
||||
"CLOSE_FILTER": "Fechar filtro",
|
||||
"SEARCH_PROGRAMS": "Pesquisar programas",
|
||||
"CLOSE_PROGRAM_SEARCH": "Fechar pesquisa de programas",
|
||||
"LIVE_NOW": "Ao vivo agora",
|
||||
"LIVE_STREAM": "Transmissão ao vivo",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift está disponível, clique para reproduzir",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Ordenar categorias",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Continuar assistindo",
|
||||
"LIVE_FAVORITES": "Ao vivo agora nos seus favoritos",
|
||||
"LIVE_RECENT": "Continuar com TV ao vivo",
|
||||
"REMAINING_SECONDS": "Restam {{seconds}} s",
|
||||
"REMAINING_MINUTES": "Restam {{minutes}} min",
|
||||
"REMAINING_HOURS": "Restam {{hours}} h",
|
||||
"REMAINING_HOURS_MINUTES": "Restam {{hours}} h {{minutes}} min",
|
||||
"PERCENT_WATCHED": "{{value}}% assistido",
|
||||
"UNTITLED_SOURCE": "Fonte sem título",
|
||||
"GLOBAL_FAVORITES": "Favoritos globais",
|
||||
"REMOVE_FROM_HISTORY": "Remover do histórico",
|
||||
"RECENT_SOURCES": "Fontes usadas recentemente",
|
||||
"MANAGE_ALL": "Gerenciar tudo",
|
||||
"SEE_ALL": "Ver todos",
|
||||
"SEE_ALL_COUNT": "Ver todos ({{count}})",
|
||||
"RECENTLY_WATCHED": "Assistidos recentemente",
|
||||
"XTREAM_RECENTLY_ADDED": "Adicionados recentemente no Xtream",
|
||||
"NO_HISTORY": "Nenhum item encontrado para este filtro.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Ao vivo",
|
||||
"TYPE_MOVIE": "Filme",
|
||||
"TYPE_SERIES": "Série",
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Ainda não sincronizado"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Успешно! Плейлисты были успешно обновлены",
|
||||
"AUTO_REFRESH_ENABLED": "Автообновление включено"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Выберите способ подключения. Переименовать плейлист можно позже.",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "Вставьте ссылку на удалённый файл .m3u или .m3u8",
|
||||
"METHOD_FILE_LABEL": "Файл M3U",
|
||||
"METHOD_FILE_SUB": "Загрузите плейлист с вашего компьютера",
|
||||
"METHOD_XTREAM_LABEL": "Учётные данные Xtream",
|
||||
"METHOD_XTREAM_SUB": "Подключение через сервер, имя пользователя и пароль",
|
||||
"METHOD_STALKER_LABEL": "Stalker портал",
|
||||
"METHOD_STALKER_SUB": "MAC-адрес и URL портала",
|
||||
"METHOD_TEXT_LABEL": "M3U текстом",
|
||||
"METHOD_TEXT_SUB": "Для разовых плейлистов или когда есть только текст"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Перетащите файл в эту область",
|
||||
"OR": "или",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Тёмная тема",
|
||||
"LIGHT_THEME": "Светлая тема",
|
||||
"SYSTEM_THEME": "Системная тема"
|
||||
"DARK_THEME": "Тёмная",
|
||||
"LIGHT_THEME": "Светлая",
|
||||
"SYSTEM_THEME": "Системная"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Воспроизвести",
|
||||
"PAUSE": "Пауза",
|
||||
"PREVIOUS_STATION": "Предыдущая станция",
|
||||
"NEXT_STATION": "Следующая станция",
|
||||
"MUTE": "Отключить звук",
|
||||
"UNMUTE": "Включить звук",
|
||||
"VOLUME": "Громкость"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Электронный программный гид",
|
||||
"NEXT_DAY": "Следующий день",
|
||||
"PREVIOUS_DAY": "Предыдущий день",
|
||||
"ZOOM_IN": "Увеличить",
|
||||
"ZOOM_OUT": "Уменьшить",
|
||||
"FILTER_CHANNELS": "Фильтровать каналы",
|
||||
"CLOSE_FILTER": "Закрыть фильтр",
|
||||
"SEARCH_PROGRAMS": "Поиск программ",
|
||||
"CLOSE_PROGRAM_SEARCH": "Закрыть поиск программ",
|
||||
"LIVE_NOW": "Сейчас",
|
||||
"LIVE_STREAM": "Прямая трансляция",
|
||||
"TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Сортировать категории",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Продолжить просмотр",
|
||||
"LIVE_FAVORITES": "Сейчас в эфире на избранных каналах",
|
||||
"LIVE_RECENT": "Продолжить просмотр прямого эфира",
|
||||
"REMAINING_SECONDS": "Осталось {{seconds}} с",
|
||||
"REMAINING_MINUTES": "Осталось {{minutes}} мин",
|
||||
"REMAINING_HOURS": "Осталось {{hours}} ч",
|
||||
"REMAINING_HOURS_MINUTES": "Осталось {{hours}} ч {{minutes}} мин",
|
||||
"PERCENT_WATCHED": "Просмотрено {{value}}%",
|
||||
"UNTITLED_SOURCE": "Источник без названия",
|
||||
"GLOBAL_FAVORITES": "Глобальные избранные",
|
||||
"REMOVE_FROM_HISTORY": "Удалить из истории",
|
||||
"RECENT_SOURCES": "Недавно использованные источники",
|
||||
"MANAGE_ALL": "Управлять всем",
|
||||
"SEE_ALL": "Смотреть все",
|
||||
"SEE_ALL_COUNT": "Смотреть все {{count}}",
|
||||
"RECENTLY_WATCHED": "Недавно просмотренные",
|
||||
"XTREAM_RECENTLY_ADDED": "Недавно добавленные на Xtream",
|
||||
"NO_HISTORY": "Для этого фильтра элементы не найдены.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "ТВ",
|
||||
"TYPE_MOVIE": "Фильм",
|
||||
"TYPE_SERIES": "Сериал",
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Еще не синхронизировано"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Başarılı! Oynatma listeleri başarıyla güncellendi",
|
||||
"AUTO_REFRESH_ENABLED": "Otomatik yenileme etkin"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "Nasıl bağlanmak istediğinizi seçin. Daha sonra yeniden adlandırabilirsiniz.",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "Uzak bir .m3u veya .m3u8 bağlantısını yapıştırın",
|
||||
"METHOD_FILE_LABEL": "M3U dosyası",
|
||||
"METHOD_FILE_SUB": "Bilgisayarınızdan bir oynatma listesi yükleyin",
|
||||
"METHOD_XTREAM_LABEL": "Xtream kimlik bilgileri",
|
||||
"METHOD_XTREAM_SUB": "Sunucu, kullanıcı adı ve parola ile bağlanın",
|
||||
"METHOD_STALKER_LABEL": "Stalker portalı",
|
||||
"METHOD_STALKER_SUB": "MAC adresi ve portal URL'si",
|
||||
"METHOD_TEXT_LABEL": "Ham m3u metni",
|
||||
"METHOD_TEXT_SUB": "Tek seferlik listeler veya yalnızca metniniz olduğunda"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Dosyaları buraya sürükleyip bırakın",
|
||||
"OR": "veya",
|
||||
@@ -362,9 +375,18 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Koyu tema",
|
||||
"LIGHT_THEME": "Açık tema",
|
||||
"SYSTEM_THEME": "Sistem teması"
|
||||
"DARK_THEME": "Koyu",
|
||||
"LIGHT_THEME": "Açık",
|
||||
"SYSTEM_THEME": "Sistem"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "Oynat",
|
||||
"PAUSE": "Duraklat",
|
||||
"PREVIOUS_STATION": "Önceki istasyon",
|
||||
"NEXT_STATION": "Sonraki istasyon",
|
||||
"MUTE": "Sesi kapat",
|
||||
"UNMUTE": "Sesi aç",
|
||||
"VOLUME": "Ses seviyesi"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "Elektronik Program Rehberi",
|
||||
"NEXT_DAY": "Sonraki gün",
|
||||
"PREVIOUS_DAY": "Önceki gün",
|
||||
"ZOOM_IN": "Yakınlaştır",
|
||||
"ZOOM_OUT": "Uzaklaştır",
|
||||
"FILTER_CHANNELS": "Kanalları filtrele",
|
||||
"CLOSE_FILTER": "Filtreyi kapat",
|
||||
"SEARCH_PROGRAMS": "Programları ara",
|
||||
"CLOSE_PROGRAM_SEARCH": "Program aramayı kapat",
|
||||
"LIVE_NOW": "Şu an canlı",
|
||||
"LIVE_STREAM": "Canlı yayın",
|
||||
"TIMESHIFT_AVAILABLE": "Zaman kaydırma mevcut, tıklayarak oynat",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "İzlemeye Devam Et",
|
||||
"LIVE_FAVORITES": "Favorilerinizde şu an canlı",
|
||||
"LIVE_RECENT": "Canlı TV ile devam et",
|
||||
"REMAINING_SECONDS": "{{seconds}} sn kaldı",
|
||||
"REMAINING_MINUTES": "{{minutes}} dk kaldı",
|
||||
"REMAINING_HOURS": "{{hours}} sa kaldı",
|
||||
"REMAINING_HOURS_MINUTES": "{{hours}} sa {{minutes}} dk kaldı",
|
||||
"PERCENT_WATCHED": "{{value}}% izlendi",
|
||||
"UNTITLED_SOURCE": "Adsız kaynak",
|
||||
"GLOBAL_FAVORITES": "Tüm Favoriler",
|
||||
"REMOVE_FROM_HISTORY": "Geçmişten kaldır",
|
||||
"RECENT_SOURCES": "Son Kullanılan Kaynaklar",
|
||||
"MANAGE_ALL": "Tümünü yönet",
|
||||
"SEE_ALL": "Tümünü gör",
|
||||
"SEE_ALL_COUNT": "{{count}} öğenin tümünü gör",
|
||||
"RECENTLY_WATCHED": "Son İzlenenler",
|
||||
"XTREAM_RECENTLY_ADDED": "Xtream'de Yeni Eklenenler",
|
||||
"NO_HISTORY": "Bu filtre için öğe bulunamadı.",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "Canlı",
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Dizi",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·B{{episode}}",
|
||||
"NOT_YET_SYNCED": "Henüz eşitlenmedi"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放列表已成功更新",
|
||||
"AUTO_REFRESH_ENABLED": "已启用自动刷新"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "选择您想要的连接方式。稍后可以重命名。",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "粘贴远程 .m3u 或 .m3u8 的链接",
|
||||
"METHOD_FILE_LABEL": "M3U 文件",
|
||||
"METHOD_FILE_SUB": "从您的电脑上传播放列表",
|
||||
"METHOD_XTREAM_LABEL": "Xtream 凭据",
|
||||
"METHOD_XTREAM_SUB": "使用主机、用户名和密码连接",
|
||||
"METHOD_STALKER_LABEL": "Stalker 门户",
|
||||
"METHOD_STALKER_SUB": "MAC 地址和门户 URL",
|
||||
"METHOD_TEXT_LABEL": "原始 m3u 文本",
|
||||
"METHOD_TEXT_SUB": "用于一次性列表,或仅有文本时"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "将文件拖放到此处",
|
||||
"OR": "或",
|
||||
@@ -362,10 +375,19 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "暗黑模式",
|
||||
"LIGHT_THEME": "明亮模式",
|
||||
"DARK_THEME": "深色",
|
||||
"LIGHT_THEME": "浅色",
|
||||
"SYSTEM_THEME": "跟随系统"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "播放",
|
||||
"PAUSE": "暂停",
|
||||
"PREVIOUS_STATION": "上一个电台",
|
||||
"NEXT_STATION": "下一个电台",
|
||||
"MUTE": "静音",
|
||||
"UNMUTE": "取消静音",
|
||||
"VOLUME": "音量"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "电子节目指南",
|
||||
"NEXT_DAY": "明天",
|
||||
"PREVIOUS_DAY": "昨天",
|
||||
"ZOOM_IN": "放大",
|
||||
"ZOOM_OUT": "缩小",
|
||||
"FILTER_CHANNELS": "筛选频道",
|
||||
"CLOSE_FILTER": "关闭筛选",
|
||||
"SEARCH_PROGRAMS": "搜索节目",
|
||||
"CLOSE_PROGRAM_SEARCH": "关闭节目搜索",
|
||||
"LIVE_NOW": "正在播放",
|
||||
"LIVE_STREAM": "正在直播",
|
||||
"TIMESHIFT_AVAILABLE": "时移功能可用,点击播放",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "排序分类",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "继续观看",
|
||||
"LIVE_FAVORITES": "收藏频道正在播放",
|
||||
"LIVE_RECENT": "继续观看直播",
|
||||
"REMAINING_SECONDS": "剩余 {{seconds}} 秒",
|
||||
"REMAINING_MINUTES": "剩余 {{minutes}} 分钟",
|
||||
"REMAINING_HOURS": "剩余 {{hours}} 小时",
|
||||
"REMAINING_HOURS_MINUTES": "剩余 {{hours}} 小时 {{minutes}} 分钟",
|
||||
"PERCENT_WATCHED": "已观看 {{value}}%",
|
||||
"UNTITLED_SOURCE": "未命名源",
|
||||
"GLOBAL_FAVORITES": "全局收藏",
|
||||
"REMOVE_FROM_HISTORY": "从历史记录中移除",
|
||||
"RECENT_SOURCES": "最近使用的源",
|
||||
"MANAGE_ALL": "管理全部",
|
||||
"SEE_ALL": "查看全部",
|
||||
"SEE_ALL_COUNT": "查看全部 {{count}} 个",
|
||||
"RECENTLY_WATCHED": "最近观看",
|
||||
"XTREAM_RECENTLY_ADDED": "Xtream 最近添加",
|
||||
"NO_HISTORY": "没有项目匹配此筛选。",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "直播",
|
||||
"TYPE_MOVIE": "电影",
|
||||
"TYPE_SERIES": "剧集",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -128,6 +128,19 @@
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放清單已成功更新",
|
||||
"AUTO_REFRESH_ENABLED": "已啟用自動重新整理"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
"PICK_METHOD": "請選擇您要使用的連線方式,之後仍可重新命名。",
|
||||
"METHOD_URL_LABEL": "M3U URL",
|
||||
"METHOD_URL_SUB": "貼上遠端 .m3u 或 .m3u8 的連結",
|
||||
"METHOD_FILE_LABEL": "M3U 檔案",
|
||||
"METHOD_FILE_SUB": "從您的電腦上傳播放清單",
|
||||
"METHOD_XTREAM_LABEL": "Xtream 帳號",
|
||||
"METHOD_XTREAM_SUB": "使用主機位址、帳號和密碼連線",
|
||||
"METHOD_STALKER_LABEL": "Stalker 入口網站",
|
||||
"METHOD_STALKER_SUB": "MAC 位址與入口網址",
|
||||
"METHOD_TEXT_LABEL": "M3U 原始文字",
|
||||
"METHOD_TEXT_SUB": "適合一次性清單,或您只有文字內容時使用"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "在此處拖放檔案",
|
||||
"OR": "或者",
|
||||
@@ -362,10 +375,19 @@
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色主題",
|
||||
"LIGHT_THEME": "亮色主題",
|
||||
"DARK_THEME": "深色",
|
||||
"LIGHT_THEME": "淺色",
|
||||
"SYSTEM_THEME": "跟隨系統"
|
||||
},
|
||||
"AUDIO_PLAYER": {
|
||||
"PLAY": "播放",
|
||||
"PAUSE": "暫停",
|
||||
"PREVIOUS_STATION": "上一個電台",
|
||||
"NEXT_STATION": "下一個電台",
|
||||
"MUTE": "靜音",
|
||||
"UNMUTE": "取消靜音",
|
||||
"VOLUME": "音量"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
@@ -506,6 +528,12 @@
|
||||
"TITLE": "電子節目指南",
|
||||
"NEXT_DAY": "下一天",
|
||||
"PREVIOUS_DAY": "上一天",
|
||||
"ZOOM_IN": "放大",
|
||||
"ZOOM_OUT": "縮小",
|
||||
"FILTER_CHANNELS": "篩選頻道",
|
||||
"CLOSE_FILTER": "關閉篩選",
|
||||
"SEARCH_PROGRAMS": "搜尋節目",
|
||||
"CLOSE_PROGRAM_SEARCH": "關閉節目搜尋",
|
||||
"LIVE_NOW": "現在播放",
|
||||
"LIVE_STREAM": "現場直播",
|
||||
"TIMESHIFT_AVAILABLE": "時間平移功能可用,點選以播放",
|
||||
@@ -823,11 +851,20 @@
|
||||
"CATEGORY_SORT_ARIA": "排序分類",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "繼續觀看",
|
||||
"LIVE_FAVORITES": "我的最愛正在直播",
|
||||
"LIVE_RECENT": "繼續觀看直播電視",
|
||||
"REMAINING_SECONDS": "剩餘 {{seconds}} 秒",
|
||||
"REMAINING_MINUTES": "剩餘 {{minutes}} 分鐘",
|
||||
"REMAINING_HOURS": "剩餘 {{hours}} 小時",
|
||||
"REMAINING_HOURS_MINUTES": "剩餘 {{hours}} 小時 {{minutes}} 分鐘",
|
||||
"PERCENT_WATCHED": "已觀看 {{value}}%",
|
||||
"UNTITLED_SOURCE": "未命名的來源",
|
||||
"GLOBAL_FAVORITES": "全域我的最愛",
|
||||
"REMOVE_FROM_HISTORY": "從歷史記錄中移除",
|
||||
"RECENT_SOURCES": "最近使用的來源",
|
||||
"MANAGE_ALL": "管理全部",
|
||||
"SEE_ALL": "查看全部",
|
||||
"SEE_ALL_COUNT": "查看全部 {{count}} 個",
|
||||
"RECENTLY_WATCHED": "最近觀看",
|
||||
"XTREAM_RECENTLY_ADDED": "Xtream 最近新增",
|
||||
"NO_HISTORY": "找不到符合此篩選條件的項目。",
|
||||
@@ -840,6 +877,7 @@
|
||||
"TYPE_LIVE": "直播",
|
||||
"TYPE_MOVIE": "電影",
|
||||
"TYPE_SERIES": "影集",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步"
|
||||
},
|
||||
"SHELL": {
|
||||
|
||||
@@ -15,11 +15,17 @@ $my-typography: mat.m2-define-typography-config(
|
||||
@include mat.typography-hierarchy($my-typography);
|
||||
|
||||
// ─── M3 Themes ───────────────────────────────────────────────────────────────
|
||||
// One primary color across the app. Azure is the closest M3 prebuilt to the
|
||||
// product's "active/selection" blue (#2f7bff / #78adff), so checkboxes, radio
|
||||
// buttons, raised CTAs, etc. now read as the same blue as rail selection.
|
||||
//
|
||||
// Cyan stays on tertiary so the EPG "now" indicator (which uses Material
|
||||
// tertiary surfaces) keeps its existing visual role.
|
||||
$light-theme: mat.define-theme(
|
||||
(
|
||||
color: (
|
||||
theme-type: light,
|
||||
primary: mat.$violet-palette,
|
||||
primary: mat.$azure-palette,
|
||||
tertiary: mat.$cyan-palette,
|
||||
),
|
||||
typography: (
|
||||
@@ -33,7 +39,7 @@ $dark-theme: mat.define-theme(
|
||||
(
|
||||
color: (
|
||||
theme-type: dark,
|
||||
primary: mat.$violet-palette,
|
||||
primary: mat.$azure-palette,
|
||||
tertiary: mat.$cyan-palette,
|
||||
),
|
||||
typography: (
|
||||
@@ -102,17 +108,17 @@ html {
|
||||
--app-eyebrow-color: #7c8495;
|
||||
|
||||
// Provider accent colors
|
||||
--app-provider-xtream: #6366f1;
|
||||
--app-provider-xtream: #2f7bff;
|
||||
--app-provider-stalker: #10b981;
|
||||
--app-provider-m3u: #d97706;
|
||||
|
||||
// Hero
|
||||
--app-accent-gradient: linear-gradient(
|
||||
135deg,
|
||||
rgba(99, 102, 241, 0.08) 0%,
|
||||
rgba(168, 85, 247, 0.05) 100%
|
||||
rgba(47, 123, 255, 0.08) 0%,
|
||||
rgba(92, 214, 255, 0.05) 100%
|
||||
);
|
||||
--app-hero-surface: rgba(99, 102, 241, 0.04);
|
||||
--app-hero-surface: rgba(47, 123, 255, 0.04);
|
||||
|
||||
.dark-theme {
|
||||
@include mat.all-component-colors($dark-theme);
|
||||
@@ -178,15 +184,15 @@ html {
|
||||
--app-eyebrow-color: #6b7494; // eyebrow labels
|
||||
|
||||
// Provider accents (slightly brighter for dark bg)
|
||||
--app-provider-xtream: #818cf8;
|
||||
--app-provider-xtream: #78adff;
|
||||
--app-provider-stalker: #34d399;
|
||||
--app-provider-m3u: #fbbf24;
|
||||
|
||||
// Hero section
|
||||
--app-accent-gradient: linear-gradient(
|
||||
135deg,
|
||||
rgba(99, 102, 241, 0.12) 0%,
|
||||
rgba(168, 85, 247, 0.07) 100%
|
||||
rgba(120, 173, 255, 0.12) 0%,
|
||||
rgba(92, 214, 255, 0.07) 100%
|
||||
);
|
||||
--app-hero-surface: rgba(30, 35, 48, 0.9);
|
||||
}
|
||||
|
||||
@@ -217,3 +217,20 @@ textarea,
|
||||
.mat-mdc-snack-bar-container.settings-snackbar {
|
||||
margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
// WCAG-friendly visually-hidden helper — content stays in the
|
||||
// accessibility tree and DOM query results but occupies no layout box.
|
||||
// Used where a visible heading would duplicate adjacent navigation
|
||||
// context (e.g. Settings page title that the left rail already
|
||||
// announces).
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -35,11 +35,11 @@ Core implementation:
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Hero — Continue Watching (most recent item) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Recently Watched · See all → │
|
||||
│ Continue Watching · See all → │
|
||||
│ [poster][poster][poster][poster] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Global Favorites · See all → │
|
||||
│ [poster][poster][poster] →→ │
|
||||
│ Live now on your favorites / Continue with live TV · See all → │
|
||||
│ [channel][channel][channel][channel] →→ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Recently Used Sources · See all → │
|
||||
│ [tile][tile][tile][tile] →→ │
|
||||
@@ -63,6 +63,9 @@ Render rules:
|
||||
5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it
|
||||
is missing the UI falls back to a blurred poster treatment instead of
|
||||
showing a flat panel.
|
||||
6. The mixed global favorites rail is not rendered on the dashboard. Live
|
||||
favorites are promoted into the live rail, while mixed favorites stay on
|
||||
`/workspace/global-favorites`.
|
||||
|
||||
## Rail Contract
|
||||
|
||||
@@ -83,10 +86,19 @@ Render rules:
|
||||
## Data Flow
|
||||
|
||||
1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`.
|
||||
2. It derives five signals via `computed()`:
|
||||
2. It derives the dashboard surface via `computed()`:
|
||||
1. `hero` — first item of `globalRecentItems()`.
|
||||
2. `recentlyWatchedCards` — maps `globalRecentItems()` to rail cards.
|
||||
3. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail
|
||||
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
|
||||
cover cards. Xtream playback positions are bulk-loaded per playlist so
|
||||
hero and cards can show progress, remaining time, and series season/
|
||||
episode badges. Series lookup uses keyed maps for both direct episode ids
|
||||
and series ids; card renders must not scan the full playback-position map.
|
||||
3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first,
|
||||
falling back to recently watched live channels when no live favorites
|
||||
exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
|
||||
fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment
|
||||
must use that key before falling back to the card title.
|
||||
4. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail
|
||||
cards. Aggregates newly added VOD and series across *all* Xtream
|
||||
playlists via `DashboardDataService.reloadXtreamRecentlyAddedItems()`,
|
||||
which calls `getGlobalRecentlyAdded('all', limit, 'xtream')` with the
|
||||
@@ -95,7 +107,6 @@ Render rules:
|
||||
playlists never see it. Cards carry a `playlist_name · type` subtitle
|
||||
so users can tell which provider each item came from. Driven by an
|
||||
effect that re-runs whenever the Xtream playlist count changes.
|
||||
4. `favoriteCards` — maps `globalFavoriteItems()` to rail cards.
|
||||
5. `sourceCards` — maps `recentPlaylists()` to rail cards. `recentPlaylists()`
|
||||
ranks M3U, Xtream, and Stalker sources by their latest recent activity
|
||||
from `globalRecentItems()`, then falls back to playlist
|
||||
@@ -114,6 +125,9 @@ Render rules:
|
||||
7. The dashboard feature triggers a fresh reload of DB-backed recent/favorite
|
||||
rows on dashboard entry so newly backfilled backdrop data is visible as soon
|
||||
as the user returns from a detail page.
|
||||
8. Playback-position reloads are keyed by the VOD/series recent set and should
|
||||
call `reloadPlaybackPositions()` through `untracked()` so live-only recent
|
||||
changes do not trigger unnecessary IPC round-trips.
|
||||
|
||||
## Empty State
|
||||
|
||||
@@ -141,6 +155,9 @@ The welcome state is rendered via the existing
|
||||
route without switching the active playlist in the header switcher.
|
||||
6. `Recently Used Sources` reflects recent source usage across all provider
|
||||
types, not just recent imports.
|
||||
7. The live rail title key must match the rendered source: favorites use
|
||||
`WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses
|
||||
`WORKSPACE.DASHBOARD.LIVE_RECENT`.
|
||||
|
||||
## Adding Or Changing Rails
|
||||
|
||||
|
||||
+32
-40
@@ -1,49 +1,41 @@
|
||||
<div class="apd">
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</h2>
|
||||
<div mat-dialog-title class="apd-head">
|
||||
<h2 class="apd-title">
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</h2>
|
||||
<p class="apd-sub">
|
||||
{{ 'HOME.ADD_PLAYLIST.PICK_METHOD' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div
|
||||
class="apd-segmented"
|
||||
role="tablist"
|
||||
aria-label="Playlist category"
|
||||
class="apd-method-grid"
|
||||
role="radiogroup"
|
||||
aria-label="Source method"
|
||||
>
|
||||
@for (opt of categoryOptions; track opt.value) {
|
||||
@for (opt of methodOptions; track opt.value) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="category() === opt.value"
|
||||
class="apd-segment"
|
||||
[class.active]="category() === opt.value"
|
||||
(click)="category.set(opt.value)"
|
||||
role="radio"
|
||||
[attr.aria-checked]="method() === opt.value"
|
||||
class="apd-method"
|
||||
[class.active]="method() === opt.value"
|
||||
(click)="method.set(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
<span class="apd-method__icon" aria-hidden="true">
|
||||
<mat-icon>{{ opt.icon }}</mat-icon>
|
||||
</span>
|
||||
<span class="apd-method__title">
|
||||
{{ opt.labelKey | translate }}
|
||||
</span>
|
||||
<span class="apd-method__sub">
|
||||
{{ opt.subKey | translate }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (category() === 'm3u') {
|
||||
<div
|
||||
class="apd-segmented apd-segmented--sub"
|
||||
role="tablist"
|
||||
aria-label="M3U source"
|
||||
>
|
||||
@for (opt of subtypeOptions; track opt.value) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="m3uSubType() === opt.value"
|
||||
class="apd-segment"
|
||||
[class.active]="m3uSubType() === opt.value"
|
||||
(click)="m3uSubType.set(opt.value)"
|
||||
>
|
||||
{{ opt.labelKey | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="apd-form-area">
|
||||
@switch (playlistType()) {
|
||||
@case ('file') {
|
||||
@@ -90,7 +82,7 @@
|
||||
@case ('url') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!urlUpload()?.form?.valid"
|
||||
(click)="submitUrlPlaylist()"
|
||||
@@ -101,7 +93,7 @@
|
||||
@case ('file') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!fileUpload()?.selectedFile()"
|
||||
(click)="fileUpload()?.confirm()"
|
||||
@@ -112,7 +104,7 @@
|
||||
@case ('text') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!textImport()?.textForm?.valid"
|
||||
(click)="
|
||||
@@ -142,7 +134,7 @@
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!xtreamImport()?.form?.valid"
|
||||
(click)="xtreamImport()?.addPlaylist()"
|
||||
@@ -154,7 +146,7 @@
|
||||
@if (stalkerImport()?.isLoading()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
@@ -165,7 +157,7 @@
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!stalkerImport()?.form?.valid"
|
||||
(click)="stalkerImport()?.addPlaylist()"
|
||||
|
||||
+139
-33
@@ -17,53 +17,106 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Segmented control ──────────────────────────────────────────────────────
|
||||
.apd-segmented {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
background: var(--apd-segment-track);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
margin-bottom: 14px;
|
||||
// ─── Dialog head (title + helper subtitle) ──────────────────────────────────
|
||||
// `mat-dialog-title` carries Material's `.mdc-dialog__title` defaults, which
|
||||
// include a 40px-tall `::before` ghost element for baseline alignment plus a
|
||||
// 6px/9px asymmetric padding. Both fight our custom two-line head — kill the
|
||||
// ghost and apply explicit padding so the title shares the same 24px
|
||||
// horizontal inset as `mat-dialog-content` below.
|
||||
.apd-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 20px 24px 4px;
|
||||
|
||||
&--sub {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-segment-track) 85%,
|
||||
transparent
|
||||
);
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.apd-segment {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--apd-body);
|
||||
padding: 8px 14px;
|
||||
border-radius: 9px;
|
||||
font: inherit;
|
||||
.apd-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--apd-heading);
|
||||
}
|
||||
|
||||
.apd-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--apd-body);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
// ─── Method grid (5 flat cards instead of 2-level segmented control) ────────
|
||||
// One card per source method. Replaces the prior 3×3 type×subtype matrix
|
||||
// which had 9 cells of which only 5 were real (Xtream-via-file, etc. didn't
|
||||
// exist). Each card is self-describing — icon + label + one-line sub.
|
||||
.apd-method-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 4px 0 16px;
|
||||
}
|
||||
|
||||
.apd-method {
|
||||
appearance: none;
|
||||
background: var(--apd-segment-track);
|
||||
border: 1px solid var(--apd-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 11px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--apd-heading);
|
||||
min-width: 0;
|
||||
// Locks every card to the same height regardless of which has the
|
||||
// longest subtitle. Without this the URL card (short text) collapses
|
||||
// and the Stalker/Xtream cards (longer text) push tall — selection
|
||||
// states then look visually inconsistent.
|
||||
min-height: 132px;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: var(--apd-heading);
|
||||
background: var(--apd-segment-active);
|
||||
border-color: color-mix(in srgb, var(--apd-accent) 32%, transparent);
|
||||
|
||||
.apd-method__icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-accent) 14%,
|
||||
transparent
|
||||
);
|
||||
color: var(--apd-accent);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--apd-segment-active);
|
||||
color: var(--apd-heading);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.25),
|
||||
inset 0 0 0 1px color-mix(in srgb, white 6%, transparent);
|
||||
background: color-mix(in srgb, var(--apd-accent) 12%, transparent);
|
||||
border-color: var(--apd-accent);
|
||||
box-shadow: 0 1px 2px
|
||||
color-mix(in srgb, var(--apd-accent) 32%, transparent);
|
||||
|
||||
.apd-method__icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-accent) 22%,
|
||||
transparent
|
||||
);
|
||||
color: var(--apd-accent);
|
||||
}
|
||||
|
||||
.apd-method__title {
|
||||
color: var(--apd-accent);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -72,6 +125,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
.apd-method__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-surface-raised) 75%,
|
||||
transparent
|
||||
);
|
||||
color: var(--apd-muted);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.apd-method__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--apd-heading);
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
|
||||
.apd-method__sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--apd-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// On narrow widths the 5-column grid wraps to 2 columns to stay legible.
|
||||
// 480px keeps the dialog usable down to small modals; below that it stacks
|
||||
// fully.
|
||||
@media (max-width: 720px) {
|
||||
.apd-method-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.apd-method-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Form area ──────────────────────────────────────────────────────────────
|
||||
.apd-form-area {
|
||||
min-height: 0;
|
||||
|
||||
+49
-12
@@ -178,18 +178,55 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
expect(component.isClearDisabled()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults to the URL method when no deep-link type is provided', () => {
|
||||
// Constructor-injected MAT_DIALOG_DATA is null in this spec setup,
|
||||
// so the dialog should land on its default first-row method.
|
||||
expect(component.method()).toBe('url');
|
||||
expect(component.playlistType()).toBe('url');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['url' as PlaylistType, 'url' as PlaylistType],
|
||||
['file' as PlaylistType, 'file' as PlaylistType],
|
||||
['text' as PlaylistType, 'text' as PlaylistType],
|
||||
['xtream' as PlaylistType, 'xtream' as PlaylistType],
|
||||
['stalker' as PlaylistType, 'stalker' as PlaylistType],
|
||||
])(
|
||||
'opens directly on the %s method when MAT_DIALOG_DATA.type === %s (deep-link from rail / shell action)',
|
||||
(deepLinkType, expectedMethod) => {
|
||||
// Rebuild the component with a deep-link to confirm the
|
||||
// flat-method signal honours the legacy { type } payload shape.
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: Store, useValue: store },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((value: string) => value),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: { type: deepLinkType },
|
||||
},
|
||||
],
|
||||
});
|
||||
const deepLinked = TestBed.runInInjectionContext(
|
||||
() => new AddPlaylistDialogComponent()
|
||||
);
|
||||
|
||||
expect(deepLinked.method()).toBe(expectedMethod);
|
||||
expect(deepLinked.playlistType()).toBe(expectedMethod);
|
||||
}
|
||||
);
|
||||
|
||||
function selectType(type: PlaylistType): void {
|
||||
if (type === 'xtream') {
|
||||
component.category.set('xtream');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'stalker') {
|
||||
component.category.set('stalker');
|
||||
return;
|
||||
}
|
||||
|
||||
component.category.set('m3u');
|
||||
component.m3uSubType.set(type);
|
||||
// The dialog now uses a single `method` signal across all 5 source
|
||||
// types — no more category × subtype matrix.
|
||||
component.method.set(type);
|
||||
}
|
||||
});
|
||||
+55
-40
@@ -12,6 +12,7 @@ import {
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
@@ -25,17 +26,16 @@ import { TextImportComponent } from '../text-import/text-import.component';
|
||||
import { UrlUploadComponent } from '../url-upload/url-upload.component';
|
||||
import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component';
|
||||
|
||||
type PlaylistCategory = 'm3u' | Extract<PlaylistType, 'xtream' | 'stalker'>;
|
||||
type M3uSubType = Extract<PlaylistType, 'url' | 'file' | 'text'>;
|
||||
|
||||
interface CategoryOption {
|
||||
value: PlaylistCategory;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SubtypeOption {
|
||||
value: M3uSubType;
|
||||
/**
|
||||
* Flat 5-method option model — replaces the prior category × subtype matrix
|
||||
* (M3U/Xtream/Stalker × URL/File/Text) which created 9 combinations of which
|
||||
* only 5 were real. Now each entry IS a method, no nesting.
|
||||
*/
|
||||
export interface PlaylistMethodOption {
|
||||
value: PlaylistType;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
subKey: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -43,6 +43,7 @@ interface SubtypeOption {
|
||||
FileUploadComponent,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
StalkerPortalImportComponent,
|
||||
TextImportComponent,
|
||||
TranslateModule,
|
||||
@@ -70,42 +71,56 @@ export class AddPlaylistDialogComponent {
|
||||
readonly xtreamImport = viewChild(XtreamCodeImportComponent);
|
||||
readonly stalkerImport = viewChild(StalkerPortalImportComponent);
|
||||
|
||||
readonly category = signal<PlaylistCategory>('m3u');
|
||||
readonly m3uSubType = signal<M3uSubType>('url');
|
||||
readonly method = signal<PlaylistType>('url');
|
||||
|
||||
readonly categoryOptions: CategoryOption[] = [
|
||||
{ value: 'm3u', label: 'M3U' },
|
||||
{ value: 'xtream', label: 'Xtream' },
|
||||
{ value: 'stalker', label: 'Stalker' },
|
||||
// Order matches the v0.22 mockup left-to-right: URL first (Most common),
|
||||
// then File, Xtream credentials, Stalker portal, raw text paste. Each
|
||||
// entry stands on its own — no nested subtypes. Labels are short and
|
||||
// sentence-cased; the "Add via …" / "Add Xtreme Code" wording from the
|
||||
// old tab labels is redundant inside a dialog already titled "Add
|
||||
// playlist".
|
||||
readonly methodOptions: PlaylistMethodOption[] = [
|
||||
{
|
||||
value: 'url',
|
||||
icon: 'public',
|
||||
labelKey: 'HOME.ADD_PLAYLIST.METHOD_URL_LABEL',
|
||||
subKey: 'HOME.ADD_PLAYLIST.METHOD_URL_SUB',
|
||||
},
|
||||
{
|
||||
value: 'file',
|
||||
icon: 'folder_open',
|
||||
labelKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_LABEL',
|
||||
subKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_SUB',
|
||||
},
|
||||
{
|
||||
value: 'xtream',
|
||||
icon: 'vpn_key',
|
||||
labelKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_LABEL',
|
||||
subKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_SUB',
|
||||
},
|
||||
{
|
||||
value: 'stalker',
|
||||
icon: 'cast',
|
||||
labelKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_LABEL',
|
||||
subKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_SUB',
|
||||
},
|
||||
{
|
||||
value: 'text',
|
||||
icon: 'subject',
|
||||
labelKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_LABEL',
|
||||
subKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_SUB',
|
||||
},
|
||||
];
|
||||
|
||||
readonly subtypeOptions: SubtypeOption[] = [
|
||||
{ value: 'url', labelKey: 'HOME.TABS.URL_UPLOAD' },
|
||||
{ value: 'file', labelKey: 'HOME.TABS.FILE_UPLOAD' },
|
||||
{ value: 'text', labelKey: 'HOME.TABS.TEXT_IMPORT' },
|
||||
];
|
||||
|
||||
readonly playlistType = computed<PlaylistType>(() => {
|
||||
const cat = this.category();
|
||||
if (cat === 'xtream') return 'xtream';
|
||||
if (cat === 'stalker') return 'stalker';
|
||||
return this.m3uSubType();
|
||||
});
|
||||
/**
|
||||
* Backwards-compatible alias. The template's @switch and the action
|
||||
* buttons key off this; keeping the name avoids churn in 5 case branches.
|
||||
*/
|
||||
readonly playlistType = computed<PlaylistType>(() => this.method());
|
||||
|
||||
constructor() {
|
||||
if (this.data?.type) {
|
||||
this.initFromType(this.data.type);
|
||||
}
|
||||
}
|
||||
|
||||
private initFromType(type: PlaylistType): void {
|
||||
if (type === 'xtream') {
|
||||
this.category.set('xtream');
|
||||
} else if (type === 'stalker') {
|
||||
this.category.set('stalker');
|
||||
} else {
|
||||
this.category.set('m3u');
|
||||
this.m3uSubType.set(type as M3uSubType);
|
||||
this.method.set(this.data.type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -110,14 +110,14 @@
|
||||
flex-wrap: wrap;
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
background: var(--app-selection-color);
|
||||
color: var(--app-selection-on-color, white);
|
||||
font-weight: 500;
|
||||
padding: 10px 24px;
|
||||
border-radius: 10px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.25);
|
||||
box-shadow: 0 4px 14px var(--app-selection-glow);
|
||||
transition: all 0.25s ease;
|
||||
|
||||
mat-icon {
|
||||
@@ -126,12 +126,12 @@
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
|
||||
box-shadow: 0 6px 18px var(--app-selection-border);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
color: #60a5fa;
|
||||
color: var(--app-selection-color);
|
||||
font-weight: 500;
|
||||
padding: 10px 18px;
|
||||
text-transform: none;
|
||||
|
||||
+2
-2
@@ -168,7 +168,7 @@
|
||||
@if (!playlist.serverUrl && !playlist.portalUrl) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="exportPlaylist()"
|
||||
>
|
||||
@@ -183,7 +183,7 @@
|
||||
<button
|
||||
mat-flat-button
|
||||
mat-dialog-close
|
||||
color="accent"
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
|
||||
>
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
@case ('failed') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
retry(item)
|
||||
@@ -372,7 +372,7 @@
|
||||
@case ('canceled') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
retry(item)
|
||||
|
||||
@@ -17,6 +17,10 @@ interface PlaylistRecentLabels {
|
||||
m3u: string;
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
|
||||
return values.map((value) => value?.trim()).find(Boolean);
|
||||
}
|
||||
|
||||
function getPlaylistRecentItems(
|
||||
playlist: PlaylistMeta
|
||||
): PlaylistRecentlyViewedItem[] {
|
||||
@@ -76,9 +80,11 @@ function mapM3uPlaylistRecentItems(
|
||||
acc.push({
|
||||
id: rawItem.id || channelUrl,
|
||||
title:
|
||||
rawItem.title?.trim() ||
|
||||
rawItem.tvg_name?.trim() ||
|
||||
rawItem.channel_id ||
|
||||
firstNonEmpty(
|
||||
rawItem.title,
|
||||
rawItem.tvg_name,
|
||||
rawItem.channel_id
|
||||
) ||
|
||||
channelUrl,
|
||||
type: 'live',
|
||||
playlist_id: playlist._id,
|
||||
@@ -87,6 +93,12 @@ function mapM3uPlaylistRecentItems(
|
||||
category_id: rawItem.category_id || 'live',
|
||||
xtream_id: channelUrl,
|
||||
poster_url: rawItem.poster_url || undefined,
|
||||
epg_lookup_key: firstNonEmpty(
|
||||
rawItem.tvg_id,
|
||||
rawItem.tvg_name,
|
||||
rawItem.title,
|
||||
rawItem.channel_id
|
||||
),
|
||||
source: 'm3u',
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface PortalActivityItem {
|
||||
category_id: string | number;
|
||||
xtream_id: string | number;
|
||||
poster_url?: string;
|
||||
/**
|
||||
* Preferred XMLTV lookup key for live EPG enrichment. For M3U channels this
|
||||
* follows the app-wide tvg.id -> tvg.name -> display name fallback chain.
|
||||
*/
|
||||
epg_lookup_key?: string;
|
||||
/**
|
||||
* Wide backdrop URL (typically 16:9), persisted when the user first views
|
||||
* the detail page. Absent for stalker and for items that have never had
|
||||
|
||||
@@ -22,10 +22,10 @@ export interface ConfirmDialogData {
|
||||
{{ dialogData.message }}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>
|
||||
{{ dialogData.cancelLabel || 'NO' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button [mat-dialog-close]="true" color="accent">
|
||||
<button mat-flat-button [mat-dialog-close]="true" color="primary">
|
||||
{{ dialogData.confirmLabel || 'YES' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -23,10 +23,22 @@
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="zoomOut()" [disabled]="hourWidth() <= 50" matTooltip="Zoom out">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="zoomOut()"
|
||||
[disabled]="hourWidth() <= 50"
|
||||
[matTooltip]="'EPG.ZOOM_OUT' | translate"
|
||||
[attr.aria-label]="'EPG.ZOOM_OUT' | translate"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="zoomIn()" [disabled]="hourWidth() >= 800" matTooltip="Zoom in">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="zoomIn()"
|
||||
[disabled]="hourWidth() >= 800"
|
||||
[matTooltip]="'EPG.ZOOM_IN' | translate"
|
||||
[attr.aria-label]="'EPG.ZOOM_IN' | translate"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
|
||||
@@ -53,7 +65,14 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleSearch()"
|
||||
[matTooltip]="isSearchExpanded() ? 'Close filter' : 'Filter channels'"
|
||||
[matTooltip]="
|
||||
(isSearchExpanded() ? 'EPG.CLOSE_FILTER' : 'EPG.FILTER_CHANNELS')
|
||||
| translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
(isSearchExpanded() ? 'EPG.CLOSE_FILTER' : 'EPG.FILTER_CHANNELS')
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ isSearchExpanded() ? 'filter_list_off' : 'filter_list' }}</mat-icon>
|
||||
</button>
|
||||
@@ -113,7 +132,18 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleProgramSearch()"
|
||||
[matTooltip]="isProgramSearchOpen() ? 'Close program search' : 'Search programs'"
|
||||
[matTooltip]="
|
||||
(isProgramSearchOpen()
|
||||
? 'EPG.CLOSE_PROGRAM_SEARCH'
|
||||
: 'EPG.SEARCH_PROGRAMS'
|
||||
) | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
(isProgramSearchOpen()
|
||||
? 'EPG.CLOSE_PROGRAM_SEARCH'
|
||||
: 'EPG.SEARCH_PROGRAMS'
|
||||
) | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ isProgramSearchOpen() ? 'close' : 'manage_search' }}</mat-icon>
|
||||
</button>
|
||||
@@ -123,16 +153,16 @@
|
||||
<svg id="channels-column" [attr.height]="filteredChannels().length * barHeight + barHeight + 50">
|
||||
@for (item of filteredChannels(); track item.id; let i = $index) {
|
||||
<g>
|
||||
<!-- channel name -->
|
||||
<!-- channel name (column widened to 180px per UX audit) -->
|
||||
<g class="channel" [class.active]="item.id === activeChannelId">
|
||||
<rect
|
||||
width="100"
|
||||
width="180"
|
||||
[attr.height]="barHeight"
|
||||
[attr.y]="barHeight * i + barHeight"
|
||||
stroke="black"
|
||||
/>
|
||||
<foreignObject
|
||||
width="100"
|
||||
width="180"
|
||||
[attr.height]="barHeight"
|
||||
[attr.y]="barHeight * i + barHeight"
|
||||
>
|
||||
@@ -141,7 +171,6 @@
|
||||
<img
|
||||
[src]="getChannelIcon(item)"
|
||||
[alt]="getChannelName(item)"
|
||||
style="height: 20px; width: 20px; margin-right: 8px;"
|
||||
>
|
||||
}
|
||||
<span>{{ getChannelName(item) }}</span>
|
||||
@@ -191,6 +220,7 @@
|
||||
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
|
||||
class="program"
|
||||
[class.highlighted]="highlightedProgramKey() === getProgramKey(program)"
|
||||
[class.is-now]="isProgramAiringNow(program)"
|
||||
(click)="showDescription(program)"
|
||||
>
|
||||
<rect
|
||||
@@ -209,7 +239,10 @@
|
||||
</g>
|
||||
}
|
||||
|
||||
<!-- current time line -->
|
||||
<!-- current time line + label badge.
|
||||
The badge sits on top of the time-header row so users can read the
|
||||
current time at a glance — addresses the UX-audit complaint that
|
||||
the now-line was a "decorative gradient" without a time anchor. -->
|
||||
<line
|
||||
[attr.x1]="currentTimeLine()"
|
||||
y1="0"
|
||||
@@ -217,7 +250,19 @@
|
||||
[attr.y2]="filteredChannels().length * barHeight + barHeight"
|
||||
stroke="red"
|
||||
stroke-width="2"
|
||||
id="current-time-line"
|
||||
></line>
|
||||
<g class="now-label" [attr.transform]="'translate(' + currentTimeLine() + ', ' + (barHeight - 14) + ')'">
|
||||
<rect
|
||||
x="-26"
|
||||
y="-9"
|
||||
width="52"
|
||||
height="18"
|
||||
rx="9"
|
||||
ry="9"
|
||||
></rect>
|
||||
<text x="0" y="4" text-anchor="middle">{{ currentTimeLabel() }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,46 @@
|
||||
// Color palette - Broadcast control room aesthetic
|
||||
$bg-deep: #0a0a0f;
|
||||
$bg-surface: #12121a;
|
||||
$bg-elevated: #1a1a24;
|
||||
$bg-hover: #24243a;
|
||||
$border-subtle: rgba(255, 255, 255, 0.06);
|
||||
$border-medium: rgba(255, 255, 255, 0.12);
|
||||
// Color palette — every value resolves through the app's theme tokens so
|
||||
// Multi-EPG looks like part of IPTVnator instead of a separate broadcast
|
||||
// utility. Previously hard-coded #0a0a0f / #12121a / #1a1a24 made the grid
|
||||
// switch to near-black while the rest of the app sat on --app-content-bg,
|
||||
// which the UX audit called out as the screen "visually detaching" from
|
||||
// the rest of the app.
|
||||
$bg-deep: var(--app-content-bg);
|
||||
$bg-surface: var(--app-widget-bg);
|
||||
$bg-elevated: var(--app-widget-header-bg);
|
||||
$bg-hover: var(--app-card-hover-bg);
|
||||
$border-subtle: var(--app-widget-header-border);
|
||||
$border-medium: var(--app-separator);
|
||||
|
||||
// Accent colors - TV broadcast vibes
|
||||
$accent-primary: #00d4aa; // Cyan/teal for time
|
||||
$accent-secondary: #8b5cf6; // Purple for programs
|
||||
$accent-tertiary: #f472b6; // Pink for highlights
|
||||
// Accent colors — unified with the rest of the app:
|
||||
// cyan stays on the "now" indicator only; blue is the primary; the old
|
||||
// $8b5cf6 purple and $00d4aa teal are dropped per the UX audit.
|
||||
$accent-primary: #5cd6ff; // Cyan — reserved for "now" indicator
|
||||
$accent-now-glow: rgba(92, 214, 255, 0.45); // Cyan glow for the now-line
|
||||
$accent-secondary: var(--app-selection-color); // Blue — programs
|
||||
$accent-tertiary: var(--app-selection-color); // Blue — highlights
|
||||
$accent-warning: #fbbf24;
|
||||
|
||||
// Text colors
|
||||
$text-primary: #f8fafc;
|
||||
$text-secondary: rgba(248, 250, 252, 0.7);
|
||||
$text-muted: rgba(248, 250, 252, 0.4);
|
||||
// Text colors — derived from app heading/body so light and dark themes
|
||||
// stay in sync without a separate `.light-theme` override at the bottom
|
||||
// (which was duplicating responsibility with the same token chain).
|
||||
$text-primary: var(--app-heading-color);
|
||||
$text-secondary: var(--app-body-color);
|
||||
$text-muted: var(--app-muted-color);
|
||||
|
||||
// Gradients
|
||||
$gradient-program: linear-gradient(
|
||||
135deg,
|
||||
rgba(139, 92, 246, 0.15) 0%,
|
||||
rgba(139, 92, 246, 0.05) 100%
|
||||
rgba(79, 142, 255, 0.15) 0%,
|
||||
rgba(79, 142, 255, 0.05) 100%
|
||||
);
|
||||
$gradient-program-hover: linear-gradient(
|
||||
135deg,
|
||||
rgba(139, 92, 246, 0.3) 0%,
|
||||
rgba(139, 92, 246, 0.15) 100%
|
||||
rgba(79, 142, 255, 0.3) 0%,
|
||||
rgba(79, 142, 255, 0.15) 100%
|
||||
);
|
||||
$gradient-header: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 212, 170, 0.08) 0%,
|
||||
rgba(92, 214, 255, 0.08) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
@@ -54,11 +64,11 @@ $gradient-header: linear-gradient(
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 92, 246, 0.4);
|
||||
background: rgba(47, 123, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(139, 92, 246, 0.6);
|
||||
background: rgba(47, 123, 255, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,8 +97,8 @@ $gradient-header: linear-gradient(
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(0, 212, 170, 0.3) 20%,
|
||||
rgba(139, 92, 246, 0.3) 80%,
|
||||
rgba(92, 214, 255, 0.3) 20%,
|
||||
rgba(47, 123, 255, 0.3) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
@@ -102,7 +112,7 @@ $gradient-header: linear-gradient(
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:not([disabled]):hover {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
background: rgba(47, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
@@ -136,7 +146,7 @@ $gradient-header: linear-gradient(
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
border-color: rgba(47, 123, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
@@ -239,7 +249,7 @@ $gradient-header: linear-gradient(
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
border-color: rgba(47, 123, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
@@ -326,7 +336,7 @@ $gradient-header: linear-gradient(
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
background: rgba(47, 123, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -444,11 +454,11 @@ $gradient-header: linear-gradient(
|
||||
|
||||
@keyframes highlightPulse {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6));
|
||||
filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 20px rgba(0, 212, 170, 0.9))
|
||||
drop-shadow(0 0 40px rgba(0, 212, 170, 0.4));
|
||||
filter: drop-shadow(0 0 20px rgba(92, 214, 255, 0.9))
|
||||
drop-shadow(0 0 40px rgba(92, 214, 255, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,12 +506,14 @@ $gradient-header: linear-gradient(
|
||||
text-transform: uppercase;
|
||||
|
||||
// Glowing text effect
|
||||
text-shadow: 0 0 20px rgba(0, 212, 170, 0.3);
|
||||
text-shadow: 0 0 20px rgba(92, 214, 255, 0.3);
|
||||
}
|
||||
|
||||
// Channel column
|
||||
// Channel column — bumped from 140px to 180px per the UX audit so long
|
||||
// channel names ("13th Street Universal HD", "ARD alpha", etc.) stop being
|
||||
// truncated to 5 characters.
|
||||
#channels-column {
|
||||
width: 140px;
|
||||
width: 180px;
|
||||
flex: none;
|
||||
border-right: 1px solid $border-medium;
|
||||
background: $bg-surface;
|
||||
@@ -527,8 +539,8 @@ $gradient-header: linear-gradient(
|
||||
}
|
||||
|
||||
&.active rect {
|
||||
fill: rgba(139, 92, 246, 0.2);
|
||||
stroke: rgba(139, 92, 246, 0.4);
|
||||
fill: rgba(47, 123, 255, 0.2);
|
||||
stroke: rgba(47, 123, 255, 0.4);
|
||||
}
|
||||
|
||||
&.active .channel-name {
|
||||
@@ -540,25 +552,40 @@ $gradient-header: linear-gradient(
|
||||
.channel-name {
|
||||
color: $text-primary;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
|
||||
img {
|
||||
// Smaller logo (24px) per audit recommendation so the channel
|
||||
// name has room to breathe in the 180px column.
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
margin-right: 0 !important;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
span {
|
||||
// Allow up to two lines so "13th Street Universal HD" and
|
||||
// similar long names wrap instead of disappearing into an
|
||||
// ellipsis after 5 characters.
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +597,7 @@ $gradient-header: linear-gradient(
|
||||
|
||||
// EPG container
|
||||
#epg-container {
|
||||
width: calc(100vw - 140px);
|
||||
width: calc(100vw - 180px);
|
||||
overflow-x: scroll;
|
||||
overflow-y: clip;
|
||||
background: $bg-deep;
|
||||
@@ -586,11 +613,11 @@ $gradient-header: linear-gradient(
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 92, 246, 0.4);
|
||||
background: rgba(47, 123, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(139, 92, 246, 0.6);
|
||||
background: rgba(47, 123, 255, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -631,13 +658,13 @@ $gradient-header: linear-gradient(
|
||||
|
||||
&:hover rect {
|
||||
fill: $bg-hover;
|
||||
stroke: rgba(139, 92, 246, 0.4);
|
||||
filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.2));
|
||||
stroke: rgba(47, 123, 255, 0.4);
|
||||
filter: drop-shadow(0 0 8px rgba(47, 123, 255, 0.2));
|
||||
}
|
||||
|
||||
// Highlighted program (from search)
|
||||
&.highlighted rect {
|
||||
fill: rgba(0, 212, 170, 0.25);
|
||||
fill: rgba(92, 214, 255, 0.25);
|
||||
stroke: $accent-primary;
|
||||
stroke-width: 2px;
|
||||
animation: highlightPulse 1s ease-in-out 3;
|
||||
@@ -651,12 +678,12 @@ $gradient-header: linear-gradient(
|
||||
|
||||
// Active channel row highlighting
|
||||
.active-row .program rect {
|
||||
fill: rgba(139, 92, 246, 0.15);
|
||||
stroke: rgba(139, 92, 246, 0.3);
|
||||
fill: rgba(47, 123, 255, 0.15);
|
||||
stroke: rgba(47, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.active-row .program:hover rect {
|
||||
fill: rgba(139, 92, 246, 0.25);
|
||||
fill: rgba(47, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.program-title {
|
||||
@@ -681,12 +708,45 @@ $gradient-header: linear-gradient(
|
||||
line[stroke='red'] {
|
||||
stroke: $accent-primary !important;
|
||||
stroke-width: 2px;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6))
|
||||
drop-shadow(0 0 16px rgba(0, 212, 170, 0.3));
|
||||
filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6))
|
||||
drop-shadow(0 0 16px rgba(92, 214, 255, 0.3));
|
||||
|
||||
// Animated pulse
|
||||
animation: timePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Now-line time badge — a small filled pill sitting on top of the time
|
||||
// header that reads "HH:MM". Keeps the cyan visual language of the line
|
||||
// itself so the relationship is unmistakable.
|
||||
.now-label {
|
||||
pointer-events: none;
|
||||
|
||||
rect {
|
||||
fill: $accent-primary;
|
||||
stroke: none;
|
||||
filter: drop-shadow(0 0 6px $accent-now-glow);
|
||||
}
|
||||
|
||||
text {
|
||||
fill: var(--app-shell-bg, #0d0f12);
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo,
|
||||
monospace;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
// Currently-airing program — a thin cyan border on every program block
|
||||
// whose time window contains the now-line. Audit recommended this to
|
||||
// make "what's airing right now" scannable without finding the line
|
||||
// first then tracing it down each row.
|
||||
.program.is-now {
|
||||
rect {
|
||||
stroke: $accent-primary;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pulse animation for time line
|
||||
@@ -694,13 +754,13 @@ $gradient-header: linear-gradient(
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6))
|
||||
drop-shadow(0 0 16px rgba(0, 212, 170, 0.3));
|
||||
filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6))
|
||||
drop-shadow(0 0 16px rgba(92, 214, 255, 0.3));
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
filter: drop-shadow(0 0 12px rgba(0, 212, 170, 0.8))
|
||||
drop-shadow(0 0 24px rgba(0, 212, 170, 0.4));
|
||||
filter: drop-shadow(0 0 12px rgba(92, 214, 255, 0.8))
|
||||
drop-shadow(0 0 24px rgba(92, 214, 255, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,7 +868,7 @@ rect {
|
||||
|
||||
&:hover rect {
|
||||
fill: #f1f5f9;
|
||||
stroke: rgba(139, 92, 246, 0.4);
|
||||
stroke: rgba(47, 123, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -817,8 +877,8 @@ rect {
|
||||
}
|
||||
|
||||
line[stroke='red'] {
|
||||
stroke: #059669 !important;
|
||||
filter: drop-shadow(0 0 6px rgba(5, 150, 105, 0.4));
|
||||
stroke: $accent-primary !important;
|
||||
filter: drop-shadow(0 0 6px $accent-now-glow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -831,16 +891,22 @@ rect {
|
||||
}
|
||||
|
||||
#channels-column {
|
||||
width: 100px;
|
||||
width: 140px;
|
||||
|
||||
.channel-name {
|
||||
font-size: 10px;
|
||||
font-size: 11.5px;
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
|
||||
img {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#epg-container {
|
||||
width: calc(100vw - 100px);
|
||||
width: calc(100vw - 140px);
|
||||
}
|
||||
|
||||
.today-date {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isSelectedEpgDayToday } from './multi-epg-container.component';
|
||||
|
||||
describe('isSelectedEpgDayToday', () => {
|
||||
it('returns true only when the selected EPG day is the actual current day', () => {
|
||||
const now = new Date('2026-05-21T20:00:00.000Z');
|
||||
|
||||
expect(isSelectedEpgDayToday('20260521', now)).toBe(true);
|
||||
expect(isSelectedEpgDayToday('20260520', now)).toBe(false);
|
||||
expect(isSelectedEpgDayToday('20260522', now)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,13 @@ interface ProgramSearchResult extends EpgProgram {
|
||||
display_name?: string | null;
|
||||
}
|
||||
|
||||
export function isSelectedEpgDayToday(
|
||||
selectedDay: string,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
return selectedDay === format(now, 'yyyyMMdd');
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
DatePipe,
|
||||
@@ -145,6 +152,19 @@ export class MultiEpgContainerComponent
|
||||
return (now.getHours() + now.getMinutes() / 60) * this.hourWidth();
|
||||
});
|
||||
|
||||
// "16:32"-style label rendered as a badge above the now-line. Recomputes
|
||||
// on the same 60-second tick that drives `currentTimeLine` (both depend
|
||||
// on hourWidth's update pulse from `ngOnInit`).
|
||||
readonly currentTimeLabel = computed(() => {
|
||||
// Subscribe to the same tick source as currentTimeLine.
|
||||
this.hourWidth();
|
||||
const now = new Date();
|
||||
return `${now.getHours().toString().padStart(2, '0')}:${now
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
// Constants
|
||||
readonly timeHeader = Array.from({ length: 24 }, (_, i) => i);
|
||||
readonly barHeight = 50;
|
||||
@@ -228,6 +248,24 @@ export class MultiEpgContainerComponent
|
||||
return `${program.start}|${program?.title?.toString() ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the now-line falls within this program's rendered
|
||||
* span — used to add a `.is-now` class so the cyan border lights up
|
||||
* every airing program at once. The template reads `currentTimeLine()`
|
||||
* (a signal) here so the highlight refreshes on the same 60-second tick
|
||||
* as the line itself.
|
||||
*/
|
||||
isProgramAiringNow(program: EnrichedProgram): boolean {
|
||||
const nowX = this.currentTimeLine();
|
||||
if (!isSelectedEpgDayToday(this.today())) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
nowX >= program.startPosition &&
|
||||
nowX <= program.startPosition + program.width
|
||||
);
|
||||
}
|
||||
|
||||
async requestPrograms(): Promise<void> {
|
||||
if (!window.electron) {
|
||||
console.warn('Multi-EPG not available: Electron not detected');
|
||||
|
||||
@@ -155,10 +155,13 @@
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container) 60%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
|
||||
// LIVE stays in the app's red "broadcasting now" role across every
|
||||
// surface (channel rows, EPG now-bar, badges). The station's extracted
|
||||
// dominant color is only used as a soft aura on the artwork.
|
||||
&.live {
|
||||
color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
|
||||
border-color: color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 52%, transparent);
|
||||
background: color-mix(in srgb, var(--radio-accent, var(--app-selection-color)) 14%, transparent);
|
||||
color: var(--app-live-color, #ff8f8f);
|
||||
border-color: color-mix(in srgb, var(--app-live-color, #ff8f8f) 52%, transparent);
|
||||
background: color-mix(in srgb, var(--app-live-color, #ff8f8f) 14%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,9 +202,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Play / Pause uses the unified app primary, not the station's dominant color
|
||||
// — same blue as every other CTA so radio doesn't feel like a different app.
|
||||
.play-btn {
|
||||
--mdc-fab-container-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
|
||||
--mat-fab-container-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
|
||||
--mdc-fab-container-color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
--mat-fab-container-color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
--mdc-fab-icon-color: #fff;
|
||||
--mat-fab-foreground-color: #fff;
|
||||
--mat-fab-icon-color: #fff;
|
||||
@@ -238,10 +243,10 @@
|
||||
|
||||
.vol-slider {
|
||||
flex: 1;
|
||||
--mdc-slider-active-track-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
|
||||
--mdc-slider-handle-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
|
||||
--mat-slider-active-track-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
|
||||
--mat-slider-handle-color: var(--radio-accent, var(--app-selection-color, var(--mat-sys-primary)));
|
||||
--mdc-slider-active-track-color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
--mdc-slider-handle-color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
--mat-slider-active-track-color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
--mat-slider-handle-color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
--mdc-slider-inactive-track-color: var(--mat-sys-outline-variant);
|
||||
--mdc-slider-handle-width: 12px;
|
||||
--mdc-slider-handle-height: 12px;
|
||||
|
||||
@@ -16,7 +16,9 @@ import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSliderModule } from '@angular/material/slider';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
|
||||
@Component({
|
||||
@@ -67,6 +69,12 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
<button
|
||||
mat-icon-button
|
||||
class="skip-btn"
|
||||
[matTooltip]="
|
||||
'AUDIO_PLAYER.PREVIOUS_STATION' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'AUDIO_PLAYER.PREVIOUS_STATION' | translate
|
||||
"
|
||||
(click)="switchChannel('previous')"
|
||||
>
|
||||
<mat-icon>skip_previous</mat-icon>
|
||||
@@ -75,6 +83,18 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
<button
|
||||
class="play-btn"
|
||||
mat-fab
|
||||
[matTooltip]="
|
||||
(playState() === 'play'
|
||||
? 'AUDIO_PLAYER.PAUSE'
|
||||
: 'AUDIO_PLAYER.PLAY'
|
||||
) | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
(playState() === 'play'
|
||||
? 'AUDIO_PLAYER.PAUSE'
|
||||
: 'AUDIO_PLAYER.PLAY'
|
||||
) | translate
|
||||
"
|
||||
(click)="playState() === 'play' ? stop() : play()"
|
||||
>
|
||||
<mat-icon>{{
|
||||
@@ -85,6 +105,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
<button
|
||||
mat-icon-button
|
||||
class="skip-btn"
|
||||
[matTooltip]="'AUDIO_PLAYER.NEXT_STATION' | translate"
|
||||
[attr.aria-label]="
|
||||
'AUDIO_PLAYER.NEXT_STATION' | translate
|
||||
"
|
||||
(click)="switchChannel('next')"
|
||||
>
|
||||
<mat-icon>skip_next</mat-icon>
|
||||
@@ -92,13 +116,38 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
</div>
|
||||
|
||||
<div class="volume-row">
|
||||
<button mat-icon-button class="vol-icon" (click)="mute()">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="vol-icon"
|
||||
[matTooltip]="
|
||||
(isMuted() || volume() === 0
|
||||
? 'AUDIO_PLAYER.UNMUTE'
|
||||
: 'AUDIO_PLAYER.MUTE'
|
||||
) | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
(isMuted() || volume() === 0
|
||||
? 'AUDIO_PLAYER.UNMUTE'
|
||||
: 'AUDIO_PLAYER.MUTE'
|
||||
) | translate
|
||||
"
|
||||
(click)="mute()"
|
||||
>
|
||||
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
||||
</button>
|
||||
<mat-slider class="vol-slider" min="0" max="1" step="0.05">
|
||||
<mat-slider
|
||||
class="vol-slider"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
[matTooltip]="'AUDIO_PLAYER.VOLUME' | translate"
|
||||
>
|
||||
<input
|
||||
matSliderThumb
|
||||
[ngModel]="volume()"
|
||||
[attr.aria-label]="
|
||||
'AUDIO_PLAYER.VOLUME' | translate
|
||||
"
|
||||
(ngModelChange)="setVolume($event)"
|
||||
/>
|
||||
</mat-slider>
|
||||
@@ -109,7 +158,14 @@ import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./audio-player.component.scss'],
|
||||
imports: [MatSliderModule, MatIconModule, MatButtonModule, FormsModule],
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatSliderModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class AudioPlayerComponent {
|
||||
readonly icon = input<string>('');
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
>Don't show anymore</mat-checkbox
|
||||
>
|
||||
</div>
|
||||
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary detail-view actions (Favorite, Download). Per UX audit: only Play
|
||||
// should read as the primary CTA, so these stay ghost/neutral. State accents
|
||||
// (favorite=red destructive, download=green completed) keep their semantic
|
||||
// meaning but ride on transparent fills so they never out-shout Play.
|
||||
@mixin secondary-action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -37,15 +41,16 @@
|
||||
padding: 0 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: rgba(109, 109, 110, 0.7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary, #fff);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
@@ -57,36 +62,37 @@
|
||||
|
||||
@mixin favorite-button-state {
|
||||
&--active {
|
||||
background: rgba(229, 9, 20, 0.8);
|
||||
background: rgba(255, 91, 110, 0.16);
|
||||
border-color: rgba(255, 91, 110, 0.4);
|
||||
color: #ff8f8f;
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 9, 20, 0.6);
|
||||
background: rgba(255, 91, 110, 0.24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin download-button-state {
|
||||
background: rgba(33, 150, 243, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(33, 150, 243, 0.6);
|
||||
}
|
||||
// Idle download stays neutral — extends secondary-action-button.
|
||||
|
||||
&--completed {
|
||||
background: rgba(76, 175, 80, 0.8);
|
||||
background: rgba(74, 222, 128, 0.16);
|
||||
border-color: rgba(74, 222, 128, 0.4);
|
||||
color: #4ade80;
|
||||
|
||||
&:hover {
|
||||
background: rgba(76, 175, 80, 0.6);
|
||||
background: rgba(74, 222, 128, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
&--downloading {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,21 @@
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--accent-color: #b81d24;
|
||||
// Single primary CTA color — matches --app-selection-color so the
|
||||
// Play button reads as the same blue used everywhere else for
|
||||
// "active / call to action".
|
||||
--accent-color: #2f7bff;
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--play-idle-bg: linear-gradient(
|
||||
135deg,
|
||||
#fffaf2 0%,
|
||||
#ffffff 52%,
|
||||
#f7ecdf 100%
|
||||
#2f7bff 0%,
|
||||
#3b86ff 52%,
|
||||
#4a90ff 100%
|
||||
);
|
||||
--play-idle-fg: #161310;
|
||||
--play-idle-border: rgba(88, 48, 18, 0.12);
|
||||
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
|
||||
--play-idle-fg: #ffffff;
|
||||
--play-idle-border: rgba(47, 123, 255, 0.4);
|
||||
--play-idle-shadow: 0 18px 38px rgba(47, 123, 255, 0.32);
|
||||
--play-launching-bg: linear-gradient(
|
||||
135deg,
|
||||
#ffd36f 0%,
|
||||
@@ -74,19 +77,19 @@
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--accent-color: #e50914;
|
||||
--accent-color: #78adff;
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
--play-idle-bg: linear-gradient(
|
||||
135deg,
|
||||
#fff5e6 0%,
|
||||
#f6efe5 58%,
|
||||
#e7dac9 100%
|
||||
#2f7bff 0%,
|
||||
#4f8eff 58%,
|
||||
#78adff 100%
|
||||
);
|
||||
--play-idle-fg: #15110d;
|
||||
--play-idle-border: rgba(255, 255, 255, 0.18);
|
||||
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
|
||||
--play-idle-fg: #ffffff;
|
||||
--play-idle-border: rgba(120, 173, 255, 0.32);
|
||||
--play-idle-shadow: 0 20px 42px rgba(47, 123, 255, 0.32);
|
||||
--play-launching-bg: linear-gradient(
|
||||
135deg,
|
||||
#ffdd79 0%,
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
DashboardDataService,
|
||||
@@ -140,6 +145,16 @@ describe('DashboardDataService', () => {
|
||||
),
|
||||
};
|
||||
|
||||
const playbackPositionsMock = {
|
||||
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
||||
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
getAllPlaybackPositions: jest
|
||||
.fn<Promise<PlaybackPositionData[]>, [string]>()
|
||||
.mockResolvedValue([]),
|
||||
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
@@ -176,6 +191,9 @@ describe('DashboardDataService', () => {
|
||||
xtreamDataSourceMock.removeRecentItem.mockResolvedValue(undefined);
|
||||
storeMock.dispatch.mockClear();
|
||||
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockClear();
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardDataService,
|
||||
@@ -198,6 +216,10 @@ describe('DashboardDataService', () => {
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: playbackPositionsMock,
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
@@ -260,6 +282,7 @@ describe('DashboardDataService', () => {
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo-1.png',
|
||||
epg_lookup_key: 'tvg-1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'https://example.com/stream-2.m3u8',
|
||||
@@ -268,6 +291,7 @@ describe('DashboardDataService', () => {
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo-2.png',
|
||||
epg_lookup_key: 'tvg-2',
|
||||
}),
|
||||
])
|
||||
);
|
||||
@@ -616,11 +640,369 @@ describe('DashboardDataService', () => {
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
xtream_id: 'https://example.com/stream-1.m3u8',
|
||||
epg_lookup_key: 'tvg-1',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('splits global recent items into VOD and live computed signals so the dashboard can render two rails', async () => {
|
||||
// Seed an Xtream movie and an Xtream series alongside the M3U live
|
||||
// channel already present in defaultPlaylists, then verify the
|
||||
// splits land on the right computed signal.
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 401,
|
||||
category_id: 18,
|
||||
title: 'Recent Movie',
|
||||
rating: '7.8',
|
||||
viewed_at: '2026-04-21T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/recent-movie.png',
|
||||
xtream_id: 7001,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
{
|
||||
id: 402,
|
||||
category_id: 19,
|
||||
title: 'Recent Series',
|
||||
rating: '8.1',
|
||||
viewed_at: '2026-04-22T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/recent-series.png',
|
||||
xtream_id: 7002,
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal(),
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
|
||||
const vodTitles = service
|
||||
.globalRecentVodItems()
|
||||
.map((item) => item.title);
|
||||
const liveTitles = service
|
||||
.globalRecentLiveItems()
|
||||
.map((item) => item.title);
|
||||
|
||||
expect(vodTitles).toEqual(
|
||||
expect.arrayContaining(['Recent Movie', 'Recent Series'])
|
||||
);
|
||||
expect(vodTitles).not.toContain('Channel One');
|
||||
|
||||
expect(liveTitles).toContain('Channel One');
|
||||
expect(liveTitles).not.toEqual(
|
||||
expect.arrayContaining(['Recent Movie', 'Recent Series'])
|
||||
);
|
||||
|
||||
// Splits stay mutually exclusive — sum of partitions equals the whole.
|
||||
expect(
|
||||
service.globalRecentVodItems().length +
|
||||
service.globalRecentLiveItems().length
|
||||
).toBe(service.globalRecentItems().length);
|
||||
});
|
||||
|
||||
it('loads playback positions for every playlist that owns VOD/series recent items and exposes them by content key', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 101,
|
||||
category_id: 18,
|
||||
title: 'Atlantic City',
|
||||
rating: '7.3',
|
||||
viewed_at: '2026-04-21T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/atlantic.png',
|
||||
xtream_id: 4242,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-A',
|
||||
playlist_name: 'Xtream A',
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
category_id: 19,
|
||||
title: 'Color Orchard S02E04',
|
||||
rating: '8.1',
|
||||
viewed_at: '2026-04-22T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/orchard.png',
|
||||
xtream_id: 909,
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-B',
|
||||
playlist_name: 'Xtream B',
|
||||
},
|
||||
]);
|
||||
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal(),
|
||||
{
|
||||
_id: 'xtream-A',
|
||||
title: 'Xtream A',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://a.example.com',
|
||||
},
|
||||
{
|
||||
_id: 'xtream-B',
|
||||
title: 'Xtream B',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://b.example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
||||
(playlistId: string) => {
|
||||
if (playlistId === 'xtream-A') {
|
||||
return Promise.resolve([
|
||||
{
|
||||
contentXtreamId: 4242,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 3600,
|
||||
durationSeconds: 6000,
|
||||
playlistId,
|
||||
} as PlaybackPositionData,
|
||||
]);
|
||||
}
|
||||
if (playlistId === 'xtream-B') {
|
||||
return Promise.resolve([
|
||||
{
|
||||
contentXtreamId: 909,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 900,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 4,
|
||||
positionSeconds: 720,
|
||||
durationSeconds: 1800,
|
||||
playlistId,
|
||||
} as PlaybackPositionData,
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
await service.reloadPlaybackPositions();
|
||||
|
||||
// Bulk fetched once per playlist that owns a tracked item — and ONLY
|
||||
// for the playlists that own VOD/series recent items. The default M3U
|
||||
// playlist (live channel) must not be queried.
|
||||
const queriedPlaylists =
|
||||
playbackPositionsMock.getAllPlaybackPositions.mock.calls.map(
|
||||
(call) => call[0]
|
||||
);
|
||||
expect(new Set(queriedPlaylists)).toEqual(
|
||||
new Set(['xtream-A', 'xtream-B'])
|
||||
);
|
||||
|
||||
const vodItems = service.globalRecentVodItems();
|
||||
const movie = vodItems.find((item) => item.title === 'Atlantic City');
|
||||
const series = vodItems.find(
|
||||
(item) => item.title === 'Color Orchard S02E04'
|
||||
);
|
||||
|
||||
// Movie: vod position with 60% watched.
|
||||
const moviePos = service.getPlaybackPositionForItem(movie!);
|
||||
expect(moviePos?.positionSeconds).toBe(3600);
|
||||
expect(moviePos?.contentType).toBe('vod');
|
||||
|
||||
// Series: episode position keyed by the recent item's xtream_id (the
|
||||
// episode id, not the series id).
|
||||
const seriesPos = service.getPlaybackPositionForItem(series!);
|
||||
expect(seriesPos?.contentType).toBe('episode');
|
||||
expect(seriesPos?.positionSeconds).toBe(720);
|
||||
// Season/episode metadata travels with the position so cards can
|
||||
// render an "S2 · E5" badge without an extra round-trip.
|
||||
expect(seriesPos?.seasonNumber).toBe(2);
|
||||
expect(seriesPos?.episodeNumber).toBe(4);
|
||||
});
|
||||
|
||||
it('resolves the latest episode position for series whose recent_items row carries the series id', async () => {
|
||||
// The Xtream series landing page records the recent item with the
|
||||
// SERIES id, while each played episode saves its position keyed by
|
||||
// the EPISODE id (with seriesXtreamId pointing back). The lookup
|
||||
// must follow seriesXtreamId so both card shapes show progress.
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
id: 200,
|
||||
category_id: 30,
|
||||
title: 'Shadow Bay',
|
||||
rating: '7.5',
|
||||
viewed_at: '2026-05-01T09:00:00.000Z',
|
||||
poster_url: 'https://example.com/shadow-bay.png',
|
||||
xtream_id: 4000, // series id
|
||||
type: 'series',
|
||||
playlist_id: 'xtream-C',
|
||||
playlist_name: 'Xtream C',
|
||||
},
|
||||
]);
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal(),
|
||||
{
|
||||
_id: 'xtream-C',
|
||||
title: 'Xtream C',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://c.example.com',
|
||||
},
|
||||
]);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
||||
(playlistId: string) => {
|
||||
if (playlistId === 'xtream-C') {
|
||||
return Promise.resolve([
|
||||
// Earlier episode — should NOT win even if it also
|
||||
// matches the series, because the newer one is more
|
||||
// relevant for "where you left off".
|
||||
{
|
||||
contentXtreamId: 4001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 4000,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 60,
|
||||
durationSeconds: 1800,
|
||||
playlistId,
|
||||
updatedAt: '2026-04-25T12:00:00.000Z',
|
||||
} as PlaybackPositionData,
|
||||
{
|
||||
contentXtreamId: 4007,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 4000,
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 7,
|
||||
positionSeconds: 540,
|
||||
durationSeconds: 1800,
|
||||
playlistId,
|
||||
updatedAt: '2026-05-01T09:30:00.000Z',
|
||||
} as PlaybackPositionData,
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
await service.reloadPlaybackPositions();
|
||||
|
||||
const series = service
|
||||
.globalRecentVodItems()
|
||||
.find((item) => item.title === 'Shadow Bay');
|
||||
const positionsMap = service.playbackPositions$();
|
||||
const failOnLinearScan = jest.fn(() => {
|
||||
throw new Error('series lookup should not scan all positions');
|
||||
});
|
||||
(
|
||||
positionsMap as unknown as {
|
||||
values: () => IterableIterator<PlaybackPositionData>;
|
||||
}
|
||||
).values =
|
||||
failOnLinearScan as unknown as () => IterableIterator<PlaybackPositionData>;
|
||||
|
||||
expect(series).toBeDefined();
|
||||
if (!series) {
|
||||
throw new Error('expected Shadow Bay recent item');
|
||||
}
|
||||
|
||||
const position = service.getPlaybackPositionForItem(series);
|
||||
expect(position?.contentType).toBe('episode');
|
||||
expect(position?.seasonNumber).toBe(3);
|
||||
expect(position?.episodeNumber).toBe(7);
|
||||
expect(failOnLinearScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => {
|
||||
// Seed two favorites: a movie and a live channel.
|
||||
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
|
||||
{
|
||||
id: 500,
|
||||
category_id: 18,
|
||||
title: 'Favorite Movie',
|
||||
rating: '7.0',
|
||||
added_at: '2026-04-21T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/fav-movie.png',
|
||||
xtream_id: 5500,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
{
|
||||
id: 501,
|
||||
category_id: 19,
|
||||
title: 'Favorite Channel HD',
|
||||
rating: '0',
|
||||
added_at: '2026-04-22T10:00:00.000Z',
|
||||
poster_url: 'https://example.com/fav-channel.png',
|
||||
xtream_id: 5501,
|
||||
type: 'live',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
},
|
||||
]);
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal(),
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 2,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalFavorites();
|
||||
|
||||
const liveOnly = service.globalFavoriteLiveItems();
|
||||
// Includes the Xtream "Favorite Channel HD" plus the two M3U live
|
||||
// favorites already seeded by the default fixture.
|
||||
expect(liveOnly.map((it) => it.title)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Favorite Channel HD',
|
||||
'Channel One',
|
||||
'Channel Two',
|
||||
])
|
||||
);
|
||||
|
||||
// Crucially, the movie favorite is excluded — the rail source must
|
||||
// never leak VOD posters into the channel layout.
|
||||
expect(liveOnly.map((it) => it.title)).not.toContain(
|
||||
'Favorite Movie'
|
||||
);
|
||||
expect(
|
||||
service.globalFavoriteLiveItems().every((it) => it.type === 'live')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null playback position for live channels (no position tracking in schema)', async () => {
|
||||
// The default M3U playlist contains a live channel. Without
|
||||
// reloading playback positions the lookup still gracefully returns
|
||||
// null — and `reloadPlaybackPositions` should skip the IPC entirely.
|
||||
const liveItem = service
|
||||
.globalRecentItems()
|
||||
.find((item) => item.type === 'live');
|
||||
expect(liveItem).toBeDefined();
|
||||
expect(service.getPlaybackPositionForItem(liveItem!)).toBeNull();
|
||||
|
||||
await service.reloadPlaybackPositions();
|
||||
expect(
|
||||
playbackPositionsMock.getAllPlaybackPositions
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => {
|
||||
playlistsSignal.set([
|
||||
{
|
||||
|
||||
@@ -51,11 +51,46 @@ import {
|
||||
buildXtreamNavigationTarget,
|
||||
getGlobalFavoriteNavigation,
|
||||
getRecentItemNavigation,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
WorkspaceNavigationTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
|
||||
|
||||
// Compound key for looking up a playback position by recent item — a single
|
||||
// playlist can contain the same xtream-id for a VOD and an episode (rare,
|
||||
// but the schema allows it), so contentType is part of the key.
|
||||
function playbackPositionMapKey(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
): string {
|
||||
return `${playlistId}::${contentXtreamId}::${contentType}`;
|
||||
}
|
||||
|
||||
function seriesPlaybackPositionMapKey(
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
): string {
|
||||
return `${playlistId}::${seriesXtreamId}`;
|
||||
}
|
||||
|
||||
function newestPlaybackPosition(
|
||||
current: PlaybackPositionData | null | undefined,
|
||||
candidate: PlaybackPositionData | null | undefined
|
||||
): PlaybackPositionData | null {
|
||||
if (!current) {
|
||||
return candidate ?? null;
|
||||
}
|
||||
if (!candidate) {
|
||||
return current;
|
||||
}
|
||||
return (candidate.updatedAt ?? '') > (current.updatedAt ?? '')
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link PortalRecentItem} from `@iptvnator/shared/interfaces` instead. */
|
||||
export type GlobalRecentItem = PortalRecentItem;
|
||||
/** @deprecated Use {@link PortalFavoriteItem} from `@iptvnator/shared/interfaces` instead. */
|
||||
@@ -72,6 +107,7 @@ export class DashboardDataService {
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private favoritesAutoRefreshEnabled = false;
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
@@ -201,6 +237,147 @@ export class DashboardDataService {
|
||||
.slice(0, 200)
|
||||
);
|
||||
|
||||
// Split the recent list by content type so the dashboard can render
|
||||
// movies/series ("Continue watching") separately from live channels
|
||||
// ("Live now on your favorites"). Two card formats — one rail each.
|
||||
readonly globalRecentVodItems = computed<GlobalRecentItem[]>(() =>
|
||||
this.globalRecentItems().filter(
|
||||
(item) => item.type === 'movie' || item.type === 'series'
|
||||
)
|
||||
);
|
||||
|
||||
readonly globalRecentLiveItems = computed<GlobalRecentItem[]>(() =>
|
||||
this.globalRecentItems().filter((item) => item.type === 'live')
|
||||
);
|
||||
|
||||
// Favorited live channels — the dashboard's "Live now" rail prefers this
|
||||
// source so the EPG enrichment lands on the channels users actually care
|
||||
// about. Falls back to globalRecentLiveItems when this is empty so a
|
||||
// fresh-install user still sees something useful.
|
||||
readonly globalFavoriteLiveItems = computed<DashboardFavoriteItem[]>(() =>
|
||||
this.globalFavoriteItems().filter((item) => item.type === 'live')
|
||||
);
|
||||
|
||||
// Playback positions, keyed by `${playlist_id}::${contentXtreamId}::${type}`.
|
||||
// Loaded lazily on dashboard mount (and refreshed when recent items
|
||||
// change) so the hero + Continue Watching cards can show "X min left"
|
||||
// and a progress bar. Live channels never have positions; M3U content
|
||||
// doesn't either. Map starts empty and degrades cleanly on missing data.
|
||||
private readonly playbackPositionsMap = signal<
|
||||
Map<string, PlaybackPositionData>
|
||||
>(new Map());
|
||||
private readonly playbackPositionsBySeriesMap = signal<
|
||||
Map<string, PlaybackPositionData>
|
||||
>(new Map());
|
||||
|
||||
readonly playbackPositions$ = this.playbackPositionsMap.asReadonly();
|
||||
|
||||
getPlaybackPositionForItem(
|
||||
item: PortalActivityItem
|
||||
): PlaybackPositionData | null {
|
||||
if (item.type !== 'movie' && item.type !== 'series') {
|
||||
return null;
|
||||
}
|
||||
const xtreamId =
|
||||
typeof item.xtream_id === 'number'
|
||||
? item.xtream_id
|
||||
: Number(item.xtream_id);
|
||||
if (!Number.isFinite(xtreamId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.type === 'movie') {
|
||||
const key = playbackPositionMapKey(
|
||||
item.playlist_id,
|
||||
xtreamId,
|
||||
'vod'
|
||||
);
|
||||
return this.playbackPositionsMap().get(key) ?? null;
|
||||
}
|
||||
|
||||
// Series recent_items rows carry either the series id (the series
|
||||
// landing page writes the series itself) or, for direct-play flows,
|
||||
// the episode id. Match both shapes through keyed maps so card renders
|
||||
// do not scan every saved playback position.
|
||||
const episodePosition =
|
||||
this.playbackPositionsMap().get(
|
||||
playbackPositionMapKey(item.playlist_id, xtreamId, 'episode')
|
||||
) ?? null;
|
||||
const seriesPosition =
|
||||
this.playbackPositionsBySeriesMap().get(
|
||||
seriesPlaybackPositionMapKey(item.playlist_id, xtreamId)
|
||||
) ?? null;
|
||||
return newestPlaybackPosition(episodePosition, seriesPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the in-memory positions map for every playlist that owns at
|
||||
* least one VOD/series recent item. Per-playlist bulk fetch is one IPC
|
||||
* round-trip each (vs N+1 per content item), so this stays cheap even
|
||||
* on heavy libraries.
|
||||
*/
|
||||
async reloadPlaybackPositions(): Promise<void> {
|
||||
const playlistIds = new Set<string>();
|
||||
for (const item of this.globalRecentItems()) {
|
||||
if (item.type === 'movie' || item.type === 'series') {
|
||||
playlistIds.add(item.playlist_id);
|
||||
}
|
||||
}
|
||||
if (playlistIds.size === 0) {
|
||||
this.playbackPositionsMap.set(new Map());
|
||||
this.playbackPositionsBySeriesMap.set(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map<string, PlaybackPositionData>();
|
||||
const nextBySeries = new Map<string, PlaybackPositionData>();
|
||||
for (const playlistId of playlistIds) {
|
||||
try {
|
||||
const positions =
|
||||
await this.playbackPositions.getAllPlaybackPositions(
|
||||
playlistId
|
||||
);
|
||||
for (const position of positions) {
|
||||
next.set(
|
||||
playbackPositionMapKey(
|
||||
playlistId,
|
||||
position.contentXtreamId,
|
||||
position.contentType
|
||||
),
|
||||
position
|
||||
);
|
||||
if (
|
||||
position.contentType === 'episode' &&
|
||||
Number.isFinite(position.seriesXtreamId)
|
||||
) {
|
||||
const seriesKey = seriesPlaybackPositionMapKey(
|
||||
playlistId,
|
||||
position.seriesXtreamId as number
|
||||
);
|
||||
nextBySeries.set(
|
||||
seriesKey,
|
||||
newestPlaybackPosition(
|
||||
nextBySeries.get(seriesKey),
|
||||
position
|
||||
) as PlaybackPositionData
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to load playback positions for playlist',
|
||||
playlistId,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.playbackPositionsMap.set(next);
|
||||
this.playbackPositionsBySeriesMap.set(nextBySeries);
|
||||
});
|
||||
}
|
||||
|
||||
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
|
||||
this.languageTick();
|
||||
return buildStalkerFavoriteItems(
|
||||
@@ -1009,6 +1186,11 @@ export class DashboardDataService {
|
||||
category_id: 'live',
|
||||
xtream_id: matchedFavoriteId,
|
||||
poster_url: channel.tvg?.logo || undefined,
|
||||
epg_lookup_key:
|
||||
channel.tvg?.id?.trim() ||
|
||||
channel.tvg?.name?.trim() ||
|
||||
channel.name?.trim() ||
|
||||
undefined,
|
||||
source: 'm3u',
|
||||
});
|
||||
return acc;
|
||||
|
||||
@@ -14,9 +14,20 @@
|
||||
[routerLink]="link"
|
||||
[attr.data-test-id]="testId() ? testId() + '-manage-all' : null"
|
||||
>
|
||||
<span class="rail__see-all-label">{{
|
||||
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
|
||||
}}</span>
|
||||
<span class="rail__see-all-label">
|
||||
@if ((totalCount() ?? items().length) > items().length) {
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.SEE_ALL_COUNT'
|
||||
| translate
|
||||
: {
|
||||
count:
|
||||
totalCount() ?? items().length,
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
{{ 'WORKSPACE.DASHBOARD.SEE_ALL' | translate }}
|
||||
}
|
||||
</span>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</a>
|
||||
}
|
||||
@@ -34,79 +45,184 @@
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
|
||||
<div #track class="rail__track" (scroll)="onScroll()" role="list">
|
||||
<div
|
||||
#track
|
||||
class="rail__track"
|
||||
[attr.data-layout]="layout()"
|
||||
(scroll)="onScroll()"
|
||||
role="list"
|
||||
>
|
||||
@for (card of items(); track card.id) {
|
||||
<div
|
||||
class="rail__card"
|
||||
role="listitem"
|
||||
[attr.title]="card.title"
|
||||
[attr.data-test-id]="testId() ? testId() + '-card' : null"
|
||||
>
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
@if (layout() === 'channel') {
|
||||
<div
|
||||
class="rail__card rail__card--channel"
|
||||
role="listitem"
|
||||
[attr.title]="card.title"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card' : null
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="rail__art"
|
||||
[class.rail__art--live]="
|
||||
card.contentType === 'live'
|
||||
"
|
||||
[style.aspect-ratio]="aspectRatio()"
|
||||
<a
|
||||
class="rail__channel-link"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
>
|
||||
@if (card.imageUrl && !failedImages()[card.id]) {
|
||||
@if (card.contentType === 'live') {
|
||||
<span
|
||||
class="rail__art-bg"
|
||||
aria-hidden="true"
|
||||
[style.background-image]="
|
||||
'url(' + card.imageUrl + ')'
|
||||
"
|
||||
></span>
|
||||
}
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="rail__art-fallback">
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
<div class="rail__channel-row">
|
||||
<div
|
||||
class="rail__channel-logo"
|
||||
[class.rail__channel-logo--fallback]="
|
||||
!card.imageUrl ||
|
||||
failedImages()[card.id]
|
||||
"
|
||||
>
|
||||
@if (
|
||||
card.imageUrl &&
|
||||
!failedImages()[card.id]
|
||||
) {
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rail__meta">
|
||||
<span class="rail__card-title">{{
|
||||
card.title
|
||||
}}</span>
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (card.actions && card.actions.length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="rail__action-trigger"
|
||||
[matMenuTriggerFor]="cardActionMenu"
|
||||
[matMenuTriggerData]="{ card: card }"
|
||||
[attr.aria-label]="'SHOW_MORE' | translate"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card-actions' : null
|
||||
"
|
||||
(click)="stopActionEvent($event)"
|
||||
<div class="rail__channel-meta">
|
||||
<span class="rail__channel-name">{{
|
||||
card.title
|
||||
}}</span>
|
||||
<span class="rail__channel-now">
|
||||
@if (card.nowPlayingTitle) {
|
||||
{{ card.nowPlayingTitle }}
|
||||
} @else if (card.subtitle) {
|
||||
{{ card.subtitle }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail__channel-progress-row">
|
||||
<span class="rail__channel-live-chip"
|
||||
>LIVE</span
|
||||
>
|
||||
<span
|
||||
class="rail__channel-progress"
|
||||
[class.rail__channel-progress--idle]="
|
||||
card.nowPlayingProgress === null ||
|
||||
card.nowPlayingProgress === undefined
|
||||
"
|
||||
>
|
||||
<i
|
||||
[style.width.%]="
|
||||
card.nowPlayingProgress ?? 0
|
||||
"
|
||||
></i>
|
||||
</span>
|
||||
@if (card.nowPlayingTimeRange) {
|
||||
<span class="rail__channel-time">{{
|
||||
card.nowPlayingTimeRange
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="rail__card"
|
||||
role="listitem"
|
||||
[attr.title]="card.title"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card' : null
|
||||
"
|
||||
>
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="rail__art"
|
||||
[class.rail__art--live]="
|
||||
card.contentType === 'live'
|
||||
"
|
||||
[style.aspect-ratio]="aspectRatio()"
|
||||
>
|
||||
@if (
|
||||
card.imageUrl && !failedImages()[card.id]
|
||||
) {
|
||||
@if (card.contentType === 'live') {
|
||||
<span
|
||||
class="rail__art-bg"
|
||||
aria-hidden="true"
|
||||
[style.background-image]="
|
||||
'url(' + card.imageUrl + ')'
|
||||
"
|
||||
></span>
|
||||
}
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="rail__art-fallback">
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (card.watchProgress !== null && card.watchProgress !== undefined) {
|
||||
<span
|
||||
class="rail__art-progress"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
[style.width.%]="card.watchProgress"
|
||||
></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rail__meta">
|
||||
<span class="rail__card-title">{{
|
||||
card.title
|
||||
}}</span>
|
||||
<span class="rail__card-meta-row">
|
||||
@if (card.episodeBadge) {
|
||||
<span class="rail__card-episode">{{
|
||||
card.episodeBadge
|
||||
}}</span>
|
||||
}
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (card.actions && card.actions.length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="rail__action-trigger"
|
||||
[matMenuTriggerFor]="cardActionMenu"
|
||||
[matMenuTriggerData]="{ card: card }"
|
||||
[attr.aria-label]="'SHOW_MORE' | translate"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card-actions' : null
|
||||
"
|
||||
(click)="stopActionEvent($event)"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Thin "watched-up-to-here" bar pinned to the bottom of the poster art.
|
||||
// Mirrors the Netflix / Plex resume-card pattern: the bar is the user's
|
||||
// quick "did I finish this?" cue without needing to read the metadata
|
||||
// strip below the card.
|
||||
.rail__art-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.rail__art-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -328,7 +350,34 @@
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.rail__card-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail__card-episode {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color, #2f86ff) 14%,
|
||||
transparent
|
||||
);
|
||||
color: var(--app-selection-color, #2f86ff);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rail__card-subtitle {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
@@ -429,3 +478,195 @@
|
||||
--rail-card-width: min(var(--cover-rail-width, 172px), 140px);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel layout (compact horizontal info row) ────────────────────────────
|
||||
// Used by the "Live now on your favorites" rail. Two stacked rows:
|
||||
// 1. logo + channel name + current programme title
|
||||
// 2. LIVE chip + progress bar + time range
|
||||
// Card is wider but much shorter than the 2:3 poster — TV station logos are
|
||||
// small (100–256px square) so a poster card wastes most of its height on
|
||||
// padding. Mirrors the live strip in the v0.22 mockup.
|
||||
.rail__track[data-layout='channel'] {
|
||||
--rail-card-width: var(--channel-rail-width, 280px);
|
||||
--rail-gap: 10px;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.rail__card--channel {
|
||||
background: var(--app-widget-bg, #1e2330);
|
||||
border: 1px solid
|
||||
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
border-radius: var(--rail-radius);
|
||||
padding: 10px 12px;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(
|
||||
--app-selection-border,
|
||||
rgba(47, 123, 255, 0.4)
|
||||
);
|
||||
box-shadow:
|
||||
0 6px 16px -10px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px var(--app-selection-border, rgba(47, 123, 255, 0.3));
|
||||
|
||||
.rail__channel-name {
|
||||
color: var(--app-selection-color, #2f7bff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rail__channel-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #2f7bff);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.rail__channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail__channel-logo {
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--app-content-bg, #161a22);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
// Drop-shadow gives small logos some lift against the card surface
|
||||
// without the dark wash from the poster-style live treatment.
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
&--fallback mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--app-selection-color, #78adff);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.rail__channel-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rail__channel-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--app-heading-color, #1a1d23);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.rail__channel-now {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--app-muted-color, #8891a4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 1.35em;
|
||||
}
|
||||
|
||||
.rail__channel-progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--app-muted-color, #8891a4);
|
||||
}
|
||||
|
||||
.rail__channel-live-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(
|
||||
--font-mono,
|
||||
ui-monospace,
|
||||
'SF Mono',
|
||||
'JetBrains Mono',
|
||||
Menlo,
|
||||
monospace
|
||||
);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--app-live-color, #ff8f8f);
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
box-shadow: 0 0 5px var(--app-live-color, #ff8f8f);
|
||||
}
|
||||
}
|
||||
|
||||
.rail__channel-progress {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 12%,
|
||||
transparent
|
||||
);
|
||||
overflow: hidden;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
&--idle i {
|
||||
// No EPG data yet — keep the bar neutral so it doesn't look like
|
||||
// "0% watched".
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 18%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.rail__channel-time {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -35,8 +35,45 @@ export interface DashboardRailCard {
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
actions?: DashboardRailAction[];
|
||||
epgLookupKey?: string;
|
||||
|
||||
/**
|
||||
* Optional EPG enrichment shown by the 'channel' rail layout. Populated
|
||||
* asynchronously after the card list is computed — when null/undefined
|
||||
* the channel card renders without the program subtitle/progress.
|
||||
*/
|
||||
nowPlayingTitle?: string | null;
|
||||
/** Localised time range like "12:15 – 13:40". */
|
||||
nowPlayingTimeRange?: string | null;
|
||||
/** 0–100, % through the current program. */
|
||||
nowPlayingProgress?: number | null;
|
||||
|
||||
/**
|
||||
* 0–100 watched, attached for movies/series with a resume position so
|
||||
* the cover layout can render a thin "watched up to here" bar at the
|
||||
* bottom of the poster. Live cards and content without a tracked
|
||||
* position leave this unset.
|
||||
*/
|
||||
watchProgress?: number | null;
|
||||
|
||||
/**
|
||||
* Localised "S{n} · E{n}" badge for series with a tracked episode
|
||||
* position. Renders as a small chip next to the card subtitle so the
|
||||
* user can see which episode they were on without opening the show.
|
||||
*/
|
||||
episodeBadge?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual layout of the rail's cards.
|
||||
* - 'cover': portrait poster (Netflix-style), used for movies/series and
|
||||
* recently-added catalog items.
|
||||
* - 'channel': compact horizontal info row (logo + channel name + current
|
||||
* program + progress), used for live TV. TV station logos are small, so
|
||||
* inflating them into 2:3 posters wastes space the cards never use.
|
||||
*/
|
||||
export type DashboardRailLayout = 'cover' | 'channel';
|
||||
|
||||
export interface DashboardRailActionSelection {
|
||||
action: DashboardRailAction;
|
||||
card: DashboardRailCard;
|
||||
@@ -60,6 +97,7 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
readonly items = input.required<DashboardRailCard[]>();
|
||||
readonly seeAllLink = input<string[] | null>(null);
|
||||
readonly aspectRatio = input<string>('2 / 3');
|
||||
readonly layout = input<DashboardRailLayout>('cover');
|
||||
readonly testId = input<string | null>(null);
|
||||
readonly actionSelected = output<DashboardRailActionSelection>();
|
||||
/**
|
||||
|
||||
+60
-30
@@ -76,6 +76,30 @@
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (item.watchProgress !== null && item.watchProgress !== undefined) {
|
||||
<span class="hero__progress" aria-hidden="true">
|
||||
<span
|
||||
class="hero__progress-bar"
|
||||
[style.width.%]="item.watchProgress"
|
||||
></span>
|
||||
</span>
|
||||
<span class="hero__progress-meta">
|
||||
@if (item.remainingLabel) {
|
||||
<span>{{
|
||||
item.remainingLabel.key
|
||||
| translate: item.remainingLabel.params
|
||||
}}</span>
|
||||
<span class="hero__progress-meta-sep"
|
||||
>·</span
|
||||
>
|
||||
}
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
|
||||
| translate: { value: item.watchProgress }
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span class="hero__cta">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
<span>{{
|
||||
@@ -102,16 +126,40 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Recently watched rail -->
|
||||
@if (recentlyWatchedCards().length > 0) {
|
||||
<!-- Continue watching (movies + series) -->
|
||||
@if (continueWatchingCards().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
|
||||
[items]="recentlyWatchedCards()"
|
||||
[totalCount]="data.globalRecentItems().length"
|
||||
[label]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
|
||||
[items]="continueWatchingCards()"
|
||||
[totalCount]="data.globalRecentVodItems().length"
|
||||
[seeAllLink]="['/workspace/global-recent']"
|
||||
[testId]="'dashboard-recently-watched-rail'"
|
||||
[testId]="'dashboard-continue-watching-rail'"
|
||||
/>
|
||||
} @else if (data.globalRecentLoading()) {
|
||||
}
|
||||
|
||||
<!-- Live rail — favorited live channels first, falls back to
|
||||
recently-watched live. Title flips based on source so the label
|
||||
is always honest about what's rendered. -->
|
||||
@if (liveOnFavoritesCardsEnriched().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="liveRailTitleKey() | translate"
|
||||
[items]="liveOnFavoritesCardsEnriched()"
|
||||
[totalCount]="liveRailTotalCount()"
|
||||
[seeAllLink]="
|
||||
liveRailSource() === 'favorites'
|
||||
? ['/workspace/global-favorites']
|
||||
: ['/workspace/global-recent']
|
||||
"
|
||||
layout="channel"
|
||||
[testId]="'dashboard-live-recent-rail'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (
|
||||
continueWatchingCards().length === 0 &&
|
||||
liveOnFavoritesCards().length === 0 &&
|
||||
data.globalRecentLoading()
|
||||
) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -126,29 +174,11 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Favorites rail -->
|
||||
@if (favoriteCards().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
|
||||
[items]="favoriteCards()"
|
||||
[totalCount]="data.globalFavoriteItems().length"
|
||||
[seeAllLink]="['/workspace/global-favorites']"
|
||||
[testId]="'dashboard-global-favorites-rail'"
|
||||
/>
|
||||
} @else if (data.globalFavoritesLoading()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="rails-page__skeleton-header"></div>
|
||||
<div class="rails-page__skeleton-track">
|
||||
@for (slot of skeletonSlots; track slot) {
|
||||
<div class="rails-page__skeleton-card"></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<!-- Mixed Global Favorites rail intentionally NOT rendered on the
|
||||
dashboard. Live favorites are promoted into the live rail above
|
||||
(with current EPG), and the full mixed catalogue lives on the
|
||||
dedicated /workspace/global-favorites page where the collection
|
||||
UI can give it the per-type filters it needs. -->
|
||||
|
||||
<!-- Sources rail -->
|
||||
@if (sourceCards().length > 0) {
|
||||
|
||||
+57
-4
@@ -40,8 +40,8 @@
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 18px;
|
||||
@@ -56,7 +56,10 @@
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
--hero-poster-width: 160px;
|
||||
// Tighter than v0.21 (was 160px). Matches the v0.22 mockup's 150px
|
||||
// poster intent, freeing space for the progress + remaining label
|
||||
// without growing total hero height.
|
||||
--hero-poster-width: 140px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
@@ -292,8 +295,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Watch-progress visualisation. Appears between the subtitle and the CTA
|
||||
// only when a resume position is known — VOD/series only (M3U and live
|
||||
// channels have no playback positions in the schema).
|
||||
.hero__progress {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
height: 4px;
|
||||
width: min(320px, 70%);
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 14%,
|
||||
transparent
|
||||
);
|
||||
overflow: hidden;
|
||||
|
||||
.hero--with-image & {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.hero__progress-meta {
|
||||
margin-top: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
color: var(--app-body-color, #8b93a8);
|
||||
letter-spacing: 0.005em;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__progress-meta-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hero__cta {
|
||||
margin-top: 16px;
|
||||
margin-top: 14px;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+287
-1
@@ -1,8 +1,17 @@
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildLiveEpgLookupKeys,
|
||||
buildPlaybackPositionReloadKey,
|
||||
buildDashboardSourceActions,
|
||||
calcEpgProgress,
|
||||
formatEpgTimeRange,
|
||||
formatRemainingLabel,
|
||||
getLiveEpgProgramForCard,
|
||||
liveRailTitleKeyForSource,
|
||||
playbackProgressPercent,
|
||||
resolveDashboardHeroArtwork,
|
||||
} from './workspace-dashboard-rails.component';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
|
||||
describe('buildDashboardSourceActions', () => {
|
||||
const basePlaylist = {
|
||||
@@ -115,3 +124,280 @@ describe('resolveDashboardHeroArtwork', () => {
|
||||
expect(artwork.fallbackPosterBackground).toContain('linear-gradient');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EPG enrichment helpers', () => {
|
||||
const baseProgram = (overrides: Partial<EpgProgram> = {}): EpgProgram =>
|
||||
({
|
||||
title: 'Tagesschau',
|
||||
desc: null,
|
||||
start: '2026-05-19T12:00:00.000Z',
|
||||
stop: '2026-05-19T12:30:00.000Z',
|
||||
startTimestamp: Date.UTC(2026, 4, 19, 12, 0, 0),
|
||||
stopTimestamp: Date.UTC(2026, 4, 19, 12, 30, 0),
|
||||
...overrides,
|
||||
}) as EpgProgram;
|
||||
|
||||
describe('formatEpgTimeRange', () => {
|
||||
it('formats a start–stop window using the locale-independent HH:MM range', () => {
|
||||
const range = formatEpgTimeRange(baseProgram());
|
||||
// Format is HH:MM – HH:MM (en dash), in the test runner's local
|
||||
// timezone — assert structure, not exact hours.
|
||||
expect(range).toMatch(/^\d{2}:\d{2} – \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('returns null when either timestamp is missing or unparseable', () => {
|
||||
expect(
|
||||
formatEpgTimeRange(
|
||||
baseProgram({
|
||||
start: 'not-a-date',
|
||||
startTimestamp: null,
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
formatEpgTimeRange(
|
||||
baseProgram({
|
||||
stop: '',
|
||||
stopTimestamp: null,
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcEpgProgress', () => {
|
||||
const program = baseProgram();
|
||||
const start = program.startTimestamp as number;
|
||||
const stop = program.stopTimestamp as number;
|
||||
|
||||
it('returns 0 at the start of the window', () => {
|
||||
expect(calcEpgProgress(program, start)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 100 at the end of the window', () => {
|
||||
expect(calcEpgProgress(program, stop)).toBe(100);
|
||||
});
|
||||
|
||||
it('clamps below 0 and above 100 for out-of-window times', () => {
|
||||
expect(calcEpgProgress(program, start - 60_000)).toBe(0);
|
||||
expect(calcEpgProgress(program, stop + 60_000)).toBe(100);
|
||||
});
|
||||
|
||||
it('interpolates linearly across the window', () => {
|
||||
const mid = start + (stop - start) / 2;
|
||||
expect(calcEpgProgress(program, mid)).toBeCloseTo(50, 5);
|
||||
});
|
||||
|
||||
it('returns null when the window is zero-length or inverted', () => {
|
||||
expect(
|
||||
calcEpgProgress(
|
||||
baseProgram({ stopTimestamp: start }),
|
||||
start + 10
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
calcEpgProgress(
|
||||
baseProgram({ stopTimestamp: start - 1 }),
|
||||
start
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live rail helpers', () => {
|
||||
const channelCard = (
|
||||
overrides: Partial<DashboardRailCard> = {}
|
||||
): DashboardRailCard => ({
|
||||
id: 'card-1',
|
||||
title: 'Display Channel',
|
||||
subtitle: 'M3U · Live',
|
||||
icon: 'live_tv',
|
||||
contentType: 'live',
|
||||
link: ['/workspace', 'playlists', 'm3u-1'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('uses explicit EPG lookup keys before falling back to card titles', () => {
|
||||
expect(
|
||||
buildLiveEpgLookupKeys([
|
||||
channelCard({
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
}),
|
||||
channelCard({
|
||||
id: 'card-2',
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
}),
|
||||
channelCard({
|
||||
id: 'card-3',
|
||||
title: 'Fallback News',
|
||||
}),
|
||||
])
|
||||
).toEqual(['ard.de', 'Fallback News']);
|
||||
});
|
||||
|
||||
it('reads EPG programs by explicit lookup key instead of display title', () => {
|
||||
const program = { title: 'Tagesschau' } as EpgProgram;
|
||||
const wrongProgram = { title: 'Wrong channel' } as EpgProgram;
|
||||
const card = channelCard({
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
});
|
||||
|
||||
expect(
|
||||
getLiveEpgProgramForCard(
|
||||
card,
|
||||
new Map<string, EpgProgram | null>([
|
||||
['Das Erste HD', wrongProgram],
|
||||
['ard.de', program],
|
||||
])
|
||||
)
|
||||
).toBe(program);
|
||||
});
|
||||
|
||||
it('uses honest, semantically named title keys for favorite and recent live rails', () => {
|
||||
expect(liveRailTitleKeyForSource('favorites')).toBe(
|
||||
'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
|
||||
);
|
||||
expect(liveRailTitleKeyForSource('recent')).toBe(
|
||||
'WORKSPACE.DASHBOARD.LIVE_RECENT'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a stable playback-position reload key from VOD and series items only', () => {
|
||||
const first = buildPlaybackPositionReloadKey([
|
||||
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
|
||||
{ playlist_id: 'live', xtream_id: 'stream-url', type: 'live' },
|
||||
{ playlist_id: 'a', xtream_id: 10, type: 'movie' },
|
||||
]);
|
||||
const second = buildPlaybackPositionReloadKey([
|
||||
{ playlist_id: 'a', xtream_id: 10, type: 'movie' },
|
||||
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
|
||||
]);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toBe('a::movie::10|b::series::20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('playback-position helpers', () => {
|
||||
describe('playbackProgressPercent', () => {
|
||||
it('returns null when there is no position or no duration', () => {
|
||||
expect(playbackProgressPercent(null)).toBeNull();
|
||||
expect(
|
||||
playbackProgressPercent({ positionSeconds: 300 })
|
||||
).toBeNull();
|
||||
expect(
|
||||
playbackProgressPercent({
|
||||
positionSeconds: 300,
|
||||
durationSeconds: 0,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the integer percent watched, clamped to [0, 100]', () => {
|
||||
expect(
|
||||
playbackProgressPercent({
|
||||
positionSeconds: 0,
|
||||
durationSeconds: 6000,
|
||||
})
|
||||
).toBe(0);
|
||||
expect(
|
||||
playbackProgressPercent({
|
||||
positionSeconds: 3000,
|
||||
durationSeconds: 6000,
|
||||
})
|
||||
).toBe(50);
|
||||
expect(
|
||||
playbackProgressPercent({
|
||||
positionSeconds: 6000,
|
||||
durationSeconds: 6000,
|
||||
})
|
||||
).toBe(100);
|
||||
// Past-end resume is clamped, not negative.
|
||||
expect(
|
||||
playbackProgressPercent({
|
||||
positionSeconds: 7000,
|
||||
durationSeconds: 6000,
|
||||
})
|
||||
).toBe(100);
|
||||
// Floor rounding — 99.97% stays at 99% to avoid the "watched
|
||||
// completely" optical illusion when 1 second remains.
|
||||
expect(
|
||||
playbackProgressPercent({
|
||||
positionSeconds: 5998.5,
|
||||
durationSeconds: 6000,
|
||||
})
|
||||
).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRemainingLabel', () => {
|
||||
it('returns null without a usable position/duration', () => {
|
||||
expect(formatRemainingLabel(null)).toBeNull();
|
||||
expect(
|
||||
formatRemainingLabel({ positionSeconds: 100 })
|
||||
).toBeNull();
|
||||
expect(
|
||||
formatRemainingLabel({
|
||||
positionSeconds: 100,
|
||||
durationSeconds: -1,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns translation keys and params for sub-minute, minute, and hour-spanning remainders', () => {
|
||||
expect(
|
||||
formatRemainingLabel({
|
||||
positionSeconds: 5970,
|
||||
durationSeconds: 6000,
|
||||
})
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
|
||||
params: { seconds: 30 },
|
||||
});
|
||||
expect(
|
||||
formatRemainingLabel({
|
||||
positionSeconds: 0,
|
||||
durationSeconds: 1800,
|
||||
})
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: 30 },
|
||||
});
|
||||
expect(
|
||||
formatRemainingLabel({
|
||||
positionSeconds: 0,
|
||||
durationSeconds: 3600,
|
||||
})
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
|
||||
params: { hours: 1 },
|
||||
});
|
||||
expect(
|
||||
formatRemainingLabel({
|
||||
positionSeconds: 600,
|
||||
durationSeconds: 6840,
|
||||
})
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
|
||||
params: { hours: 1, minutes: 44 },
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps below zero to the translated zero-seconds state', () => {
|
||||
expect(
|
||||
formatRemainingLabel({
|
||||
positionSeconds: 7000,
|
||||
durationSeconds: 6000,
|
||||
})
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
|
||||
params: { seconds: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+317
-4
@@ -5,7 +5,12 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, of, startWith, switchMap } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -44,6 +49,13 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
// full list. Matches the single-rail density of Netflix / Apple TV+.
|
||||
const RAIL_ITEM_LIMIT = 20;
|
||||
|
||||
// EPG "now" data ticks every 30s — short enough that the progress bar moves
|
||||
// visibly between long-tail program changes, long enough that we don't hammer
|
||||
// the SQLite backend with a batched IPC every animation frame. Matches the
|
||||
// channel-list-container's existing progress cadence so the two surfaces
|
||||
// can't drift out of sync.
|
||||
const LIVE_EPG_TICK_MS = 30_000;
|
||||
|
||||
// Six placeholder slots per skeleton rail — fills a typical viewport without
|
||||
// taking the whole page. Mirrors the recently-added skeleton density.
|
||||
const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const;
|
||||
@@ -62,6 +74,14 @@ interface DashboardHeroModel {
|
||||
readonly state?: Record<string, unknown>;
|
||||
readonly subtitle: string;
|
||||
readonly title: string;
|
||||
/** 0–100 watched, when a resume position is known. */
|
||||
readonly watchProgress?: number | null;
|
||||
readonly remainingLabel?: DashboardRemainingLabel | null;
|
||||
}
|
||||
|
||||
export interface DashboardRemainingLabel {
|
||||
readonly key: string;
|
||||
readonly params: Record<string, number>;
|
||||
}
|
||||
|
||||
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
|
||||
@@ -193,6 +213,170 @@ export function buildDashboardSourceActions(
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Reads either an ISO `start`/`stop` or the pre-computed `startTimestamp`
|
||||
// when present (the parsed XMLTV pipeline populates both, but legacy rows
|
||||
// only carry the strings).
|
||||
function epgTimestampMs(
|
||||
program: EpgProgram,
|
||||
side: 'start' | 'stop'
|
||||
): number | null {
|
||||
const cached =
|
||||
side === 'start' ? program.startTimestamp : program.stopTimestamp;
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
const iso = side === 'start' ? program.start : program.stop;
|
||||
const ms = iso ? new Date(iso).getTime() : NaN;
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function formatEpgTime(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatEpgTimeRange(program: EpgProgram): string | null {
|
||||
const start = epgTimestampMs(program, 'start');
|
||||
const stop = epgTimestampMs(program, 'stop');
|
||||
if (start == null || stop == null) {
|
||||
return null;
|
||||
}
|
||||
return `${formatEpgTime(start)} – ${formatEpgTime(stop)}`;
|
||||
}
|
||||
|
||||
export function calcEpgProgress(
|
||||
program: EpgProgram,
|
||||
nowMs: number
|
||||
): number | null {
|
||||
const start = epgTimestampMs(program, 'start');
|
||||
const stop = epgTimestampMs(program, 'stop');
|
||||
if (start == null || stop == null || stop <= start) {
|
||||
return null;
|
||||
}
|
||||
const ratio = (nowMs - start) / (stop - start);
|
||||
if (!Number.isFinite(ratio)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.min(100, ratio * 100));
|
||||
}
|
||||
|
||||
function liveEpgLookupKeyForCard(card: DashboardRailCard): string {
|
||||
return card.epgLookupKey?.trim() || card.title.trim();
|
||||
}
|
||||
|
||||
export function buildLiveEpgLookupKeys(
|
||||
cards: readonly DashboardRailCard[]
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const card of cards) {
|
||||
const key = liveEpgLookupKeyForCard(card);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function getLiveEpgProgramForCard(
|
||||
card: DashboardRailCard,
|
||||
epgMap: ReadonlyMap<string, EpgProgram | null>
|
||||
): EpgProgram | null {
|
||||
const key = liveEpgLookupKeyForCard(card);
|
||||
const program = epgMap.get(key);
|
||||
if (program) {
|
||||
return program;
|
||||
}
|
||||
|
||||
const titleKey = card.title.trim();
|
||||
return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null;
|
||||
}
|
||||
|
||||
export function liveRailTitleKeyForSource(
|
||||
source: 'favorites' | 'recent'
|
||||
): string {
|
||||
return source === 'favorites'
|
||||
? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
|
||||
: 'WORKSPACE.DASHBOARD.LIVE_RECENT';
|
||||
}
|
||||
|
||||
export function buildPlaybackPositionReloadKey(
|
||||
items: readonly Pick<GlobalRecentItem, 'playlist_id' | 'type' | 'xtream_id'>[]
|
||||
): string {
|
||||
return items
|
||||
.filter((item) => item.type === 'movie' || item.type === 'series')
|
||||
.map((item) => `${item.playlist_id}::${item.type}::${item.xtream_id}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
// ── Playback-position helpers (used by both hero + Continue Watching cards)
|
||||
// — kept as plain functions so they're easy to unit-test without mounting the
|
||||
// component or the data service.
|
||||
|
||||
export function playbackProgressPercent(
|
||||
position: { positionSeconds: number; durationSeconds?: number } | null
|
||||
): number | null {
|
||||
if (
|
||||
!position ||
|
||||
position.durationSeconds == null ||
|
||||
position.durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ratio = position.positionSeconds / position.durationSeconds;
|
||||
if (!Number.isFinite(ratio)) {
|
||||
return null;
|
||||
}
|
||||
// Integer percent — keeps "92% watched" out of "92.4% watched" territory,
|
||||
// and matches the resolution of a 3px-tall progress bar on a 280px card.
|
||||
return Math.max(0, Math.min(100, Math.floor(ratio * 100)));
|
||||
}
|
||||
|
||||
export function formatRemainingLabel(
|
||||
position: { positionSeconds: number; durationSeconds?: number } | null
|
||||
): DashboardRemainingLabel | null {
|
||||
if (
|
||||
!position ||
|
||||
position.durationSeconds == null ||
|
||||
position.durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.round(position.durationSeconds - position.positionSeconds)
|
||||
);
|
||||
if (remaining < 60) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
|
||||
params: { seconds: remaining },
|
||||
};
|
||||
}
|
||||
const totalMinutes = Math.round(remaining / 60);
|
||||
if (totalMinutes < 60) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: totalMinutes },
|
||||
};
|
||||
}
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (minutes === 0) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
|
||||
params: { hours },
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
|
||||
params: { hours, minutes },
|
||||
};
|
||||
}
|
||||
|
||||
function isXtreamAccountPlaylist(
|
||||
playlist: PlaylistMeta
|
||||
): playlist is PlaylistMeta & {
|
||||
@@ -233,7 +417,12 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly epgService = inject(EpgService);
|
||||
|
||||
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
|
||||
readonly ready = this.data.dashboardReady;
|
||||
@@ -259,6 +448,8 @@ export class WorkspaceDashboardRailsComponent {
|
||||
this.failedHeroImages()
|
||||
);
|
||||
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
contentType: item.type,
|
||||
@@ -267,14 +458,111 @@ export class WorkspaceDashboardRailsComponent {
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
subtitle: this.buildHeroSubtitle(item),
|
||||
title: item.title,
|
||||
watchProgress: playbackProgressPercent(position),
|
||||
remainingLabel: formatRemainingLabel(position),
|
||||
};
|
||||
});
|
||||
|
||||
readonly recentlyWatchedCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.globalRecentItems()
|
||||
// Split the mixed "recently watched" history into two rails. VOD items
|
||||
// (movies/series) get one rail; live channels get their own. Two card
|
||||
// formats — one rail each — so users can scan each surface at a glance
|
||||
// instead of decoding a mixed grid of fallback posters and channel logos.
|
||||
readonly continueWatchingCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
return this.data
|
||||
.globalRecentVodItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toRecentCard(item))
|
||||
.map((item) => this.toRecentCard(item));
|
||||
});
|
||||
|
||||
// Live rail source — favorited live channels take precedence so the EPG
|
||||
// enrichment lands on the channels the user actually cares about. When
|
||||
// no favorites exist (fresh install, or a user who hasn't starred any
|
||||
// channel yet), fall back to recently-watched live so the rail still
|
||||
// has something to show. `liveRailSource` drives the title swap.
|
||||
readonly liveRailSource = computed<'favorites' | 'recent'>(() =>
|
||||
this.data.globalFavoriteLiveItems().length > 0
|
||||
? 'favorites'
|
||||
: 'recent'
|
||||
);
|
||||
|
||||
readonly liveOnFavoritesCards = computed<DashboardRailCard[]>(() => {
|
||||
if (this.liveRailSource() === 'favorites') {
|
||||
return this.data
|
||||
.globalFavoriteLiveItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toFavoriteCard(item));
|
||||
}
|
||||
return this.data
|
||||
.globalRecentLiveItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toRecentCard(item));
|
||||
});
|
||||
|
||||
// Title key flips with the source — "Live now on your favorites" when
|
||||
// we're rendering favorites, "Continue with live TV" when we're falling
|
||||
// back to recently-watched. Always honest about what the user is seeing.
|
||||
readonly liveRailTitleKey = computed(() =>
|
||||
liveRailTitleKeyForSource(this.liveRailSource())
|
||||
);
|
||||
|
||||
readonly liveRailTotalCount = computed(() =>
|
||||
this.liveRailSource() === 'favorites'
|
||||
? this.data.globalFavoriteLiveItems().length
|
||||
: this.data.globalRecentLiveItems().length
|
||||
);
|
||||
|
||||
// Best-effort EPG lookup keyed by the app-wide M3U XMLTV chain
|
||||
// (tvg-id -> tvg-name -> name), with the card title as a final fallback.
|
||||
// Xtream/Stalker live items often have no XMLTV side-channel and will
|
||||
// simply return null — the card renders without the program row.
|
||||
private readonly liveChannelLookupKeys = computed(() =>
|
||||
buildLiveEpgLookupKeys(this.liveOnFavoritesCards())
|
||||
);
|
||||
|
||||
private readonly playbackPositionReloadKey = computed(() =>
|
||||
buildPlaybackPositionReloadKey(this.data.globalRecentVodItems())
|
||||
);
|
||||
|
||||
// Re-fetch on rail change AND on a 30s heartbeat so the progress bar
|
||||
// catches the boundary between programs without a full page revisit.
|
||||
private readonly liveEpgPrograms = toSignal(
|
||||
toObservable(this.liveChannelLookupKeys).pipe(
|
||||
switchMap((keys) =>
|
||||
keys.length === 0
|
||||
? of(new Map<string, EpgProgram | null>())
|
||||
: interval(LIVE_EPG_TICK_MS).pipe(
|
||||
startWith(0),
|
||||
switchMap(() =>
|
||||
this.epgService.getCurrentProgramsForChannels(
|
||||
keys
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
{ initialValue: new Map<string, EpgProgram | null>() }
|
||||
);
|
||||
|
||||
readonly liveOnFavoritesCardsEnriched = computed<DashboardRailCard[]>(
|
||||
() => {
|
||||
const epgMap = this.liveEpgPrograms();
|
||||
// Recompute the now-window each tick so progress moves between
|
||||
// 30s ticks even if the program identity is unchanged.
|
||||
const now = Date.now();
|
||||
return this.liveOnFavoritesCards().map((card) => {
|
||||
const program = getLiveEpgProgramForCard(card, epgMap);
|
||||
if (!program) {
|
||||
return card;
|
||||
}
|
||||
return {
|
||||
...card,
|
||||
nowPlayingTitle: program.title || null,
|
||||
nowPlayingTimeRange: formatEpgTimeRange(program),
|
||||
nowPlayingProgress: calcEpgProgress(program, now),
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
readonly favoriteCards = computed<DashboardRailCard[]>(() =>
|
||||
@@ -328,6 +616,13 @@ export class WorkspaceDashboardRailsComponent {
|
||||
|
||||
void this.data.reloadXtreamRecentlyAddedItems(RAIL_ITEM_LIMIT);
|
||||
});
|
||||
|
||||
// Reload playback positions when the VOD/series recent set changes.
|
||||
// The primitive key keeps live-only recent churn out of the IPC path.
|
||||
effect(() => {
|
||||
this.playbackPositionReloadKey();
|
||||
untracked(() => void this.data.reloadPlaybackPositions());
|
||||
});
|
||||
}
|
||||
|
||||
onAddPlaylist(type?: WorkspacePlaylistType): void {
|
||||
@@ -375,6 +670,20 @@ export class WorkspaceDashboardRailsComponent {
|
||||
}
|
||||
|
||||
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
const watchProgress = playbackProgressPercent(position);
|
||||
const episodeBadge =
|
||||
item.type === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`,
|
||||
title: item.title,
|
||||
@@ -382,8 +691,11 @@ export class WorkspaceDashboardRailsComponent {
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
epgLookupKey: item.epg_lookup_key,
|
||||
link: this.data.getRecentItemLink(item),
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
watchProgress,
|
||||
episodeBadge,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,6 +707,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
epgLookupKey: item.epg_lookup_key,
|
||||
link: this.data.getGlobalFavoriteLink(item),
|
||||
state: this.data.getGlobalFavoriteNavigationState(item),
|
||||
};
|
||||
|
||||
+11
-4
@@ -7,12 +7,19 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Quiet sentence-case rail header — matches the typography of the
|
||||
// section nav items below ("Dashboard", "Sources", etc., all sentence
|
||||
// case) instead of standing out as an uppercase eyebrow that didn't
|
||||
// appear anywhere else in the workspace rail. The muted colour and
|
||||
// small size still distinguish it from the clickable items so it reads
|
||||
// as a section caption rather than competing for focus.
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
padding: 16px 16px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--mat-sys-on-surface);
|
||||
padding: 20px 18px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.005em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.settings-panel-body {
|
||||
|
||||
+52
-39
@@ -1,18 +1,24 @@
|
||||
<header class="workspace-header">
|
||||
<app-playlist-switcher
|
||||
class="playlist-switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="playlistSubtitle()"
|
||||
[showPlaylistInfo]="canOpenPlaylistInfo()"
|
||||
[showAccountInfo]="canOpenAccountInfo()"
|
||||
[showAddPlaylist]="true"
|
||||
[canRefreshActivePlaylist]="canRefreshPlaylist()"
|
||||
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
|
||||
(playlistInfoRequested)="onPlaylistInfoRequested()"
|
||||
(accountInfoRequested)="onAccountInfoRequested()"
|
||||
(addPlaylistRequested)="onAddPlaylistRequested()"
|
||||
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
|
||||
/>
|
||||
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
|
||||
<!-- Playlist switcher hidden on /settings: those controls are
|
||||
playlist-scoped, but Settings is a global page. Per the UX
|
||||
audit, leaving them visible implied switching playlists changes
|
||||
which settings you're editing. -->
|
||||
@if (!isSettingsRoute()) {
|
||||
<app-playlist-switcher
|
||||
class="playlist-switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="playlistSubtitle()"
|
||||
[showPlaylistInfo]="canOpenPlaylistInfo()"
|
||||
[showAccountInfo]="canOpenAccountInfo()"
|
||||
[showAddPlaylist]="true"
|
||||
[canRefreshActivePlaylist]="canRefreshPlaylist()"
|
||||
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
|
||||
(playlistInfoRequested)="onPlaylistInfoRequested()"
|
||||
(accountInfoRequested)="onAccountInfoRequested()"
|
||||
(addPlaylistRequested)="onAddPlaylistRequested()"
|
||||
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!hasNoPlaylists()) {
|
||||
<label class="search-field">
|
||||
@@ -53,6 +59,11 @@
|
||||
</label>
|
||||
}
|
||||
|
||||
<!-- The "Add playlist" button stays visible on /settings — adding a
|
||||
playlist is a global action, not playlist-scoped, so it must
|
||||
remain reachable (e.g. a fresh user configuring the app before
|
||||
importing their first source). Only the per-playlist shortcut
|
||||
and bulk actions are hidden on Settings. -->
|
||||
<div class="header-actions header-actions--leading">
|
||||
<button
|
||||
type="button"
|
||||
@@ -72,30 +83,32 @@
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
@if (headerShortcut(); as action) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut"
|
||||
mat-icon-button
|
||||
(click)="onHeaderShortcutRequested()"
|
||||
[matTooltip]="action.tooltipKey | translate"
|
||||
[attr.aria-label]="action.ariaLabelKey | translate"
|
||||
>
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (headerBulkAction(); as bulk) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut header-bulk-action"
|
||||
mat-icon-button
|
||||
[disabled]="bulk.disabled"
|
||||
(click)="onHeaderBulkActionRequested()"
|
||||
[matTooltip]="bulk.tooltip"
|
||||
[attr.aria-label]="bulk.ariaLabel"
|
||||
>
|
||||
<mat-icon>{{ bulk.icon }}</mat-icon>
|
||||
</button>
|
||||
@if (!isSettingsRoute()) {
|
||||
@if (headerShortcut(); as action) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut"
|
||||
mat-icon-button
|
||||
(click)="onHeaderShortcutRequested()"
|
||||
[matTooltip]="action.tooltipKey | translate"
|
||||
[attr.aria-label]="action.ariaLabelKey | translate"
|
||||
>
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (headerBulkAction(); as bulk) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut header-bulk-action"
|
||||
mat-icon-button
|
||||
[disabled]="bulk.disabled"
|
||||
(click)="onHeaderBulkActionRequested()"
|
||||
[matTooltip]="bulk.tooltip"
|
||||
[attr.aria-label]="bulk.ariaLabel"
|
||||
>
|
||||
<mat-icon>{{ bulk.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
+9
@@ -48,6 +48,15 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly hasNoPlaylists = input(false);
|
||||
readonly isDownloadsView = input(false);
|
||||
readonly hasActiveDownloads = input(false);
|
||||
/**
|
||||
* When true the playlist switcher + the "+ Add source" / refresh /
|
||||
* bulk-action buttons are hidden — those controls scope to a
|
||||
* playlist, but Settings is a global page, so leaving them visible
|
||||
* implies (falsely) that switching the playlist changes which
|
||||
* settings you're editing. Driven from the shell facade's existing
|
||||
* isSettingsRoute computed.
|
||||
*/
|
||||
readonly isSettingsRoute = input(false);
|
||||
|
||||
readonly searchChanged = output<string>();
|
||||
readonly searchSubmitted = output<string>();
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
[hasNoPlaylists]="facade.hasNoPlaylists()"
|
||||
[isDownloadsView]="facade.isDownloadsView()"
|
||||
[hasActiveDownloads]="facade.hasActiveDownloads()"
|
||||
[isSettingsRoute]="facade.isSettingsRoute()"
|
||||
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
|
||||
(searchChanged)="facade.onSearchInput($event)"
|
||||
(searchSubmitted)="facade.onSearchEnter($event)"
|
||||
|
||||
@@ -59,6 +59,7 @@ class MockWorkspaceShellHeaderComponent {
|
||||
readonly hasNoPlaylists = input(false);
|
||||
readonly isDownloadsView = input(false);
|
||||
readonly hasActiveDownloads = input(false);
|
||||
readonly isSettingsRoute = input(false);
|
||||
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly searchChanged = output<string>();
|
||||
readonly searchSubmitted = output<string>();
|
||||
|
||||
Reference in new issue
Block a user