Merge pull request #955 from 4gray/claude/elegant-hertz-40b11b

Unified color, rebuilt dashboard, polished Settings & Multi-EPG
This commit is contained in:
4gray authored and GitHub committed 2026-05-22 01:58:57 +03:00
commit ff9c13058c
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"]'
);
+2 -1
View File
@@ -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',
+6 -2
View File
@@ -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);
+12 -3
View File
@@ -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');
});
+2 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
+14 -2
View File
@@ -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">
+112 -107
View File
@@ -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]) {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+38
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+41 -3
View File
@@ -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": {
+40 -2
View File
@@ -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": {
+40 -2
View File
@@ -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 -9
View File
@@ -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);
}
+17
View File
@@ -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;
}
+24 -7
View File
@@ -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
@@ -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()"
@@ -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;
@@ -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);
}
});
@@ -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);
}
}
@@ -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;
@@ -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>('');
@@ -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>
+22 -16
View File
@@ -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);
}
}
}
+17 -14
View File
@@ -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>();
/**
@@ -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) {
@@ -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;
@@ -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 },
});
});
});
});
@@ -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),
};
@@ -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 {
@@ -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>
@@ -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>();