diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index f1d23c113..bf8e7c139 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -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, { diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 6757825e4..9589992ad 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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 { 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 { - await clickDialogSegmentedOption( - dialog, - 'Playlist category', - label, - legacySelector - ); -} - -async function clickDialogSubtypeOption( - dialog: Locator, - label: RegExp, - legacySelector?: string -): Promise { - await clickDialogSegmentedOption( - dialog, - 'M3U source', - label, - legacySelector - ); -} - -async function clickDialogSegmentedOption( - dialog: Locator, - tablistLabel: string, - label: RegExp, - legacySelector?: string -): Promise { - 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 { + // 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 { + 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"]' ); diff --git a/apps/web-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index 3f76af819..462d5f53a 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -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', diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index daaf7eb73..85047cab0 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -36,7 +36,10 @@ async function addXtreamPortal(page: Page): Promise { 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 { 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); diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 3b18bc6e1..f7ba49b2b 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -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'); }); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index aa8e8bc5e..11695285a 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index f1c54b529..2cf44e57b 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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); diff --git a/apps/web/src/_settings-theme.scss b/apps/web/src/_settings-theme.scss index 437c54977..8a843b60e 100644 --- a/apps/web/src/_settings-theme.scss +++ b/apps/web/src/_settings-theme.scss @@ -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%, diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index bf379c6ea..47d3ed7a1 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -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 } : {}, }); diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts new file mode 100644 index 000000000..7ac76cf97 --- /dev/null +++ b/apps/web/src/app/settings/settings-options.spec.ts @@ -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 { + const ids = new Set(); + 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
root only — + // descendant elements (form controls, anchors) also use id= + // attributes and would pollute the set. + const rootMatch = /]*\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([]); + }); +}); diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 8c509f186..93e8bb84d 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.ts b/apps/web/src/app/settings/settings-section-scroll.directive.ts index 1550d6d41..d2207a61d 100644 --- a/apps/web/src/app/settings/settings-section-scroll.directive.ts +++ b/apps/web/src/app/settings/settings-section-scroll.directive.ts @@ -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 { diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index a23a5308b..314e151e4 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -14,18 +14,20 @@ class="settings-layout" novalidate > + @if (!isDialog) { -
+ -
-

- {{ 'SETTINGS.TITLE' | translate }} -

-

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

-
-
+ {{ 'SETTINGS.TITLE' | translate }} + }
diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 5fcd5c475..14386f6b9 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -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]) { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 593509aa2..8793c3da9 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3b4ec08d3..e3818bebd 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 1ecdeeb78..28166c96e 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8388a0860..c03f42e6b 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 1caaf6877..026ee5d9d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c53c8fd73..30aa8e0b0 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 7cf593332..2a46d3a5c 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 25b16a654..bf5fb1041 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 14c46c88b..e3827b296 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d35388e32..1bb34bd1a 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f3d3fa86a..609035586 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 8fbce2a4b..315933711 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index bb45aa790..8e1b107e4 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index a5961a753..c1617c139 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 5009b61cf..e2d94ce57 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index d86ed8793..a7e6cf1c3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 33b6b5abe..07ad2e46a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 8da2bf49d..0f4e9830a 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": { diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index 60b860acc..4f8ac3325 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -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); } diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index b8d185552..6470250f7 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -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; +} diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 6a5a471f7..15b8ad5f1 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index 25fd20642..c78c7c66f 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -1,49 +1,41 @@
-

- {{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }} -

+
+

+ {{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }} +

+

+ {{ 'HOME.ADD_PLAYLIST.PICK_METHOD' | translate }} +

+
- @for (opt of categoryOptions; track opt.value) { + @for (opt of methodOptions; track opt.value) { }
- @if (category() === 'm3u') { -
- @for (opt of subtypeOptions; track opt.value) { - - } -
- } -
@switch (playlistType()) { @case ('file') { @@ -90,7 +82,7 @@ @case ('url') { - diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html index 06ecb3ca9..83901224d 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html @@ -23,10 +23,22 @@ > chevron_right - - @@ -53,7 +65,14 @@ @@ -113,7 +132,18 @@ @@ -123,16 +153,16 @@ @for (item of filteredChannels(); track item.id; let i = $index) { - + @@ -141,7 +171,6 @@ } {{ getChannelName(item) }} @@ -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)" > } - + + + + {{ currentTimeLabel() }} + diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss index 6b4724f7b..94bf9af24 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss @@ -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 { diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts new file mode 100644 index 000000000..addd6cb0c --- /dev/null +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 5e2d740c8..3923f4822 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -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 { if (!window.electron) { console.warn('Multi-EPG not available: Electron not detected'); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss index b1618faec..148235b42 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss @@ -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; diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index a0fde9dba..a54bd623e 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -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'; - + @@ -109,7 +158,14 @@ import { ChannelActions } from '@iptvnator/m3u-state'; `, styleUrls: ['./audio-player.component.scss'], - imports: [MatSliderModule, MatIconModule, MatButtonModule, FormsModule], + imports: [ + FormsModule, + MatButtonModule, + MatIconModule, + MatSliderModule, + MatTooltip, + TranslatePipe, + ], }) export class AudioPlayerComponent { readonly icon = input(''); diff --git a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts index 61f8d1eae..5ee4e0a0e 100644 --- a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts +++ b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts @@ -37,7 +37,7 @@ import { TranslateModule } from '@ngx-translate/core'; >Don't show anymore - diff --git a/libs/ui/styles/_detail-view-actions.scss b/libs/ui/styles/_detail-view-actions.scss index 867a4cac0..46cfe6e58 100644 --- a/libs/ui/styles/_detail-view-actions.scss +++ b/libs/ui/styles/_detail-view-actions.scss @@ -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); } } } diff --git a/libs/ui/styles/_detail-view.scss b/libs/ui/styles/_detail-view.scss index 98b012e2b..e4b248f00 100644 --- a/libs/ui/styles/_detail-view.scss +++ b/libs/ui/styles/_detail-view.scss @@ -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%, diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 9410ed0db..8bb5317d0 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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, [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; + } + ).values = + failOnLinearScan as unknown as () => IterableIterator; + + 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([ { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 5fac114de..261f291dc 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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(() => + this.globalRecentItems().filter( + (item) => item.type === 'movie' || item.type === 'series' + ) + ); + + readonly globalRecentLiveItems = computed(() => + 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(() => + 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 + >(new Map()); + private readonly playbackPositionsBySeriesMap = signal< + Map + >(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 { + const playlistIds = new Set(); + 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(); + const nextBySeries = new Map(); + 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(() => { 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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index cf93a9f76..8b91b148a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -14,9 +14,20 @@ [routerLink]="link" [attr.data-test-id]="testId() ? testId() + '-manage-all' : null" > - {{ - 'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate - }} + + @if ((totalCount() ?? items().length) > items().length) { + {{ + 'WORKSPACE.DASHBOARD.SEE_ALL_COUNT' + | translate + : { + count: + totalCount() ?? items().length, + } + }} + } @else { + {{ 'WORKSPACE.DASHBOARD.SEE_ALL' | translate }} + } + chevron_right } @@ -34,79 +45,184 @@ chevron_left - diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index 9b1d6de73..b459f7d29 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index 6c7ac3355..ae38601f9 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -35,8 +35,45 @@ export interface DashboardRailCard { link: string[]; state?: Record; 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(); readonly seeAllLink = input(null); readonly aspectRatio = input('2 / 3'); + readonly layout = input('cover'); readonly testId = input(null); readonly actionSelected = output(); /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 9555f7741..1ed8e2679 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -76,6 +76,30 @@

{{ item.title }}

{{ item.subtitle }}

+ @if (item.watchProgress !== null && item.watchProgress !== undefined) { + + + @if (item.remainingLabel) { + {{ + item.remainingLabel.key + | translate: item.remainingLabel.params + }} + · + } + {{ + 'WORKSPACE.DASHBOARD.PERCENT_WATCHED' + | translate: { value: item.watchProgress } + }} + + } + play_arrow {{ @@ -102,16 +126,40 @@ } - - @if (recentlyWatchedCards().length > 0) { + + @if (continueWatchingCards().length > 0) { - } @else if (data.globalRecentLoading()) { + } + + + @if (liveOnFavoritesCardsEnriched().length > 0) { + + } + + @if ( + continueWatchingCards().length === 0 && + liveOnFavoritesCards().length === 0 && + data.globalRecentLoading() + ) {