diff --git a/.changes/playback-diagnostic-card-layout.md b/.changes/playback-diagnostic-card-layout.md new file mode 100644 index 000000000..68a28e78b --- /dev/null +++ b/.changes/playback-diagnostic-card-layout.md @@ -0,0 +1,9 @@ +--- +type: fix +area: playback +--- + +When a stream fails to play, the recovery cards such as "Open in MPV" now show +their full label in every language instead of cutting it off. The whole +message, including each card's status line, also fits in the smaller player +above the programme guide. A player too short for it scrolls from the top. diff --git a/apps/electron-backend-e2e/src/playback-diagnostic-layout.e2e.ts b/apps/electron-backend-e2e/src/playback-diagnostic-layout.e2e.ts new file mode 100644 index 000000000..f2d77abf9 --- /dev/null +++ b/apps/electron-backend-e2e/src/playback-diagnostic-layout.e2e.ts @@ -0,0 +1,303 @@ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Locator } from '@playwright/test'; +import { + buildM3uContent, + channelItemByTitle, + closeElectronApp, + createMutableTextServer, + expect, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + test, + workspaceRoot, +} from './electron-test-fixtures'; + +/** + * Layout of the playback diagnostic banner in the M3U inline player, which + * sits above the EPG panel and is about 400px tall in the default window. + * + * Each locale's panel text, card labels and "player started" status are + * written into the rendered banner instead of being reached through the + * cards: activating an external-player card can launch a real MPV or VLC + * installed on the machine. + */ + +const I18N_DIR = join(workspaceRoot, 'apps/web/src/assets/i18n'); + +/** Every label a recommendation card can show (playback-recommendation-view.util.ts). */ +const CARD_LABEL_KEYS = [ + 'ACTION_OPEN_MPV', + 'ACTION_OPEN_VLC', + 'ACTION_OPENING_MPV', + 'ACTION_OPENING_VLC', + 'ACTION_REOPEN_MPV', + 'ACTION_REOPEN_VLC', + 'ACTION_RETRY_MPV', + 'ACTION_RETRY_VLC', + 'ACTION_RETRY', + 'ACTION_TRY_PLAYER', +] as const; + +type LocaleTexts = { + locale: string; + /** Every label a card can show, in this locale. */ + labels: string[]; + status: string; + /** English panel strings paired with this locale's translation. */ + translations: [english: string, translated: string][]; +}; + +type CardLayout = { + /** Text nodes on the banner that have a translation key. */ + translatedNodes: number; + /** Labels whose text is wider than their box (ellipsized or overflowing). */ + truncated: string[]; + /** Texts whose banner content leaves the visible banner box. */ + clipped: string[]; + /** Texts whose content was taller than the player, so the banner scrolled. */ + scrolled: number; +}; + +/** The diagnostics strings of one locale file, keyed by their dotted path. */ +function readDiagnosticStrings(file: string): Map { + const strings = new Map(); + const collect = (value: unknown, path: string): void => { + if (typeof value === 'string') { + strings.set(path, value); + } else if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + collect(child, path ? `${path}.${key}` : key); + } + } + }; + collect( + JSON.parse(readFileSync(join(I18N_DIR, file), 'utf8')) + .PLAYBACK_DIAGNOSTICS, + '' + ); + return strings; +} + +function localeTexts(): LocaleTexts[] { + const english = readDiagnosticStrings('en.json'); + return readdirSync(I18N_DIR) + .filter((file) => file.endsWith('.json')) + .map((file) => { + const strings = readDiagnosticStrings(file); + return { + locale: file.replace(/\.json$/, ''), + labels: CARD_LABEL_KEYS.map((key) => + (strings.get(key) ?? '').replace('{{player}}', 'ArtPlayer') + ), + status: strings.get('EXTERNAL_STARTED') ?? '', + translations: [...english].map(([key, value]) => [ + value, + strings.get(key) ?? value, + ]), + }; + }); +} + +/** + * Translates the banner into each locale, puts every card label of that + * locale into both cards with a status line under the hint, and reports + * truncated labels and any content outside the banner's visible box at the + * top of the scroll range and, when the banner scrolls, at its end. + */ +async function measureLocales( + banner: Locator, + locales: LocaleTexts[] +): Promise { + return banner.evaluate((element, entries) => { + const cards = Array.from( + element.querySelectorAll( + '.web-player-diagnostic__player-card' + ) + ); + const labels = cards.map( + (card) => + card.querySelector( + '.web-player-diagnostic__player-label' + ) as HTMLElement + ); + const originalLabels = labels.map((label) => label.textContent); + // The rest of the banner's text, rendered in English by the app. + const panelText: { node: Text; english: string; original: string }[] = + []; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node as Text; + const english = text.data.trim(); + if (english && !labels.some((label) => label.contains(text))) { + panelText.push({ node: text, english, original: text.data }); + } + } + // A clone keeps the hint's encapsulation attribute, so the status + // class gets the panel's real status styles. + const statuses = cards.map((card) => { + const hint = card.querySelector( + '.web-player-diagnostic__player-hint' + ) as HTMLElement; + const status = hint.cloneNode(false) as HTMLElement; + status.className = 'web-player-diagnostic__player-status'; + hint.after(status); + return status; + }); + const content = element.querySelector( + '.web-player-diagnostic__content' + ) as HTMLElement; + const isInside = (): boolean => { + const box = element.getBoundingClientRect(); + const rect = content.getBoundingClientRect(); + return rect.top >= box.top - 0.5 && rect.bottom <= box.bottom + 0.5; + }; + const isLastLineVisible = (): boolean => { + const box = element.getBoundingClientRect(); + const last = content.lastElementChild as HTMLElement; + return last.getBoundingClientRect().bottom <= box.bottom + 0.5; + }; + + const layout = { + clipped: [] as string[], + scrolled: 0, + translatedNodes: Number.POSITIVE_INFINITY, + truncated: [] as string[], + }; + for (const entry of entries.flatMap((locale) => + locale.labels.map((label) => ({ ...locale, label })) + )) { + const translations = new Map(entry.translations); + let translatedNodes = 0; + for (const text of panelText) { + const translated = translations.get(text.english); + if (translated === undefined) continue; + text.node.data = translated; + translatedNodes += 1; + } + layout.translatedNodes = Math.min( + layout.translatedNodes, + translatedNodes + ); + const name = `${entry.locale}: ${entry.label}`; + labels.forEach((label) => (label.textContent = entry.label)); + statuses.forEach((status) => (status.textContent = entry.status)); + element.scrollTop = 0; + if (labels.some((label) => label.scrollWidth > label.clientWidth)) { + layout.truncated.push(name); + } + const scrolls = element.scrollHeight > element.clientHeight; + if (!scrolls) { + if (!isInside()) layout.clipped.push(name); + continue; + } + layout.scrolled += 1; + const topVisible = + content.getBoundingClientRect().top >= + element.getBoundingClientRect().top - 0.5; + element.scrollTop = element.scrollHeight; + if (!topVisible || !isLastLineVisible()) { + layout.clipped.push(name); + } + } + + element.scrollTop = 0; + panelText.forEach((text) => (text.node.data = text.original)); + statuses.forEach((status) => status.remove()); + labels.forEach( + (label, index) => (label.textContent = originalLabels[index]) + ); + return layout; + }, locales); +} + +/** The EPG panel animates its height when the viewport height changes. */ +async function waitForStableBox(locator: Locator): Promise { + await expect + .poll(() => + locator.evaluate(async (element) => { + const before = element.getBoundingClientRect(); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ); + const after = element.getBoundingClientRect(); + return ( + before.width === after.width && + before.height === after.height + ); + }) + ) + .toBe(true); +} + +test('@electron @m3u @playback diagnostic cards keep every locale label and status line visible in short inline players', async ({ + dataDir, +}) => { + const server = await createMutableTextServer( + 'This fixture declares Matroska without playable media bytes.\n', + { contentType: 'video/x-matroska', resourcePath: '/broken.mkv' } + ); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + const playlist = join(dataDir, 'diagnostic-layout.m3u'); + writeFileSync( + playlist, + buildM3uContent([ + { name: 'Unsupported MKV', url: server.resourceUrl }, + ]) + ); + await importM3uPlaylistFromNativeDialog(app, playlist); + await channelItemByTitle(page, 'Unsupported MKV').first().click(); + + const banner = page.getByTestId('playback-diagnostic-banner'); + await expect(banner).toContainText( + /container is likely unsupported by the browser player/i, + { timeout: 20_000 } + ); + await expect(banner.getByTestId('playback-fallback-mpv')).toBeVisible(); + await expect(banner.getByTestId('playback-fallback-vlc')).toBeVisible(); + + const locales = localeTexts(); + const textCount = locales.length * CARD_LABEL_KEYS.length; + expect(locales.length).toBeGreaterThan(1); + + // 1280x720 is the default window (a 760x404 player beside the channel + // list); 1440 leaves the player just past the 760px breakpoint, where + // window-sized padding used to squeeze the content until it overflowed. + for (const viewport of [ + { width: 1280, height: 720 }, + { width: 1440, height: 720 }, + ]) { + await page.setViewportSize(viewport); + await waitForStableBox(banner); + const box = await banner.boundingBox(); + expect(box?.height ?? 0).toBeLessThan(420); + if (viewport.width === 1440) { + expect(box?.width ?? 0).toBeGreaterThan(760); + } + + const layout = await measureLocales(banner, locales); + // Badge, headline, description, both hints and three utilities. + expect(layout.translatedNodes).toBeGreaterThanOrEqual(8); + expect(layout.truncated, `${viewport.width}px`).toEqual([]); + expect(layout.clipped, `${viewport.width}px`).toEqual([]); + // Short players get the compact layout, so all of it fits. + expect(layout.scrolled, `${viewport.width}px`).toBe(0); + } + + // A player shorter than its content scrolls from the top: nothing is + // pushed above the scroll origin or left below the scroll range. + await page.setViewportSize({ width: 1440, height: 460 }); + await waitForStableBox(banner); + expect((await banner.boundingBox())?.width ?? 0).toBeGreaterThan(760); + const shortLayout = await measureLocales(banner, locales); + expect(shortLayout.truncated).toEqual([]); + expect(shortLayout.clipped).toEqual([]); + expect(shortLayout.scrolled).toBe(textCount); + } finally { + await closeElectronApp(app); + await server.close(); + } +}); diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 7923f995f..abf41b729 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -804,6 +804,17 @@ also drives a concise browser-support hint for codecs that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3, E-AC-3, DTS, and MPEG-2 video. +The panel's host is a size container over the player, so its spacing, its +narrow-width rules and a compact layout for players up to 560px tall all +follow the player box rather than the window. The M3U inline player above its +EPG panel is about 400px tall in a 720px window; there the whole surface fits, +including each card's hint and status lines and the utilities. A shorter +player scrolls the surface from its top. Card labels wrap instead of being +ellipsized, because translated actions are wider than a card column. +`apps/electron-backend-e2e/src/playback-diagnostic-layout.e2e.ts` writes every +locale's card labels and status line into the cards and fails on any +truncated label or clipped content. + URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present. The HTML5 player and ArtPlayer choose their source engine from one URL rule, diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss index 3189d3fec..719b0cf01 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss @@ -1,14 +1,20 @@ +/* The host covers the player and is the panel's size container, so spacing + * and the short-player rules follow the player box rather than the window: + * the M3U inline player above its EPG panel is about 400px tall in a 720px + * window, and a sidebar can leave it barely wider than the 760px breakpoint. */ :host { - display: contents; + position: absolute; + inset: 0; + z-index: 5; + container: playback-diagnostic / size; } .web-player-diagnostic { position: absolute; inset: 0; - z-index: 5; display: flex; - align-items: center; - padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px); + align-items: flex-start; + padding: clamp(24px, 9cqb, 92px) clamp(20px, 18cqi, 216px); overflow: auto; background: radial-gradient( @@ -28,6 +34,10 @@ .web-player-diagnostic__content { width: min(100%, 720px); min-width: 0; + /* Centres the content while it fits. Unlike align-items: center, the auto + * margins never go negative, so content taller than the player starts at + * the top and the rest stays reachable by scrolling. */ + margin-block: auto; } .web-player-diagnostic__badge { @@ -105,7 +115,7 @@ .web-player-diagnostic__recommendations { display: grid; - grid-template-columns: repeat(2, minmax(168px, 192px)); + grid-template-columns: repeat(2, minmax(168px, 240px)); gap: 12px; margin-top: 28px; } @@ -149,7 +159,7 @@ border-radius: 12px; background: var(--playback-diagnostic-source-surface); text-align: left; - max-height: min(46vh, 320px); + max-height: min(46cqb, 320px); overflow-y: auto; } @@ -264,13 +274,15 @@ gap: 2px; } +/* Translated actions such as "Паспрабаваць MPV яшчэ раз" are wider than any + * card column, so the label wraps instead of ellipsizing the action. */ .web-player-diagnostic__player-label { - overflow: hidden; + min-width: 0; font-size: 0.95rem; font-weight: 700; - line-height: 1.05; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.15; + overflow-wrap: anywhere; + text-wrap: balance; } .web-player-diagnostic__player-hint { @@ -396,12 +408,15 @@ overflow-wrap: anywhere; } -@container (max-width: 760px) { +@container playback-diagnostic (max-width: 760px) { .web-player-diagnostic { - align-items: flex-start; padding: 22px 16px; } + .web-player-diagnostic__content { + margin-block: 0; + } + .web-player-diagnostic__badge, .web-player-diagnostic__badge span { white-space: normal; @@ -431,9 +446,53 @@ } } -@container (max-width: 520px) { +/* Short players, such as the inline player above the M3U EPG panel: tighter + * spacing and type keep the cards with their hint and status lines and the + * utilities on screen. Shorter players scroll from the top. */ +@container playback-diagnostic (max-height: 560px) { + /* The side padding grows from the narrow players' 16px at the 760px + * breakpoint, so a short player keeps its width for fewer lines. */ + .web-player-diagnostic { + padding: clamp(14px, 5cqb, 28px) clamp(16px, 18cqi - 120px, 216px); + } + + .web-player-diagnostic__headline { + margin-top: 14px; + font-size: 1.35rem; + line-height: 1.08; + } + + .web-player-diagnostic__description { + margin-top: 8px; + font-size: 0.9rem; + line-height: 1.38; + } + + .web-player-diagnostic__meta { + margin-top: 6px; + } + + .web-player-diagnostic__codec-hint { + margin-top: 8px; + } + + .web-player-diagnostic__recommendations { + gap: 8px; + margin-top: 16px; + } + + .web-player-diagnostic__player-card { + min-height: 52px; + padding: 10px 12px; + } + + .web-player-diagnostic__utilities { + margin-top: 14px; + } +} + +@container playback-diagnostic (max-width: 520px) { .web-player-diagnostic { - align-items: flex-start; padding: clamp(14px, 5cqi, 22px); } @@ -489,12 +548,6 @@ padding: 10px 12px; } - .web-player-diagnostic__player-label { - overflow-wrap: anywhere; - text-overflow: clip; - white-space: normal; - } - .web-player-diagnostic__utilities { display: grid; gap: 7px; @@ -536,7 +589,7 @@ } } -@container (max-width: 340px) { +@container playback-diagnostic (max-width: 340px) { .web-player-diagnostic { padding: 12px; } diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts index 2025edf23..b47a2f666 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts @@ -241,7 +241,11 @@ describe('PlaybackDiagnosticPanelComponent', () => { const host = fixture.nativeElement as HTMLElement; - expect(getComputedStyle(host).display).toBe('contents'); + // The host covers the player as the panel's size container. + expect(getComputedStyle(host).position).toBe('absolute'); + expect(PANEL_STYLE_SOURCE).toContain( + 'container: playback-diagnostic / size' + ); }); it('renders supplied policy-ranked action buttons in order with priority styling', () => { @@ -669,6 +673,29 @@ describe('PlaybackDiagnosticPanelComponent', () => { ); }); + it('wraps long translated action labels instead of ellipsizing them', () => { + fixture.componentRef.setInput('recommendations', [ + recommendation('player', { target: 'mpv', priority: 'primary' }), + recommendation('player', { target: 'vlc' }), + ]); + fixture.detectChanges(); + + const labels = Array.from( + fixture.nativeElement.querySelectorAll( + '.web-player-diagnostic__player-label' + ) as NodeListOf + ); + + expect(labels).toHaveLength(2); + for (const label of labels) { + const styles = getComputedStyle(label); + expect(styles.whiteSpace).not.toBe('nowrap'); + expect(styles.overflowWrap).toBe('anywhere'); + expect(styles.textOverflow).not.toBe('ellipsis'); + expect(styles.overflow).not.toBe('hidden'); + } + }); + it('wraps long translated reasons and temporary hints without clipping', () => { fixture.componentRef.setInput('recommendations', [ recommendation('player', { @@ -723,7 +750,12 @@ describe('PlaybackDiagnosticPanelComponent', () => { '.web-player-diagnostic__player-card:focus-visible' ); expect(styles).toContain('outline: 2px solid #ffb24c'); - expect(styles).toContain('@container (max-width: 520px)'); + expect(styles).toContain( + '@container playback-diagnostic (max-width: 520px)' + ); + expect(styles).toContain( + '@container playback-diagnostic (max-height: 560px)' + ); expect(styles).toMatch( /web-player-diagnostic__recommendations[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/ );