From f1a5b29be4d3cb90aae006c888809b7b503821ff Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:32:04 +0200 Subject: [PATCH] fix(playback): diagnostic cards wrap translated labels and fit short players (#1892) * fix(playback): size diagnostic cards to the player and wrap their labels The recovery cards ellipsized every translated action wider than their 124px label column (de, ru, hu, pt, pl, by and others), and the banner took its padding from the window rather than the player. In a short inline player just wider than the 760px breakpoint that squeezed the content until it overflowed, and align-items: center pushed its top above the scroll origin. The panel host is now a size container over the player: padding uses container units, a compact layout applies to players up to 560px tall, auto margins centre the content without clipping its top, labels wrap, and the cards grow to 240px. Co-Authored-By: Claude Opus 5.5 * fix(playback): keep short diagnostic players at full content width Measure the layout E2E with the whole banner translated, not only the card labels: the badge, headline, description, hints and utilities set the banner's height too. With them, a by banner in a 920px-wide, 404px-tall player had 45px to spare, because 18cqi side padding left a 588px column. Short players now grow their side padding from 16px at the 760px breakpoint, which keeps the 720px column and about 90px spare. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changes/playback-diagnostic-card-layout.md | 9 + .../src/playback-diagnostic-layout.e2e.ts | 303 ++++++++++++++++++ docs/architecture/embedded-inline-playback.md | 11 + .../playback-diagnostic-panel.component.scss | 95 ++++-- ...layback-diagnostic-panel.component.spec.ts | 36 ++- 5 files changed, 431 insertions(+), 23 deletions(-) create mode 100644 .changes/playback-diagnostic-card-layout.md create mode 100644 apps/electron-backend-e2e/src/playback-diagnostic-layout.e2e.ts 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\)/ );