diff --git a/.changes/dashboard-rail-focus-reveal.md b/.changes/dashboard-rail-focus-reveal.md new file mode 100644 index 000000000..7c6368b10 --- /dev/null +++ b/.changes/dashboard-rail-focus-reveal.md @@ -0,0 +1,8 @@ +--- +type: fix +area: dashboard +--- + +Moving keyboard focus onto a dashboard rail card, such as a source in Recently +Used Sources, now scrolls the rail so the whole card shows instead of leaving +it half-hidden under the edge fade. diff --git a/.changes/detail-back-button-lane.md b/.changes/detail-back-button-lane.md index 21a1e6da9..b22c088e6 100644 --- a/.changes/detail-back-button-lane.md +++ b/.changes/detail-back-button-lane.md @@ -3,4 +3,4 @@ type: fix area: detail --- -On movie and series pages, the Back button no longer covers the start of the "Seasons and Episodes" heading or the corner of the player while you scroll, and in narrow windows the heading no longer gets squeezed onto several lines beside its buttons. +On movie and series pages in narrow windows, the "Seasons and Episodes" heading no longer gets squeezed onto several lines beside its buttons. diff --git a/.changes/ui-destructive-confirmations.md b/.changes/ui-destructive-confirmations.md new file mode 100644 index 000000000..3a5f8bb0f --- /dev/null +++ b/.changes/ui-destructive-confirmations.md @@ -0,0 +1,6 @@ +--- +type: fix +area: ui +--- + +Removing or clearing data now asks with a named action on a red button instead of "Yes"/"No", and delete buttons are red again. Each source type has one icon everywhere. Deleting a playlist from its error screen now confirms with a message and keeps the playlist if the delete fails. diff --git a/.changes/workspace-header-back.md b/.changes/workspace-header-back.md new file mode 100644 index 000000000..8fd5fcefb --- /dev/null +++ b/.changes/workspace-header-back.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +The Back button on movie and series pages now sits at the start of the top bar, so it never covers the page and the page keeps its full width. The IPTVnator logo is gone from the side rail: it opened the same page as the Dashboard button right below it. diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index bd52b37b7..a68bced22 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -29,8 +29,8 @@ consumers currently use relative `@use` paths to the needed partial. them outside Material components only for roles without an app token. - Set component tokens through `mat.*-overrides()`; retired `--mdc-*` names do nothing and `pnpm run styles:material-tokens:validate` rejects them. -- Destructive buttons use `.app-destructive-button` (`color="warn"` is a no-op - with M3). +- Destructive buttons use `.app-destructive-button` (the `warn` color input is + a no-op with M3); provider icons come from `SOURCE_TYPE_ICONS`. - Local semantic status colors are acceptable. Existing hard-coded layout, selection, and EPG surface colors are migration debt, not precedent. diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index c6029fc95..98485b1da 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -590,12 +590,9 @@ async function firstVisibleGridTitle(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); try { diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 560dc1f33..7f4ae4d8e 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -551,7 +551,9 @@ async function refreshFromWorkspaceHeader(page: Page): Promise { const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .click(); const refreshOverlay = page.locator('app-workspace-shell-import-overlay'); await expect(refreshOverlay).toBeVisible({ timeout: 5000 }); diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 5dfb3e3e0..51eb37830 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -306,12 +306,9 @@ function dashboardRailCardByTitle( } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -350,9 +347,9 @@ async function expectInlineCollectionDetail( await expectPathname(page, params.pathname); await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); - await expect( - page.locator('app-portal-detail-shell .shell__back-button').first() - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('workspace-header-back')).toBeVisible({ + timeout: 20000, + }); } async function playCurrentDetail(page: Page): Promise { diff --git a/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts new file mode 100644 index 000000000..0e9cf812f --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts @@ -0,0 +1,175 @@ +import { Locator, Page } from '@playwright/test'; +import { + closeElectronApp, + expect, + goToDashboard, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + test, + writeTemporaryM3uFile, +} from './electron-test-fixtures'; + +const sourcesRailId = 'dashboard-recent-sources-rail'; +const sourceCount = 4; +/** The rail's stable `data-test-id` hooks, as CSS selectors. */ +const hooks = { + viewport: `[data-test-id="${sourcesRailId}-viewport"]`, + track: `[data-test-id="${sourcesRailId}-track"]`, + card: `[data-test-id="${sourcesRailId}-card"]`, + cardLink: `[data-test-id="${sourcesRailId}-card-link"]`, +}; + +/** + * Sizes the sources rail's cards so the last one is half visible: the rail + * overflows by less than a card, and Chromium skips its own focus scroll for + * an element that already shows 32px or more. Returns the card width. + */ +async function makeLastCardHalfVisible(rail: Locator): Promise { + const width = await rail.evaluate( + (section, { count, selectors }) => { + const host = section.parentElement as HTMLElement; + const viewport = section.querySelector( + selectors.viewport + ) as HTMLElement; + const track = section.querySelector(selectors.track) as HTMLElement; + const gap = parseFloat(getComputedStyle(track).columnGap) || 0; + // count cards + (count - 1) gaps = viewport + half a card. + const cardWidth = Math.floor( + (viewport.clientWidth - gap * (count - 1)) / (count - 0.5) + ); + host.style.setProperty('--cover-rail-width', `${cardWidth}px`); + track.scrollTo({ left: 0, behavior: 'auto' }); + return cardWidth; + }, + { count: sourceCount, selectors: hooks } + ); + await expect + .poll(() => lastCardVisibleWidth(rail)) + .toBeGreaterThan(Math.min(64, width / 4)); + return width; +} + +/** Pixels of the last card inside the rail's visible viewport. */ +function lastCardVisibleWidth(rail: Locator): Promise { + return rail.evaluate((section, selectors) => { + const viewport = section + .querySelector(selectors.viewport) + ?.getBoundingClientRect(); + const cards = section.querySelectorAll(selectors.card); + const card = cards[cards.length - 1]?.getBoundingClientRect(); + if (!viewport || !card) return 0; + return ( + Math.min(card.right, viewport.right) - + Math.max(card.left, viewport.left) + ); + }, hooks); +} + +async function lastCardFullyVisible(rail: Locator): Promise { + return rail.evaluate((section, selectors) => { + const viewport = section + .querySelector(selectors.viewport) + ?.getBoundingClientRect(); + const cards = section.querySelectorAll(selectors.card); + const card = cards[cards.length - 1]?.getBoundingClientRect(); + if (!viewport || !card) return false; + return ( + card.left >= viewport.left - 1 && card.right <= viewport.right + 1 + ); + }, hooks); +} + +function lastCardLinkFocused(page: Page): Promise { + return page.evaluate((selector) => { + const links = document.querySelectorAll(selector); + return document.activeElement === links[links.length - 1]; + }, hooks.cardLink); +} + +test.describe('Dashboard rail focus', () => { + test('reveals a partly hidden card on keyboard and programmatic focus, and keeps mouse clicks on it', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + for (let index = 1; index <= sourceCount; index++) { + const filePath = writeTemporaryM3uFile( + dataDir, + `rail-focus-source-${index}.m3u`, + [ + { + groupTitle: 'News', + name: `Rail Focus Channel ${index}`, + url: `https://streams.example.test/rail-${index}.m3u8`, + }, + ] + ); + await importM3uPlaylistFromNativeDialog(app, filePath); + } + + await goToDashboard(app.mainWindow); + const rail = app.mainWindow.getByTestId(sourcesRailId); + await expect(rail.locator(hooks.card)).toHaveCount(sourceCount); + const cardLinks = rail.locator(hooks.cardLink); + + // Tab from the first card to the last card's link. + await makeLastCardHalfVisible(rail); + await cardLinks.first().focus(); + for ( + let presses = 0; + presses < sourceCount * 3 && + !(await lastCardLinkFocused(app.mainWindow)); + presses++ + ) { + await app.mainWindow.keyboard.press('Tab'); + } + expect(await lastCardLinkFocused(app.mainWindow)).toBe(true); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(true); + + // `focus()` from script after a mouse click elsewhere, starting + // at the rail's start again. + await rail.getByRole('heading').click(); + await rail.evaluate((section, selector) => { + (document.activeElement as HTMLElement | null)?.blur(); + section + .querySelector(selector) + ?.scrollTo({ left: 0, behavior: 'auto' }); + }, hooks.track); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); + await cardLinks.last().evaluate((link) => { + (link as HTMLElement).focus(); + }); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(true); + + // A mouse press focuses the link too; the rail must not slide it + // away from under the pointer before the click lands. + await rail.evaluate((section, selector) => { + (document.activeElement as HTMLElement | null)?.blur(); + section + .querySelector(selector) + ?.scrollTo({ left: 0, behavior: 'auto' }); + }, hooks.track); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); + const box = await cardLinks.last().boundingBox(); + expect(box).not.toBeNull(); + await app.mainWindow.mouse.move( + (box?.x ?? 0) + 24, + (box?.y ?? 0) + (box?.height ?? 0) / 2 + ); + await app.mainWindow.mouse.down(); + expect(await lastCardLinkFocused(app.mainWindow)).toBe(true); + expect( + await rail + .locator(hooks.track) + .evaluate((track) => track.scrollLeft) + ).toBe(0); + await app.mainWindow.mouse.up(); + await expect(app.mainWindow).not.toHaveURL( + /\/workspace\/dashboard$/ + ); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts b/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts deleted file mode 100644 index 07d43b8db..000000000 --- a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts +++ /dev/null @@ -1,343 +0,0 @@ -import type { Locator, Page } from '@playwright/test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { - addXtreamPortal, - clickFirstGridListCard, - closeElectronApp, - expect, - launchElectronApp, - openSettings, - resetMockServers, - saveSettings, - test, - waitForXtreamWorkspaceReady, -} from './electron-test-fixtures'; - -// --------------------------------------------------------------------------- -// The detail shell's sticky Back control floats over its scroll owner. While -// it is shown, every content column reserves the control's lane, so no -// scroll position may put it over the "Episodes" heading or the inline -// player's top-left corner. Tested at a wide and a narrow window; the -// workspace rail and category panel leave the detail pane far narrower than -// either, and the heading used to wrap beside its actions (two lines at -// 1280px, three at 780px) with its first word under the arrow. -// -// A pane under 400px cannot spare the lane: a 700px window beside the -// category panel, or a phone. There the control sits in an opaque sticky bar, -// and whatever reaches the arrow's band must be hidden beneath the bar, never -// visible beside the arrow. -// --------------------------------------------------------------------------- - -const widths = [1280, 780]; -const compactWidths = [700, 375]; -const playerCorner = 56; -/** The widest translation of the heading; it must fit wherever English does. */ -const widestLocale = 'nl'; -const widestHeading = ( - JSON.parse( - readFileSync( - join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`), - 'utf8' - ) - ) as { PORTALS: { DETAIL: { EPISODES: string } } } -).PORTALS.DETAIL.EPISODES; - -type Sweep = { - overlaps: string[]; - /** Scroll positions where the target shared the arrow's vertical band. */ - beside: { player: number; title: number }; -}; - -/** - * Scrolls the shell from top to bottom in steps shorter than the arrow and - * records every position where the arrow's box intersects the heading or the - * player's top-left corner. Reads layout synchronously after each scroll - * write, so a hidden or throttled window cannot skip frames. - */ -function sweepDetail(shell: Locator): Promise { - return shell.evaluate((element, corner) => { - const back = element.querySelector('.shell__back-button'); - if (!back) throw new Error('The detail shows no Back control.'); - const sweep = { - overlaps: [] as string[], - beside: { player: 0, title: 0 }, - }; - const max = element.scrollHeight - element.clientHeight; - for (let top = 0; ; top = Math.min(max, top + 24)) { - element.scrollTop = top; - const arrow = back.getBoundingClientRect(); - const title = element - .querySelector('[data-test-id="episodes-heading"]') - ?.getBoundingClientRect(); - const player = element - .querySelector('app-portal-inline-player') - ?.getBoundingClientRect(); - const targets = { - title, - player: player && { - left: player.left, - top: player.top, - right: player.left + corner, - bottom: player.top + corner, - }, - }; - for (const [name, box] of Object.entries(targets)) { - if (!box) continue; - const sameBand = - box.top < arrow.bottom && arrow.top < box.bottom; - if (!sameBand) continue; - sweep.beside[name as keyof Sweep['beside']] += 1; - if (box.left < arrow.right && arrow.left < box.right) { - sweep.overlaps.push(`${name} at scrollTop ${top}`); - } - } - if (top >= max) break; - } - element.scrollTop = 0; - return sweep; - }, playerCorner); -} - -type BarSweep = { - background: string; - /** Scroll positions where a target reached the bar's band. */ - beneath: number; - /** Points of a target in the bar's band that hit content, not the bar. */ - exposed: string[]; -}; - -/** - * Compact counterpart of {@link sweepDetail}: the bar sits in flow and - * content scrolls beneath it, so boxes do intersect. Instead, every sampled - * point of the heading or the player's corner that is inside the bar's band - * must hit-test to the bar: hidden beneath it, and unreachable by a tap. - */ -function sweepBar(shell: Locator): Promise { - return shell.evaluate((element, corner) => { - const bar = element.querySelector('.shell__navigation'); - if (!bar) throw new Error('The detail shows no Back control.'); - const sweep = { - background: getComputedStyle(bar).backgroundColor, - beneath: 0, - exposed: [] as string[], - }; - const max = element.scrollHeight - element.clientHeight; - for (let top = 0; ; top = Math.min(max, top + 24)) { - element.scrollTop = top; - const band = bar.getBoundingClientRect(); - const title = element - .querySelector('[data-test-id="episodes-heading"]') - ?.getBoundingClientRect(); - const player = element - .querySelector('app-portal-inline-player') - ?.getBoundingClientRect(); - const targets = { - title, - player: player && { - left: player.left, - top: player.top, - right: player.left + corner, - bottom: player.top + corner, - }, - }; - for (const [name, box] of Object.entries(targets)) { - if (!box) continue; - const from = Math.max(box.top, band.top); - const to = Math.min(box.bottom, band.bottom); - if (to <= from) continue; - sweep.beneath += 1; - const y = (from + to) / 2; - for (const x of [box.left + 2, box.right - 2]) { - const hit = element.ownerDocument.elementFromPoint(x, y); - if (!hit || !bar.contains(hit)) { - sweep.exposed.push( - `${name} at scrollTop ${top} hits ${hit?.tagName}.${String(hit?.className)}` - ); - } - } - } - if (top >= max) break; - } - element.scrollTop = 0; - return sweep; - }, playerCorner); -} - -/** Line boxes of the heading's text; 1 means it did not wrap. */ -function headingLineCount(page: Page): Promise { - return page - .locator('[data-test-id="episodes-heading"]') - .evaluate((heading) => { - const range = document.createRange(); - range.selectNodeContents(heading); - const lines = [...range.getClientRects()] - .filter((rect) => rect.width > 0) - .map((rect) => Math.round(rect.top)); - return new Set(lines).size; - }); -} - -/** - * Lets the browse↔watch morph, the player's fade-in and the workspace's own - * transitions settle — crossing into the phone layout slides the category - * drawer out over the page for 200ms. Bounded, so a paused animation - * elsewhere cannot stall the test. - */ -async function settle(shell: Locator): Promise { - await shell.evaluate((element) => - Promise.race([ - Promise.all( - element.ownerDocument - .getAnimations() - .filter( - (animation) => - animation.effect?.getTiming().iterations !== - Infinity - ) - .map((animation) => - animation.finished.catch(() => undefined) - ) - ), - new Promise((resolve) => setTimeout(resolve, 2_000)), - ]) - ); -} - -async function expectBackClearOfContent( - page: Page, - state: 'browse' | 'watch' -): Promise { - const shell = page.locator('app-portal-detail-shell'); - for (const width of widths) { - await page.setViewportSize({ width, height: 800 }); - // Fail on the mode first: a lane assertion against the bar would - // only report confusing intersections. - await expect(shell, `${state} at ${width}px`).not.toHaveClass( - /shell-host--compact/ - ); - await settle(shell); - const sweep = await sweepDetail(shell); - - expect(sweep.overlaps, `${state} at ${width}px`).toEqual([]); - // The sweep has to carry the heading (and the player) past the arrow, - // or the empty overlap list proves nothing. - expect(sweep.beside.title, `${state} at ${width}px`).toBeGreaterThan(0); - if (state === 'watch') { - expect( - sweep.beside.player, - `${state} at ${width}px` - ).toBeGreaterThan(0); - } - expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1); - } - - for (const width of compactWidths) { - await page.setViewportSize({ width, height: 800 }); - await expect(shell, `${state} at ${width}px`).toHaveClass( - /shell-host--compact/ - ); - await settle(shell); - const sweep = await sweepBar(shell); - - expect(sweep.exposed, `${state} at ${width}px`).toEqual([]); - expect(sweep.beneath, `${state} at ${width}px`).toBeGreaterThan(0); - // Opaque, or what scrolls beneath would show through. - expect(sweep.background, `${state} at ${width}px`).toMatch(/^rgb\(/); - expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1); - } -} - -/** - * Re-checks the heading in the widest translation at the lane and bar widths. - * Below them (a ~220px header beside the category panel) a translation wider - * than the pane itself wraps by design rather than losing words to an - * ellipsis. - */ -async function expectWidestHeadingOnOneLine( - page: Page, - detailUrl: string -): Promise { - await page.setViewportSize({ width: widths[0], height: 800 }); - await openSettings(page); - await page.getByTestId('select-language').click(); - await page.getByTestId(widestLocale).click(); - await saveSettings(page); - await page.goBack(); - await page.waitForURL(detailUrl); - await expect(page.locator('[data-test-id="episodes-heading"]')).toHaveText( - widestHeading, - { - timeout: 20_000, - } - ); - for (const width of [...widths, ...compactWidths]) { - await page.setViewportSize({ width, height: 800 }); - expect( - await headingLineCount(page), - `${widestLocale} at ${width}px` - ).toBe(1); - } -} - -/** - * The actions move onto their own row before the heading wraps, at every - * pane width that can hold the heading at all — including the widths where - * the lane gives way to the bar. - */ -async function expectHeadingOnOneLine(page: Page): Promise { - const wrapped: number[] = []; - for (let width = 680; width <= 1600; width += 20) { - await page.setViewportSize({ width, height: 800 }); - if ((await headingLineCount(page)) !== 1) wrapped.push(width); - } - expect(wrapped).toEqual([]); -} - -test.describe('Portal detail Back lane', () => { - test('@xtream @electron keeps the Back arrow off the heading and the player while scrolling', async ({ - dataDir, - request, - }) => { - await resetMockServers(request, ['xtream']); - const app = await launchElectronApp(dataDir); - - try { - const page = app.mainWindow; - await addXtreamPortal(page); - await waitForXtreamWorkspaceReady(page); - await page - .getByRole('link', { name: 'Series', exact: true }) - .click(); - await clickFirstGridListCard(page); - await page.waitForURL( - /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/ - ); - - const detailUrl = page.url(); - const shell = page.locator('app-portal-detail-shell'); - const episodes = page.locator('.episode-card'); - await expect( - page.locator('[data-test-id="episodes-heading"]') - ).toBeVisible({ - timeout: 20_000, - }); - await expect(episodes.first()).toBeVisible({ timeout: 20_000 }); - - await expectBackClearOfContent(page, 'browse'); - await expectHeadingOnOneLine(page); - - await page.setViewportSize({ width: widths[0], height: 800 }); - await episodes.first().click(); - await expect(shell).toHaveClass(/shell-host--watch/); - await expect( - shell.locator('app-portal-inline-player app-web-player-view') - ).toBeVisible({ timeout: 20_000 }); - - await expectBackClearOfContent(page, 'watch'); - await expectWidestHeadingOnOneLine(page, detailUrl); - } finally { - await closeElectronApp(app); - } - }); -}); diff --git a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts new file mode 100644 index 000000000..fbe3b4a73 --- /dev/null +++ b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts @@ -0,0 +1,283 @@ +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + addXtreamPortal, + clickFirstGridListCard, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + resetMockServers, + saveSettings, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +// --------------------------------------------------------------------------- +// A detail page's Back lives in the workspace header's leading slot, not in +// the page. Nothing floats over the scroll owner, so the content keeps its +// full width: every column has symmetric insets instead of a reserved Back +// lane. Checked at a wide and a narrow desktop window and at phone width, +// where Back takes the context drawer toggle's slot. +// +// The "Seasons and Episodes" heading must also stay on one line: the detail +// pane is far narrower than the window beside the rail and category panel, +// and the heading used to wrap beside its actions. +// --------------------------------------------------------------------------- + +const widths = [1280, 780, 375]; +const phoneWidth = 640; +/** The widest translation of the heading; it must fit wherever English does. */ +const widestLocale = 'nl'; +const widestHeading = ( + JSON.parse( + readFileSync( + join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`), + 'utf8' + ) + ) as { PORTALS: { SEASONS_AND_EPISODES: string } } +).PORTALS.SEASONS_AND_EPISODES; + +const detailUrlPattern = /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/; + +function headerBack(page: Page): Locator { + return page.getByTestId('workspace-header-back'); +} + +/** Line boxes of the heading's text; 1 means it did not wrap. */ +function headingLineCount(page: Page): Promise { + return page.locator('.section-title').evaluate((heading) => { + const range = document.createRange(); + range.selectNodeContents(heading); + const lines = [...range.getClientRects()] + .filter((rect) => rect.width > 0) + .map((rect) => Math.round(rect.top)); + return new Set(lines).size; + }); +} + +/** + * Columns whose start inset differs from their end inset. A reserved Back + * lane shows up here as a wider start inset. + */ +function asymmetricColumns(shell: Locator): Promise { + return shell.evaluate((element) => + [ + '.hero__content', + '.shell__player--active', + '.shell__episodes', + '.shell__extras', + 'app-content-about .about', + ].flatMap((selector) => { + const column = element.querySelector(selector); + if (!column || !column.getClientRects().length) return []; + const style = getComputedStyle(column); + return style.paddingLeft === style.paddingRight + ? [] + : [`${selector}: ${style.paddingLeft} / ${style.paddingRight}`]; + }) + ); +} + +/** + * Lets the browse↔watch morph, the player's fade-in and the workspace's own + * transitions settle — crossing into the phone layout slides the category + * drawer out over the page for 200ms. Bounded, so a paused animation + * elsewhere cannot stall the test. + */ +async function settle(shell: Locator): Promise { + await shell.evaluate((element) => + Promise.race([ + Promise.all( + element.ownerDocument + .getAnimations() + .filter( + (animation) => + animation.effect?.getTiming().iterations !== + Infinity + ) + .map((animation) => + animation.finished.catch(() => undefined) + ) + ), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]) + ); +} + +async function expectBackInHeader( + page: Page, + state: 'browse' | 'watch' +): Promise { + const shell = page.locator('app-portal-detail-shell'); + for (const width of widths) { + const label = `${state} at ${width}px`; + await page.setViewportSize({ width, height: 800 }); + await settle(shell); + + const back = headerBack(page); + await expect(back, label).toBeVisible(); + await expect(back, label).toHaveAccessibleName('Back'); + // Escape unwinds the page in browse; in watch it closes the player. + if (state === 'browse') { + await expect(back, label).toHaveAttribute( + 'aria-keyshortcuts', + 'Escape' + ); + } else { + await expect(back, label).not.toHaveAttribute('aria-keyshortcuts'); + } + await expect( + page + .locator('app-workspace-shell-header') + .getByTestId('workspace-header-back'), + label + ).toHaveCount(1); + // The page itself carries no second arrow. + await expect( + shell.getByRole('button', { name: 'Back', exact: true }), + label + ).toHaveCount(0); + expect(await asymmetricColumns(shell), label).toEqual([]); + // On a phone, Back takes the drawer toggle's slot in the header. + await expect( + page.getByTestId('context-drawer-toggle'), + label + ).toHaveCount(0); + if (width <= phoneWidth) { + const [backBox, switcherBox] = await Promise.all([ + back.boundingBox(), + page.locator('app-playlist-switcher').boundingBox(), + ]); + expect(backBox?.x ?? Infinity, label).toBeLessThan( + switcherBox?.x ?? -Infinity + ); + } + expect(await headingLineCount(page), label).toBe(1); + } +} + +/** + * Re-checks the heading in the widest translation. Below these widths (a + * ~220px header beside the category panel) a translation wider than the pane + * itself wraps by design rather than losing words to an ellipsis. + */ +async function expectWidestHeadingOnOneLine( + page: Page, + detailUrl: string +): Promise { + await page.setViewportSize({ width: widths[0], height: 800 }); + await openSettings(page); + await page.getByTestId('select-language').click(); + await page.getByTestId(widestLocale).click(); + await saveSettings(page); + await page.goBack(); + await page.waitForURL(detailUrl); + await expect(page.locator('.section-title')).toHaveText(widestHeading, { + timeout: 20_000, + }); + for (const width of [...widths, 700]) { + await page.setViewportSize({ width, height: 800 }); + expect( + await headingLineCount(page), + `${widestLocale} at ${width}px` + ).toBe(1); + } +} + +/** + * The actions move onto their own row before the heading wraps, at every + * pane width that can hold the heading at all. + */ +async function expectHeadingOnOneLine(page: Page): Promise { + const wrapped: number[] = []; + for (let width = 680; width <= 1600; width += 20) { + await page.setViewportSize({ width, height: 800 }); + if ((await headingLineCount(page)) !== 1) wrapped.push(width); + } + expect(wrapped).toEqual([]); +} + +async function openFirstSeries(page: Page): Promise { + await page.getByRole('link', { name: 'Series', exact: true }).click(); + await clickFirstGridListCard(page); + await page.waitForURL(detailUrlPattern); + await expect(page.locator('.section-title')).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator('.episode-card').first()).toBeVisible({ + timeout: 20_000, + }); + return page.url(); +} + +async function startFirstEpisode(page: Page): Promise { + const shell = page.locator('app-portal-detail-shell'); + await page.locator('.episode-card').first().click(); + await expect(shell).toHaveClass(/shell-host--watch/); + await expect( + shell.locator('app-portal-inline-player app-web-player-view') + ).toBeVisible({ timeout: 20_000 }); +} + +/** The header Back leaves the detail and is gone from the list it opens. */ +async function expectHeaderBackReturnsToList(page: Page): Promise { + await headerBack(page).click(); + await expect(page).not.toHaveURL(detailUrlPattern); + await expect(page.locator('app-portal-detail-shell')).toHaveCount(0); + await expect(headerBack(page)).toHaveCount(0); +} + +test.describe('Portal detail header Back', () => { + test('@xtream @electron keeps Back in the header and the detail columns at full width', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await expect(headerBack(page)).toHaveCount(0); + + const detailUrl = await openFirstSeries(page); + await expectBackInHeader(page, 'browse'); + await expectHeadingOnOneLine(page); + + await page.setViewportSize({ width: widths[0], height: 800 }); + await startFirstEpisode(page); + await expectBackInHeader(page, 'watch'); + await expectWidestHeadingOnOneLine(page, detailUrl); + } finally { + await closeElectronApp(app); + } + }); + + test('@xtream @electron returns to the list from the header in browse and watch', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + + await openFirstSeries(page); + await expectHeaderBackReturnsToList(page); + + await clickFirstGridListCard(page); + await page.waitForURL(detailUrlPattern); + await startFirstEpisode(page); + // Route-level in watch too: it leaves the page, not just the player. + await expectHeaderBackReturnsToList(page); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index fa6154625..01295d7c6 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1013,7 +1013,7 @@ export async function saveSettings(page: Page): Promise { export async function goToDashboard(page: Page): Promise { const dashboardLink = page - .locator('a.brand[href$="/workspace/dashboard"]') + .locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]') .first(); await expect(dashboardLink).toBeVisible(); @@ -1266,7 +1266,7 @@ export async function clearCurrentUnifiedCollection(page: Page): Promise { const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: /^Yes$/i }).click(); + await dialog.getByRole('button', { name: 'Clear', exact: true }).click(); await expect(dialog).toBeHidden(); } @@ -1664,7 +1664,7 @@ export async function deleteSource(page: Page, title: string): Promise { await expect(row).toBeVisible(); await row.locator('.delete-btn').click(); - await confirmDialog(page); + await confirmDialog(page, 'Remove playlist'); } export async function refreshSource( @@ -1681,7 +1681,7 @@ export async function refreshSource( await row.locator('.refresh-btn').click(); if (confirm) { - await confirmDialog(page); + await confirmDialog(page, 'Refresh playlist'); } } @@ -2098,7 +2098,7 @@ async function openCommandPalette(page: Page): Promise { return dialog; } -async function confirmDialog(page: Page, buttonLabel = 'Yes'): Promise { +async function confirmDialog(page: Page, buttonLabel: string): Promise { const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index a1b82b105..3a0bf7dfd 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -606,12 +606,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -637,9 +634,9 @@ async function expectInlineCollectionDetail( ); await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); - await expect( - page.locator('app-portal-detail-shell .shell__back-button').first() - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('workspace-header-back')).toBeVisible({ + timeout: 20000, + }); } async function expectInlinePlayerWithoutDialog(page: Page): Promise { diff --git a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts index 704a8b920..40084c0a6 100644 --- a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts +++ b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts @@ -163,7 +163,10 @@ test.describe('Live channel rail collapse (#1458)', () => { await app.mainWindow .getByRole('button', { name: 'Clear recently viewed Live TV' }) .click(); - await app.mainWindow.getByRole('button', { name: 'Yes' }).click(); + await app.mainWindow + .locator('mat-dialog-container') + .getByRole('button', { name: 'Clear', exact: true }) + .click(); await expect( channelItemByTitle(app.mainWindow, 'Channel Alpha') ).toHaveCount(0); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts index 2b0543746..31782aebc 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -35,7 +35,8 @@ describe('Xtream scenario driver source wiring', () => { "'app-playlist-item'", "'.refresh-btn'", "'.delete-btn'", - "'Yes'", + "'Refresh playlist'", + "'Remove playlist'", "'.busy-state__message'", "'.action-spinner'", "'.cancel-btn'", diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts index fdd9df3b2..17e187503 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts @@ -157,10 +157,15 @@ describe('Xtream Playwright scenario driver', () => { }); it('does not require a misleading portal-status badge before refresh or delete', async () => { - for (const [scenarioId, selector] of [ - [XTREAM_SCENARIO_ID.REFRESH_LARGE, '.refresh-btn'], - [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn'], + for (const [scenarioId, selector, confirmLabel] of [ + [ + XTREAM_SCENARIO_ID.REFRESH_LARGE, + '.refresh-btn', + 'Refresh playlist', + ], + [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'], ] as const) { + const confirmClick = `name=${confirmLabel};exact=true`; const fake = new FakePage(); const prepared = await prepareXtreamScenario( fake.asPage(), @@ -168,7 +173,7 @@ describe('Xtream Playwright scenario driver', () => { ); assert.equal(fake.clicksMatching(selector), 1); - assert.equal(fake.clicksMatching('name=Yes;exact=true'), 0); + assert.equal(fake.clicksMatching(confirmClick), 0); const portalReadyIndex = fake.log.findIndex((entry) => entry.includes('[aria-label="Portal status: active"]:visible') ); @@ -179,7 +184,7 @@ describe('Xtream Playwright scenario driver', () => { assert.equal(portalReadyIndex, -1); assert.ok(sourceActionIndex >= 0); await prepared.trigger(); - assert.equal(fake.clicksMatching('name=Yes;exact=true'), 1); + assert.equal(fake.clicksMatching(confirmClick), 1); } }); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts index 457ee6554..e2c7b83e0 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts @@ -83,7 +83,10 @@ export async function prepareXtreamSourceActionTrigger( const dialog = page.locator('mat-dialog-container').last(); await dialog.waitFor({ state: 'visible' }); const confirmation = dialog - .getByRole('button', { name: 'Yes', exact: true }) + .getByRole('button', { + name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist', + exact: true, + }) .last(); await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`); return confirmation; diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts index 5d382c55c..ed920dc3d 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts @@ -87,7 +87,7 @@ export async function runXtreamBackgroundUiActions( async () => { const start = await clickSelector( page, - 'a.brand[href$="/workspace/dashboard"]' + 'app-workspace-shell-rail a[href$="/workspace/dashboard"]' ); await page.waitForFunction(() => { const overlay = document.querySelector( diff --git a/apps/electron-backend-e2e/src/providers.e2e.ts b/apps/electron-backend-e2e/src/providers.e2e.ts index e414cf818..af3ca8cab 100644 --- a/apps/electron-backend-e2e/src/providers.e2e.ts +++ b/apps/electron-backend-e2e/src/providers.e2e.ts @@ -245,7 +245,9 @@ async function refreshRecentXtreamSourceFromDashboard( const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); - await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); + await dialog + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .click(); const refreshOverlay = page.locator('app-workspace-shell-import-overlay'); await expect(refreshOverlay).toBeVisible({ timeout: 5000 }); diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index a82a4f086..c313acb6e 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -676,7 +676,10 @@ async function clearRecentItems(page: Page, typeLabel: string): Promise { await page .getByRole('button', { name: `Clear recently viewed ${typeLabel}` }) .click(); - await page.getByRole('button', { name: 'Yes' }).click(); + await page + .locator('mat-dialog-container') + .getByRole('button', { name: 'Clear', exact: true }) + .click(); } async function expectUnifiedLiveDetailOpen( @@ -694,12 +697,9 @@ async function expectUnifiedLiveDetailOpen( } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -725,9 +725,9 @@ async function expectInlineCollectionDetail( ); await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); - await expect( - page.locator('app-portal-detail-shell .shell__back-button').first() - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('workspace-header-back')).toBeVisible({ + timeout: 20000, + }); } async function expectInlinePlayerWithoutDialog(page: Page): Promise { diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index b9bdee229..257c903f3 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -1540,12 +1540,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); await backButton.click(); diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 4e70c10a6..8784a7d09 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -657,10 +657,11 @@ test.describe('Electron Settings', () => { exact: true, }) ).toHaveCount(0); - await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute( - 'href', - /\/workspace\/sources$/ - ); + await expect( + secondLaunch.mainWindow + .locator('app-workspace-shell-rail a.nav-item') + .first() + ).toHaveAttribute('href', /\/workspace\/sources$/); } finally { await closeElectronApp(secondLaunch); } diff --git a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts index bcadf0f9d..6782598e9 100644 --- a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -4,6 +4,7 @@ import { expect, launchElectronApp, openSettings, + openSettingsSection, test, } from './electron-test-fixtures'; import { applyTheme } from './theme-contrast'; @@ -193,4 +194,55 @@ test.describe('Theme tokens', () => { await closeElectronApp(app); } }); + + test('@theme @electron destructive actions name the action and use the error color', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + await openSettings(page); + await openSettingsSection(page, 'epg'); + + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + const error = await resolveColor(page, 'var(--mat-sys-error)'); + const trigger = page.getByRole('button', { + name: 'Clear EPG data', + exact: true, + }); + // The `warn` color input was a no-op with M3: the trigger + // rendered in the primary color. + await expect + .poll(() => + trigger.evaluate((el) => getComputedStyle(el).color) + ) + .toBe(error); + await trigger.click(); + + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('button', { name: 'Yes' }) + ).toHaveCount(0); + const confirm = dialog.getByTestId('confirm-dialog-confirm'); + await expect(confirm).toHaveText('Clear EPG data'); + await expect(confirm).toHaveClass(/app-destructive-button/); + await expect + .poll(() => + confirm.evaluate( + (el) => getComputedStyle(el).backgroundColor + ) + ) + .toBe(error); + + await dialog + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + } + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index 1b25035fc..2f0cc03f0 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -11,6 +11,7 @@ import { waitForXtreamCatalog, xtreamMockServer, } from './electron-test-fixtures'; +import { applyTheme } from './theme-contrast'; const stressPortalName = 'Stress Xtream Portal'; const stressXtreamUsername = 'stress'; @@ -292,7 +293,7 @@ test.describe('Electron Xtream Responsiveness', () => { ); await expect(confirmDialog).toBeVisible(); await confirmDialog - .getByRole('button', { name: 'Yes', exact: true }) + .getByRole('button', { name: 'Remove playlist', exact: true }) .click(); await waitForDbOperationEvent(app.mainWindow, { @@ -304,7 +305,25 @@ test.describe('Electron Xtream Responsiveness', () => { await expect( playlistRow.locator('.busy-state__message') ).toBeVisible({ timeout: 20000 }); - await expect(playlistRow.locator('.cancel-btn')).toBeVisible(); + const cancel = playlistRow.locator('.cancel-btn'); + await expect(cancel).toBeVisible(); + // The row's action buttons inherit its color; the cancel action + // must still read as destructive on the selected row. + for (const theme of ['light', 'dark'] as const) { + await applyTheme(app.mainWindow, theme); + const error = await app.mainWindow.evaluate(() => { + const probe = document.createElement('div'); + probe.style.color = 'var(--mat-sys-error)'; + document.body.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }); + expect( + await cancel.evaluate((el) => getComputedStyle(el).color), + `cancel color in ${theme} theme` + ).toBe(error); + } await waitForDbOperationEvent(app.mainWindow, { operation: 'delete-playlist', diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index 046c784a6..97dd918f8 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -247,10 +247,10 @@ for (const theme of ['light', 'dark']) { await expect .poll(() => shell.evaluate((el) => el.scrollTop)) .toBeGreaterThan(0); - await page.keyboard.press('Tab'); - await expect( - shell.locator('.shell__back-button') - ).toBeFocused(); + // Back lives in the header, which never scrolls away. + const back = page.getByTestId('workspace-header-back'); + await expect(back).toBeInViewport(); + await back.focus(); await page.keyboard.press('Enter'); await expect(shell).toHaveCount(0); } diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index c7a23d639..7cb02e09f 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -345,17 +345,15 @@ test('@web @m3u @tmdb recognized movies open the VOD detail view', async ({ ).toEqual(['app-html-video-player']); // Metadata patches the mounted view asynchronously. The shell stamps the - // host templates into BOTH the hero and the watch-state About block, so - // every metadata string legitimately resolves twice. + // host templates into BOTH the hero and the watch-state About block, but + // the collapsed hero turns `visibility: hidden` 300ms after mount, + // sometimes before TMDB answers. Assert the copy a watching user sees. + const about = detail(page).locator('app-content-about'); await expect( - detail(page).getByText('Paul Atreides arrives on Arrakis.').first() - ).toBeVisible(); - await expect( - detail(page).getByText('Denis Villeneuve').first() - ).toBeVisible(); - await expect( - detail(page).getByText('Science Fiction').first() + about.getByText('Paul Atreides arrives on Arrakis.') ).toBeVisible(); + await expect(about.getByText('Denis Villeneuve')).toBeVisible(); + await expect(about.getByText('Science Fiction')).toBeVisible(); // A live channel keeps the classic layout. await sidebarEntry(page, 'Live One').click(); @@ -406,19 +404,19 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ ) ) .toBe(0.25); - // M3U has no browse Back target, so the shell shows no arrow in either + // M3U has no browse Back target, so the header shows no arrow in either // state; the now-playing bar's own Close button returns to browse. const shell = detail(page).locator('app-portal-detail-shell'); - await expect(shell.locator('.shell__back-button')).toHaveCount(0); + const headerBack = page.locator('[data-test-id="workspace-header-back"]'); + await expect(headerBack).toHaveCount(0); await shell .locator('app-portal-inline-player') .getByRole('button', { name: 'Close player', exact: true }) .click(); await expect(inlineVideo(page)).toHaveCount(0); - await expect(shell.locator('.shell__back-button')).toHaveCount(0); - // Without the arrow there is no lane to reserve: the hero keeps its own - // inset (32px, or 20px in a pane narrower than 760px) instead of the - // 72px column portal details give the Back control. + await expect(headerBack).toHaveCount(0); + // The hero keeps its own inset (32px, or 20px in a pane narrower than + // 760px) in both states. expect( await shell .locator('.hero__content') diff --git a/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts b/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts index c8fbdae50..4d92d7314 100644 --- a/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts +++ b/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts @@ -90,8 +90,7 @@ async function openUnsavedDialog(page: Page, locale: Locale): Promise { page.locator('[data-test-id="settings-unsaved-bar"]') ).toBeVisible(); - // The rail's Dashboard link stays visible in the phone top bar, unlike - // the brand link. + // The rail's Dashboard link stays visible in the phone top bar. await page .getByRole('navigation') .locator('a[href$="/workspace/dashboard"]') diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 9686a9939..a13489149 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -1156,7 +1156,11 @@ for (const theme of ['light', 'dark']) { .click(); await page.locator('app-grid-list mat-card').first().click(); const shell = page.locator('app-portal-detail-shell'); - await expect(shell).toBeVisible(); + // The loading skeleton is a scrollable shell of its own; a key + // sent to it is lost when the loaded shell replaces it. + await expect( + shell.getByRole('heading', { level: 1 }) + ).toBeVisible(); await page.evaluate( (dark) => document.body.classList.toggle('dark-theme', dark), theme === 'dark' @@ -1178,7 +1182,7 @@ for (const theme of ['light', 'dark']) { .toBeGreaterThan(0); }); - test(`@xtream sticky detail Back ${section} (${theme})`, async ({ + test(`@xtream header detail Back ${section} (${theme})`, async ({ page, }, testInfo) => { await page.setViewportSize({ width: 1200, height: 540 }); @@ -1196,30 +1200,28 @@ for (const theme of ['light', 'dark']) { (dark) => document.body.classList.toggle('dark-theme', dark), theme === 'dark' ); - const back = shell.getByRole('button', { - name: 'Back', - exact: true, - }); + // Back is the header's leading control, not a page overlay. + const back = page.locator( + 'app-workspace-shell-header [data-test-id="workspace-header-back"]' + ); await expect(back).toBeVisible(); - const backOffset = () => - back.evaluate((el) => { - const owner = el.closest('app-portal-detail-shell'); - return owner - ? el.getBoundingClientRect().top - - owner.getBoundingClientRect().top - : NaN; - }); - await expect.poll(backOffset).toBeCloseTo(16, 0); + await expect(back).toHaveAccessibleName('Back'); + await expect( + shell.getByRole('button', { name: 'Back', exact: true }) + ).toHaveCount(0); + const backTop = () => + back.evaluate((el) => el.getBoundingClientRect().top); + const initialTop = await backTop(); await page.keyboard.press('End'); await expect .poll(() => shell.evaluate((el) => el.scrollTop)) .toBeGreaterThan(0); await waitForScrollIdle(shell); - await expect.poll(backOffset).toBeCloseTo(16, 0); + expect(await backTop()).toBe(initialTop); await expect(back).toBeInViewport(); - await shell.screenshot({ + await page.screenshot({ path: testInfo.outputPath( - `sticky-back-${section}-${theme}.png` + `header-back-${section}-${theme}.png` ), }); await back.click(); @@ -1230,7 +1232,8 @@ for (const theme of ['light', 'dark']) { ).toBeVisible(); await shell.focus(); await page.keyboard.press('End'); - // Hover/focus must not make a tooltip consume the advertised Esc. + // Hover/focus must not make a tooltip consume the advertised Esc, + // even with focus on the header rather than inside the page. await page.clock.install(); await back.focus(); await back.hover(); diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html index db5e7c365..f9e84765e 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.html @@ -49,7 +49,11 @@ - diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts index 759812dd1..87dd57aa2 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts @@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; +import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces'; export interface SettingsDeleteAllPlaylistsDialogData { summary: { @@ -48,19 +49,19 @@ export class SettingsDeleteAllPlaylistsDialogComponent { { id: 'm3u', count: this.dialogData.summary.m3u, - icon: 'playlist_play', + icon: SOURCE_TYPE_ICONS.m3u, labelKey: 'HOME.PLAYLIST_TYPES.M3U', }, { id: 'xtream', count: this.dialogData.summary.xtream, - icon: 'cloud', + icon: SOURCE_TYPE_ICONS.xtream, labelKey: 'HOME.PLAYLIST_TYPES.XTREAM', }, { id: 'stalker', count: this.dialogData.summary.stalker, - icon: 'router', + icon: SOURCE_TYPE_ICONS.stalker, labelKey: 'HOME.PLAYLIST_TYPES.STALKER', }, ]); diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index b82aa1643..523e92004 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -217,7 +217,7 @@ - diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.spec.ts b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.spec.ts new file mode 100644 index 000000000..be17a85f6 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.spec.ts @@ -0,0 +1,156 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { provideRouter, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable, of } from 'rxjs'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, + SourceActivityService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { ConfirmDialogData, DialogService } from '@iptvnator/ui/components'; +import { PlaylistErrorViewComponent } from './playlist-error-view.component'; + +const XTREAM_PLAYLIST = { + _id: 'xtream-1', + title: 'Portal', + serverUrl: 'http://portal.test', + username: 'user', + password: 'pass', + importDate: '2026-09-30T10:00:00.000Z', +} as PlaylistMeta; + +describe('PlaylistErrorViewComponent removal', () => { + let activity: SourceActivityService; + let busyDuringDelete: boolean[]; + let deleteResult: Observable<{ success: boolean }>; + let playlistsService: { deletePlaylist: jest.Mock }; + let dialogService: { openConfirmDialog: jest.Mock }; + let store: { dispatch: jest.Mock }; + let snackBar: { open: jest.Mock }; + let navigate: jest.SpyInstance; + + function setup(): PlaylistErrorViewComponent { + busyDuringDelete = []; + deleteResult = of({ success: true }); + playlistsService = { + deletePlaylist: jest.fn((id: string) => { + busyDuringDelete.push(activity.isBusy(id)); + return deleteResult; + }), + }; + dialogService = { openConfirmDialog: jest.fn() }; + store = { dispatch: jest.fn() }; + snackBar = { open: jest.fn() }; + + TestBed.configureTestingModule({ + imports: [PlaylistErrorViewComponent, TranslateModule.forRoot()], + providers: [ + { + provide: PlaylistContextFacade, + useValue: { activePlaylist: signal(XTREAM_PLAYLIST) }, + }, + { provide: PlaylistsService, useValue: playlistsService }, + { + provide: DatabaseService, + useValue: { createOperationId: () => 'op-1' }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsXtreamSqliteDataSource: true }, + }, + { provide: DialogService, useValue: dialogService }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: MatSnackBar, useValue: snackBar }, + { provide: Store, useValue: store }, + provideRouter([]), + ], + }); + activity = TestBed.inject(SourceActivityService); + navigate = jest + .spyOn(TestBed.inject(Router), 'navigate') + .mockResolvedValue(true); + return TestBed.createComponent(PlaylistErrorViewComponent) + .componentInstance; + } + + function confirmRemoval(component: PlaylistErrorViewComponent) { + component.removeClicked(); + const data = dialogService.openConfirmDialog.mock + .calls[0][0] as ConfirmDialogData; + data.onConfirm(); + return data; + } + + it('asks with a named destructive action', () => { + const component = setup(); + component.removeClicked(); + + expect(dialogService.openConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + confirmLabel: 'HOME.PLAYLISTS.REMOVE', + tone: 'destructive', + }) + ); + }); + + it('removes through the shared delete action: busy source, Xtream cache, commit, toast', async () => { + const component = setup(); + confirmRemoval(component); + + await new Promise((resolve) => setTimeout(resolve)); + + // The worker delete (with an operation id) drops the Xtream cache. + expect(playlistsService.deletePlaylist).toHaveBeenCalledWith( + 'xtream-1', + expect.objectContaining({ operationId: 'op-1' }) + ); + // The source is marked busy while it is deleted, then released. + expect(busyDuringDelete).toEqual([true]); + expect(activity.isBusy('xtream-1')).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.playlistRemovalCommitted({ + playlistId: 'xtream-1', + }) + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS', + undefined, + { duration: 2000 } + ); + expect(navigate).toHaveBeenCalledWith(['/']); + }); + + it('keeps the playlist and stays put when the delete fails', async () => { + const component = setup(); + deleteResult = new Observable((subscriber) => + subscriber.error(new Error('worker failed')) + ); + + await component.removePlaylist(XTREAM_PLAYLIST); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + expect(activity.isBusy('xtream-1')).toBe(false); + }); + + it('does not start a second removal while the source is busy', async () => { + const component = setup(); + const release = activity.begin(['xtream-1']); + + component.removeClicked(); + await component.removePlaylist(XTREAM_PLAYLIST); + + expect(dialogService.openConfirmDialog).not.toHaveBeenCalled(); + expect(playlistsService.deletePlaylist).not.toHaveBeenCalled(); + release(); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts index aa12df8d7..cae2b8f39 100644 --- a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts @@ -7,13 +7,19 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + PlaylistDeleteActionService, + SourceActivityService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { DialogService } from '@iptvnator/ui/components'; -import { PlaylistActions } from '@iptvnator/m3u-state'; @Component({ selector: 'app-playlist-error-view', @@ -25,8 +31,11 @@ import { PlaylistActions } from '@iptvnator/m3u-state'; export class PlaylistErrorViewComponent { private dialog = inject(MatDialog); private dialogService = inject(DialogService); + private readonly activity = inject(SourceActivityService); private readonly playlistContext = inject(PlaylistContextFacade); + private readonly playlistDeleteAction = inject(PlaylistDeleteActionService); private router = inject(Router); + private readonly snackBar = inject(MatSnackBar); private store = inject(Store); private translate = inject(TranslateService); @@ -48,7 +57,10 @@ export class PlaylistErrorViewComponent { removeClicked(): void { const currentPlaylist = this.currentPlaylist(); - if (!currentPlaylist?._id) { + if ( + !currentPlaylist?._id || + this.activity.isBusy(currentPlaylist._id) + ) { return; } @@ -57,12 +69,38 @@ export class PlaylistErrorViewComponent { message: this.translate.instant( 'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE' ), - onConfirm: (): void => this.removePlaylist(currentPlaylist._id), + confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'), + tone: 'destructive', + onConfirm: (): void => void this.removePlaylist(currentPlaylist), }); } - removePlaylist(playlistId: string): void { - this.store.dispatch(PlaylistActions.removePlaylist({ playlistId })); - this.router.navigate(['/']); + /** + * Same path as every other source removal: the shared delete action marks + * the source busy, lets persistence drop the Xtream cache and cleanups, + * and only a completed delete is committed to the store. + */ + async removePlaylist(playlist: PlaylistMeta): Promise { + if (this.activity.isBusy(playlist._id)) { + return; + } + + const deleted = + await this.playlistDeleteAction.deletePlaylist(playlist); + if (!deleted) { + return; + } + + this.store.dispatch( + PlaylistActions.playlistRemovalCommitted({ + playlistId: playlist._id, + }) + ); + this.snackBar.open( + this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'), + undefined, + { duration: 2000 } + ); + void this.router.navigate(['/']); } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts index b86701537..d32f8ce18 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-clear-action.ts @@ -67,6 +67,8 @@ export function createClearCollectionAction(options: { dialogService.openConfirmDialog({ title: translate.instant(titleKey, { type }), message: translate.instant(messageKey, { type }), + confirmLabel: translate.instant('CLEAR'), + tone: 'destructive', onConfirm: async () => { if (isFavorites) { await clearFavorites(itemsToRemove); diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index a5bc439df..7022c32f2 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -23,6 +23,7 @@ export * from './lib/category-sort-state.service'; export * from './lib/keyboard'; export * from './lib/keyboard-shortcuts'; export * from './lib/remote-channel-navigation'; +export * from './lib/workspace-back-target'; export * from './lib/workspace-header-context.service'; export * from './lib/workspace-view-command.types'; export * from './lib/workspace-view-command.service'; diff --git a/libs/portal/shared/util/src/lib/workspace-back-target.ts b/libs/portal/shared/util/src/lib/workspace-back-target.ts new file mode 100644 index 000000000..0c4ebcba1 --- /dev/null +++ b/libs/portal/shared/util/src/lib/workspace-back-target.ts @@ -0,0 +1,10 @@ +import { Signal } from '@angular/core'; + +/** A page's Back action, rendered in the workspace header's leading slot. */ +export interface WorkspaceBackTarget { + /** Accessible name and tooltip; null falls back to the generic "Back". */ + readonly label: Signal; + /** True while Escape on the page runs the same action. */ + readonly escapeShortcut: Signal; + run(): void; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 1b1e814dc..244131fc4 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -822,6 +822,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { message: this.translateService.instant( 'DOWNLOADS.CANCEL_CONFIRM_MESSAGE' ), + confirmLabel: this.translateService.instant( + 'DOWNLOADS.CANCEL_CONFIRM_TITLE' + ), + // "Cancel" next to "Cancel download" would read as the same action. + cancelLabel: this.translateService.instant('CLOSE'), + tone: 'destructive', onConfirm: () => void this.downloads.cancelActive(), }); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 29853f3e1..6e8ee1abf 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -47,6 +47,7 @@ export * from './lib/portal-debug.interface'; export * from './lib/playlist-display-label.util'; export * from './lib/portal-playback.interface'; export * from './lib/provider-import-detection.util'; +export * from './lib/source-type-icons.util'; export * from './lib/provider-overview.util'; export * from './lib/random-id.util'; export * from './lib/recording-metadata.interface'; diff --git a/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts b/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts new file mode 100644 index 000000000..e5c056835 --- /dev/null +++ b/libs/shared/interfaces/src/lib/source-type-icons.util.spec.ts @@ -0,0 +1,43 @@ +import { + getPlaylistSourceIcon, + resolvePlaylistSourceIconKey, + SOURCE_TYPE_ICONS, +} from './source-type-icons.util'; + +describe('source type icons', () => { + it('never lets one icon stand for two providers', () => { + const byProvider = { + xtream: [SOURCE_TYPE_ICONS.xtream], + stalker: [SOURCE_TYPE_ICONS.stalker], + m3u: [ + SOURCE_TYPE_ICONS.m3u, + SOURCE_TYPE_ICONS['m3u-url'], + SOURCE_TYPE_ICONS['m3u-local'], + SOURCE_TYPE_ICONS['m3u-text'], + ], + }; + const owners = new Map(); + for (const [provider, icons] of Object.entries(byProvider)) { + for (const icon of icons) { + expect(owners.get(icon) ?? provider).toBe(provider); + owners.set(icon, provider); + } + } + }); + + it('does not reuse the Dashboard rail icon for a provider', () => { + expect(Object.values(SOURCE_TYPE_ICONS)).not.toContain('dashboard'); + }); + + it.each([ + [{ macAddress: '00:1A:79:00:00:01', url: 'http://portal' }, 'stalker'], + [{ serverUrl: 'http://xtream' }, 'xtream'], + [{ url: 'http://list.m3u' }, 'm3u-url'], + [{}, 'm3u-local'], + ] as const)('resolves %o to %s', (playlist, expected) => { + expect(resolvePlaylistSourceIconKey(playlist)).toBe(expected); + expect(getPlaylistSourceIcon(playlist)).toBe( + SOURCE_TYPE_ICONS[expected] + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/source-type-icons.util.ts b/libs/shared/interfaces/src/lib/source-type-icons.util.ts new file mode 100644 index 000000000..90084b579 --- /dev/null +++ b/libs/shared/interfaces/src/lib/source-type-icons.util.ts @@ -0,0 +1,50 @@ +import type { PlaylistMeta } from './playlist-meta.type'; + +/** + * One Material icon ligature per source type, used everywhere a source is + * shown: the add dialog, provider auto-detection, the Sources list and its + * filters, the playlist switcher, dashboard cards, the command palette and + * the reset summaries. A provider must look the same on every screen, and no + * icon may stand for two providers. + * + * `m3u` is the provider family (filters and summaries that count every M3U + * source); the other M3U keys name how a single playlist was added. Stored + * playlists cannot tell a local file from pasted text, so both render as + * `m3u-local`; only the add dialog and auto-detection show `m3u-text`. + */ +export const SOURCE_TYPE_ICONS = { + m3u: 'playlist_play', + 'm3u-url': 'link', + 'm3u-local': 'description', + 'm3u-text': 'subject', + xtream: 'cloud', + stalker: 'cast', +} as const; + +export type SourceTypeIconKey = keyof typeof SOURCE_TYPE_ICONS; + +export type PlaylistSourceIconKey = Extract< + SourceTypeIconKey, + 'm3u-url' | 'm3u-local' | 'xtream' | 'stalker' +>; + +export function resolvePlaylistSourceIconKey( + playlist: Pick +): PlaylistSourceIconKey { + if (playlist.macAddress) { + return 'stalker'; + } + if (playlist.serverUrl) { + return 'xtream'; + } + if (playlist.url) { + return 'm3u-url'; + } + return 'm3u-local'; +} + +export function getPlaylistSourceIcon( + playlist: Pick +): string { + return SOURCE_TYPE_ICONS[resolvePlaylistSourceIconKey(playlist)]; +} diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html index e4be4b844..c6a8d16d3 100644 --- a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.html @@ -59,7 +59,11 @@ @if (currentMapping()) { - } diff --git a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts index 5efb3c49e..ed6e0b3f9 100644 --- a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts +++ b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts @@ -52,3 +52,49 @@ describe('ConfirmDialogComponent actions', () => { } ); }); + +describe('ConfirmDialogComponent labels and tone', () => { + async function render(data: Record) { + await TestBed.configureTestingModule({ + imports: [ + ConfirmDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + title: 'Remove playlist', + message: 'Delete it?', + confirmLabel: 'Remove playlist', + onConfirm: jest.fn(), + ...data, + }, + }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ConfirmDialogComponent); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll( + 'button' + ) as NodeListOf; + return { cancel: buttons[0], confirm: buttons[1] }; + } + + it('names the action instead of answering "Yes", and cancels by default', async () => { + const { cancel, confirm } = await render({}); + + expect(confirm.textContent?.trim()).toBe('Remove playlist'); + // No translations are loaded, so the key is the rendered text. + expect(cancel.textContent?.trim()).toBe('CANCEL'); + expect(confirm.classList).not.toContain('app-destructive-button'); + }); + + it('styles destructive confirmations as destructive', async () => { + const { confirm } = await render({ tone: 'destructive' }); + + expect(confirm.classList).toContain('app-destructive-button'); + }); +}); diff --git a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts index 28278b175..937c06e54 100644 --- a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts +++ b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts @@ -3,12 +3,25 @@ import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; +/** + * `destructive` renders the confirm action with the app-wide error styling + * (`.app-destructive-button`). Use it whenever confirming removes or discards + * user data; Material's `warn` color input has no effect with the M3 theme. + */ +export type ConfirmDialogTone = 'default' | 'destructive'; + export interface ConfirmDialogData { title: string; message: string; width?: string; - confirmLabel?: string; + /** + * Translated verb that names the action ("Remove playlist", "Clear"). + * Required so a confirmation never falls back to an ambiguous "Yes". + */ + confirmLabel: string; + /** Translated dismiss label; defaults to "Cancel". */ cancelLabel?: string; + tone?: ConfirmDialogTone; /** Run the action in the dialog; only Close/backdrop/Escape dismiss it. */ keepOpenOnConfirm?: boolean; onConfirm: () => void; @@ -27,23 +40,25 @@ export interface ConfirmDialogData { @if (dialogData.keepOpenOnConfirm) { } @else { } @@ -56,4 +71,8 @@ export class ConfirmDialogComponent { constructor() { this.dialogData = this.data; } + + get isDestructive(): boolean { + return this.dialogData.tone === 'destructive'; + } } diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts index 348d3ec79..3dda815f8 100644 --- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts +++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts @@ -33,6 +33,7 @@ describe('DialogService', () => { service.openConfirmDialog({ title: 'Remove', message: 'Confirm removal?', + confirmLabel: 'Remove playlist', onConfirm: jest.fn(), }); @@ -57,6 +58,7 @@ describe('DialogService', () => { service.openConfirmDialog({ title: 'Refresh Xtream Playlist', message: 'Confirm refresh?', + confirmLabel: 'Refresh playlist', width: '400px', onConfirm: jest.fn(), }); @@ -78,6 +80,7 @@ describe('DialogService', () => { service.openConfirmDialog({ title: 'Remove', message: 'Confirm removal?', + confirmLabel: 'Remove playlist', onConfirm, }); @@ -89,6 +92,7 @@ describe('DialogService', () => { service.openConfirmDialog({ title: 'Recovery', message: 'Path', + confirmLabel: 'Copy path', keepOpenOnConfirm: true, onConfirm, }); diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 553cbb452..2420745ec 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -157,8 +157,6 @@ max-width: var(--content-max-width); margin: 0 auto; padding: 48px 32px 24px; - // Reserved by the detail shell while its sticky Back control shows. - padding-inline-start: max(32px, var(--detail-back-lane, 0px)); box-sizing: border-box; &--skeleton { @@ -435,7 +433,6 @@ .hero__content { padding: 40px 20px 24px; - padding-inline-start: max(20px, var(--detail-back-lane, 0px)); } .details { @@ -450,7 +447,6 @@ @media (max-width: 640px) { .hero__content { padding-inline: 16px; - padding-inline-start: max(16px, var(--detail-back-lane, 0px)); } .details__title { diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss index ba11eeaa3..070f9d06f 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss @@ -6,8 +6,6 @@ max-width: var(--content-max-width, 1400px); margin: 0 auto; padding: 24px 40px 40px; - // The detail shell's sticky Back lane (0 when there is no Back). - padding-inline-start: max(40px, var(--detail-back-lane, 0px)); border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12)); &__heading { @@ -129,7 +127,6 @@ @media (max-width: 800px) { .about { padding: 20px 20px 32px; - padding-inline-start: max(20px, var(--detail-back-lane, 0px)); &__poster { width: 92px; diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html index c83eb682e..2153d8557 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html @@ -1,22 +1,3 @@ -@if (backAvailable()) { - @let label = backLabel() || ('BACK' | translate); - -
- -
-} -
diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss index eb696ba1d..ba3bc3715 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss @@ -13,13 +13,6 @@ --tag-border: color-mix(in srgb, var(--app-on-surface, #1d1b1e) 14%, transparent); --poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.3); --content-max-width: 1400px; - // The sticky Back control floats over the scroll owner. While it is shown, - // every content column (hero, player, episodes, About, extras) starts - // after its lane — inset + button + the same inset again — so nothing - // scrolls beneath it. content-hero and content-about read the lane too. - --shell-back-inset: 16px; - --shell-back-size: 40px; - --detail-back-lane: 0px; display: block; height: 100%; @@ -40,58 +33,10 @@ } } -:host(.shell-host--back) { - --detail-back-lane: calc( - 2 * var(--shell-back-inset) + var(--shell-back-size) - ); -} - :host-context(.dark-theme) { --poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.6); } -.shell__navigation { - // Direct child of the scroll owner: sticky lasts through the entire page. - position: sticky; - top: var(--shell-back-inset); - height: 0; - z-index: 10; - pointer-events: none; -} - -.shell__back-button { - position: absolute; - inset-inline-start: var(--shell-back-inset); - pointer-events: auto; - app-region: no-drag; - -webkit-app-region: no-drag; - display: flex; - align-items: center; - justify-content: center; - width: var(--shell-back-size); - height: var(--shell-back-size); - border-radius: 50%; - background: var(--app-widget-bg, var(--surface-bg)); - color: var(--app-on-surface, var(--text-primary)); - border: 1px solid var(--app-separator, var(--tag-border)); - cursor: pointer; - - &:hover { - background: var(--app-card-hover-bg, var(--surface-bg)); - } - - &:focus-visible { - outline: 2px solid var(--app-selection-color); - outline-offset: 2px; - } - - mat-icon { - font-size: 24px; - width: 24px; - height: 24px; - } -} - // ============================================================================ // Hero collapse (browse ↔ watch morph, ~300ms, no height measuring) // ============================================================================ @@ -132,7 +77,6 @@ &--active { padding: 16px 40px 0; - padding-inline-start: max(40px, var(--detail-back-lane)); animation: shell-fade-in 300ms ease 50ms both; } } @@ -141,7 +85,6 @@ max-width: var(--content-max-width); margin: 0 auto; padding: 0 32px; - padding-inline-start: max(32px, var(--detail-back-lane)); &:empty { display: none; @@ -162,7 +105,6 @@ max-width: var(--content-max-width); margin: 0 auto; padding: 8px 32px 40px; - padding-inline-start: max(32px, var(--detail-back-lane)); &:empty { display: none; @@ -183,38 +125,14 @@ @media (max-width: 800px) { .shell__player--active { padding: 8px 12px 0; - padding-inline-start: max(12px, var(--detail-back-lane)); } .shell__episodes { padding: 0 20px; - padding-inline-start: max(20px, var(--detail-back-lane)); } .shell__extras { padding: 0 20px 32px; - padding-inline-start: max(20px, var(--detail-back-lane)); - } -} - -// A pane too narrow to spare a 72px column (a phone, or a desktop pane beside -// the context panel; the component sets the class from the pane's width) -// would clip the player's controls. There the control takes a row instead — -// an opaque sticky bar in flow that content scrolls beneath, never alongside. -:host(.shell-host--compact) { - --detail-back-lane: 0px; - - .shell__navigation { - top: 0; - height: calc(var(--shell-back-size) + 16px); - background: var(--app-header-bg); - // The bar hides what scrolls beneath it, so it must not let taps - // through to that hidden content either. - pointer-events: auto; - } - - .shell__back-button { - top: 8px; } } diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts index 55e51eeda..3b540b449 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts @@ -1,6 +1,7 @@ import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -69,6 +70,9 @@ describe('PortalDetailShellComponent', () => { return element; }; + const backTarget = () => + TestBed.inject(WorkspaceBackNavigationService).target(); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [HostComponent, TranslateModule.forRoot()], @@ -130,13 +134,13 @@ describe('PortalDetailShellComponent', () => { expect(query('.details__meta .details__meta-item')).toBeTruthy(); expect(query('.action-buttons .play-btn')).toBeTruthy(); expect(query('app-content-about')).toBeNull(); - expect(query('.shell__back-button')?.getAttribute('aria-label')).toBe( - 'Return to downloads' - ); + // The header renders Back; the page itself keeps no arrow. + expect(query('button[aria-label="Return to downloads"]')).toBeNull(); + expect(backTarget()?.label()).toBe('Return to downloads'); }); it.each([{ isLoading: true }, { errorMessage: 'Unavailable' }])( - 'keeps the translated fallback Back available in loading/error states', + 'keeps the generic Back available in loading/error states', (state) => { fixture.destroy(); const shellFixture = TestBed.createComponent( @@ -145,16 +149,14 @@ describe('PortalDetailShellComponent', () => { for (const [key, value] of Object.entries(state)) shellFixture.componentRef.setInput(key, value); shellFixture.detectChanges(); - const element = shellFixture.nativeElement as HTMLElement; - const button = element.querySelector( - '.shell__back-button' - ); - expect(button?.type).toBe('button'); - expect(button?.getAttribute('aria-label')).toBe('Go back'); + // No host label: the header falls back to the translated "Back". + expect(backTarget()?.label()).toBeNull(); const back = jest.fn(); shellFixture.componentInstance.backClicked.subscribe(back); - button?.click(); + backTarget()?.run(); expect(back).toHaveBeenCalledTimes(1); + shellFixture.destroy(); + expect(backTarget()).toBeNull(); } ); @@ -277,30 +279,33 @@ describe('PortalDetailShellComponent', () => { expect(host.closeRequests).toBe(0); }); - it('keeps one route-back control outside the collapsing hero in both states', () => { - const back = requiredQuery('.shell__back-button'); - expect(back.closest('app-content-hero')).toBeNull(); - expect(back.getAttribute('aria-label')).toBe('Return to downloads'); - expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape'); - back.click(); + it('offers one route-level Back to the header in both states', () => { + const target = backTarget(); + expect(target?.label()).toBe('Return to downloads'); + expect(target?.escapeShortcut()).toBe(true); + target?.run(); expect(host.backRequests).toBe(1); host.playbackActive.set(true); fixture.detectChanges(); - expect(query('.shell__back-button')).toBe(back); + expect(backTarget()).toBe(target); // Watch keeps the arrow's meaning: it leaves the page, it does not // close the player. Escape is the close shortcut, so the hint goes. - expect(back.getAttribute('aria-label')).toBe('Return to downloads'); - expect(back.getAttribute('aria-keyshortcuts')).toBeNull(); - expect(back.getAttribute('title')).toBe('Return to downloads'); - back.click(); + expect(target?.escapeShortcut()).toBe(false); + target?.run(); expect(host.backRequests).toBe(2); expect(host.closeRequests).toBe(0); }); - it('moves lost focus to the back control after Escape closes the player', async () => { + it('releases the header Back when the page goes away', () => { + expect(backTarget()).not.toBeNull(); + fixture.destroy(); + expect(backTarget()).toBeNull(); + }); + + it('keeps focus on the page after Escape closes the player', async () => { host.playbackActive.set(true); fixture.detectChanges(); - const back = requiredQuery('.shell__back-button'); + const shell = requiredQuery('app-portal-detail-shell'); const player = requiredQuery('.fake-player'); player.tabIndex = 0; player.focus(); @@ -315,13 +320,13 @@ describe('PortalDetailShellComponent', () => { await fixture.whenStable(); expect(host.closeRequests).toBe(1); expect(host.backRequests).toBe(0); - expect(document.activeElement).toBe(back); + expect(document.activeElement).toBe(shell); }); it('has no dead-end browse action for a host without back navigation', () => { host.backAvailable.set(false); fixture.detectChanges(); - expect(query('.shell__back-button')).toBeNull(); + expect(backTarget()).toBeNull(); requiredQuery('app-portal-detail-shell').dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', @@ -334,7 +339,7 @@ describe('PortalDetailShellComponent', () => { fixture.detectChanges(); // No route to go back to, so no arrow in watch either; the player's // own Close button and Escape remain the exits. - expect(query('.shell__back-button')).toBeNull(); + expect(backTarget()).toBeNull(); requiredQuery('app-portal-detail-shell').dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', @@ -451,67 +456,3 @@ describe('PortalDetailShellComponent', () => { expect(host.closeRequests).toBe(0); }); }); - -describe('PortalDetailShellComponent pane width', () => { - const original = globalThis.ResizeObserver; - const disconnect = jest.fn(); - // The hero observes itself too, so keep each callback with its target. - let observers: { callback: ResizeObserverCallback; target?: Element }[]; - - beforeEach(() => { - observers = []; - disconnect.mockClear(); - globalThis.ResizeObserver = class { - private readonly entry: (typeof observers)[number]; - constructor(callback: ResizeObserverCallback) { - this.entry = { callback }; - observers.push(this.entry); - } - observe(target: Element): void { - this.entry.target = target; - } - unobserve = jest.fn(); - disconnect = disconnect; - } as unknown as typeof ResizeObserver; - TestBed.configureTestingModule({ - imports: [HostComponent, TranslateModule.forRoot()], - }); - }); - - afterEach(() => { - globalThis.ResizeObserver = original; - }); - - const resize = (shell: HTMLElement, inlineSize: number): void => { - const observer = observers.find(({ target }) => target === shell); - if (!observer) throw new Error('The shell does not observe itself.'); - observer.callback( - [ - { - borderBoxSize: [{ inlineSize, blockSize: 800 }], - } as unknown as ResizeObserverEntry, - ], - {} as ResizeObserver - ); - }; - - it('trades the Back lane for a bar when the pane itself is narrow', () => { - const fixture = TestBed.createComponent(HostComponent); - fixture.detectChanges(); - const shell = (fixture.nativeElement as HTMLElement).querySelector( - 'app-portal-detail-shell' - ) as HTMLElement; - - // A 780px window leaves the pane ~402px beside the context panel. - resize(shell, 402); - expect(shell.classList).not.toContain('shell-host--compact'); - // A 641px window leaves it ~260px: too narrow for the lane. - resize(shell, 260); - expect(shell.classList).toContain('shell-host--compact'); - resize(shell, 1280); - expect(shell.classList).not.toContain('shell-host--compact'); - - fixture.destroy(); - expect(disconnect).toHaveBeenCalled(); - }); -}); diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index dada488a8..f94266f4a 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -2,7 +2,6 @@ import { NgTemplateOutlet } from '@angular/common'; import { afterNextRender, Component, - DestroyRef, ElementRef, Injector, computed, @@ -11,11 +10,10 @@ import { inject, input, output, - viewChild, ChangeDetectionStrategy, } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import { TranslateModule } from '@ngx-translate/core'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; import { ContentHeroComponent } from '../content-hero/content-hero.component'; import { ContentAboutComponent } from './content-about.component'; import { @@ -24,14 +22,6 @@ import { DetailTagsTemplateDirective, } from './detail-template.directives'; -/** - * Below this pane width the Back lane (16 + 40 + 16px) would leave the player - * card under ~316px, where its control row clips, so the control takes a - * sticky bar instead. The pane decides, not the viewport: beside the context - * panel a desktop pane can be narrower than a phone. - */ -const COMPACT_SHELL_WIDTH = 400; - /** * Two-state layout shell for portal VOD/series detail pages. * @@ -41,22 +31,17 @@ const COMPACT_SHELL_WIDTH = 400; * About block below the episodes slot. * * The shell owns the page scroll, the browse↔watch animation, Escape - * handling, the one sticky Back control (route-level in both states; closing - * the player is the player's own Close button and Escape), and never - * conditionally wraps the `[detail-player]` slot — the - * host's own `@if (inlinePlayback())` is the only thing that creates or - * destroys the player, so shell state changes cannot recreate it. + * handling and the page's Back action, which the workspace header renders in + * its leading slot (route-level in both states; closing the player is the + * player's own Close button and Escape). It never conditionally wraps the + * `[detail-player]` slot — the host's own `@if (inlinePlayback())` is the + * only thing that creates or destroys the player, so shell state changes + * cannot recreate it. */ @Component({ selector: 'app-portal-detail-shell', standalone: true, - imports: [ - ContentHeroComponent, - ContentAboutComponent, - NgTemplateOutlet, - MatIconModule, - TranslateModule, - ], + imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet], templateUrl: './portal-detail-shell.component.html', styleUrls: ['./portal-detail-shell.component.scss'], // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. @@ -70,17 +55,13 @@ const COMPACT_SHELL_WIDTH = 400; // on body, while preserving already-handled events and overlay guards. '(keydown.escape)': 'onEscape($event)', '[class.shell-host--watch]': 'isWatch()', - // Content columns reserve the sticky Back control's lane. - '[class.shell-host--back]': 'backAvailable()', '(document:keydown.escape)': 'onEscape($event)', }, }) export class PortalDetailShellComponent { private readonly host = inject>(ElementRef); private readonly injector = inject(Injector); - private readonly destroyRef = inject(DestroyRef); - private readonly backButton = - viewChild>('backButton'); + private readonly backNavigation = inject(WorkspaceBackNavigationService); readonly title = input(); /** "Movie · playlist name" eyebrow above the hero title. */ @@ -100,7 +81,7 @@ export class PortalDetailShellComponent { /** True while inline playback is active — flips the layout to watch state. */ readonly playbackActive = input(false); - /** The sticky control in either state, or Escape in browse. */ + /** The header's Back in either state, or Escape in browse. */ readonly backClicked = output(); /** Emitted by Escape during inline playback. */ readonly closePlayerRequested = output(); @@ -113,6 +94,12 @@ export class PortalDetailShellComponent { readonly isWatch = computed(() => this.playbackActive()); + private readonly backTarget: WorkspaceBackTarget = { + label: computed(() => this.backLabel() || null), + escapeShortcut: computed(() => !this.isWatch()), + run: () => this.backClicked.emit(), + }; + constructor() { afterNextRender(() => { const element = this.host.nativeElement; @@ -126,7 +113,10 @@ export class PortalDetailShellComponent { element.focus({ preventScroll: true }); } }); - this.observeCompactWidth(); + effect((onCleanup) => { + if (!this.backAvailable()) return; + onCleanup(this.backNavigation.register(this.backTarget)); + }); let wasWatch = false; effect(() => { const watch = this.isWatch(); @@ -233,15 +223,15 @@ export class PortalDetailShellComponent { afterNextRender( () => { const element = this.host.nativeElement; + // The page keeps focus, so the next Escape still unwinds it + // and the arrow keys still scroll it. if ( element.isConnected && !element.closest('[inert]') && element.ownerDocument.activeElement === element.ownerDocument.body ) { - (this.backButton()?.nativeElement ?? element).focus({ - preventScroll: true, - }); + element.focus({ preventScroll: true }); } }, { injector: this.injector } @@ -260,24 +250,4 @@ export class PortalDetailShellComponent { } element.scrollTo({ top: 0, behavior: 'auto' }); } - - /** - * Toggles `shell-host--compact` straight on the host, so the class lands - * in the same frame as the resize instead of after a change-detection - * pass. The border box keeps the threshold independent of scrollbar width. - */ - private observeCompactWidth(): void { - if (typeof ResizeObserver === 'undefined') return; - const element = this.host.nativeElement; - const observer = new ResizeObserver(([entry]) => { - const width = - entry?.borderBoxSize?.[0]?.inlineSize ?? element.offsetWidth; - element.classList.toggle( - 'shell-host--compact', - width < COMPACT_SHELL_WIDTH - ); - }); - observer.observe(element); - this.destroyRef.onDestroy(() => observer.disconnect()); - } } 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 649460d5b..a600e1599 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 @@ -33,7 +33,11 @@ } -
+
+ } + - @if (showContextDrawerToggle()) { + it. A page with Back takes its slot (one navigation icon, as in + Material's top app bar); the list Back returns to shows it again. --> + @if (showContextDrawerToggle() && !backTarget()) {