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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:32:04 +02:00
1 parent c76cf357b7
commit f1a5b29be4
5 files changed
+431 -23

No files matched your search

@@ -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.
@@ -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<string, string> {
const strings = new Map<string, string>();
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<CardLayout> {
return banner.evaluate((element, entries) => {
const cards = Array.from(
element.querySelectorAll<HTMLElement>(
'.web-player-diagnostic__player-card'
)
);
const labels = cards.map(
(card) =>
card.querySelector<HTMLElement>(
'.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<void> {
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();
}
});
@@ -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,
@@ -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;
}
@@ -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<HTMLElement>
);
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\)/
);