fix(playback): apply themes to player and EPG panels

This commit is contained in:
4gray committed 2026-09-05 13:49:45 +02:00
1 parent eba68d687e
commit 2a1c04f8a8
17 files changed
+803 -191

No files matched your search

@@ -1,16 +1,30 @@
import {
addXtreamPortal,
addStalkerPortal,
waitForStalkerCatalog,
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
expect,
launchElectronApp,
openWorkspaceSection,
openSettings,
openSettingsSection,
saveSettings,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
import {
fetchXtreamEpgFixture,
fetchStalkerCategoryFixture,
} from './portal-mock-fixtures';
import {
applyTheme,
expectTextContrast,
expectThemeSurface,
} from './theme-contrast';
const epgCredentials = {
username: 'epg',
@@ -32,6 +46,13 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } });
try {
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setSize(1800, 1000);
});
// No external demo stream is needed for guide interaction/contrast.
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
// Keep the external demo request pending; guide data is local.
});
await addXtreamPortal(app.mainWindow, {
name: 'Xtream Timeline Interaction',
username: epgCredentials.username,
@@ -76,6 +97,40 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
const maxZoomWidth = await blockWidthAt('max');
expect(maxZoomWidth).toBeGreaterThan(minZoomWidth);
for (const theme of ['light', 'dark', 'light'] as const) {
await applyTheme(app.mainWindow, theme);
await expectThemeSurface(timeline, theme);
await expectTextContrast(
timeline.locator('.epg-timeline__heading b')
);
await expectTextContrast(
nowBlock.locator('.epg-timeline__block-title')
);
await expectTextContrast(
nowBlock.locator('.epg-timeline__block-time')
);
// Exercise the worst-case overlapping current/playing tints,
// including the sibling progress fill behind the small live label.
for (const playing of [false, true]) {
await nowBlock.evaluate((element, active) => {
element.classList.toggle('is-playing', active);
}, playing);
await expectTextContrast(
nowBlock.locator('.epg-timeline__tag.is-now'),
4.5,
'.epg-timeline__fill--live'
);
}
await nowBlock.evaluate((element) =>
element.classList.remove('is-playing')
);
await nowBlock.hover();
await expectTextContrast(
nowBlock.locator('.epg-timeline__info'),
3
);
}
// At max zoom the on-air block is wide enough to expose the info
// affordance (hidden on narrow/micro tiers), which opens the shared
// programme-details dialog with the programme metadata.
@@ -87,14 +142,87 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
currentProgram.title
);
// An on-air programme offers "watch live" as the primary action.
await expect(
dialog.locator('.epg-dialog__btn--primary')
).toBeVisible();
await expect(dialog.locator('.epg-dialog__btn--primary')).toBeVisible();
for (const theme of ['light', 'dark', 'light'] as const) {
await applyTheme(app.mainWindow, theme);
await expectThemeSurface(dialog, theme);
await expectTextContrast(dialog.locator('.epg-dialog__title'));
await expectTextContrast(dialog.locator('.epg-dialog__desc'));
await expectTextContrast(dialog.locator('.epg-dialog__close'), 3);
}
await app.mainWindow.screenshot({
path: test.info().outputPath('epg-light.png'),
});
await dialog.locator('.epg-dialog__close').click();
await app.mainWindow.waitForSelector('.epg-dialog', {
state: 'detached',
});
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow.getByTestId('epg-view-mode-list').click();
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await channelItemByTitle(app.mainWindow, fixture.stream.name ?? '')
.first()
.click();
const guide = app.mainWindow.locator('app-epg-list-view');
await expect(guide).toBeVisible();
for (const theme of ['light', 'dark'] as const) {
await applyTheme(app.mainWindow, theme);
await expectThemeSurface(guide, theme);
await expectTextContrast(
guide.locator('[data-when="now"] .time').first()
);
await expectTextContrast(
guide.locator('[data-when="now"] .title').first()
);
await expectTextContrast(
guide.locator('[data-when="now"] .desc').first()
);
}
} finally {
await closeElectronApp(app);
}
});
test('@epg @stalker @theme @electron applies live themes to the shared Stalker guide', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const fixture = await fetchStalkerCategoryFixture(request, 'itv');
const item = fixture.items[0];
const app = await launchElectronApp(dataDir);
try {
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setSize(1800, 1000);
});
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
// Keep the external demo request pending; guide data is local.
});
await addStalkerPortal(app.mainWindow, {
name: 'Stalker Theme Fixture',
});
await waitForStalkerCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await channelItemByTitle(app.mainWindow, item.o_name || item.name || '')
.first()
.click();
const timeline = app.mainWindow.locator('app-epg-timeline');
await expect(timeline).toBeVisible();
for (const theme of ['light', 'dark', 'light'] as const) {
await applyTheme(app.mainWindow, theme);
await expectThemeSurface(timeline, theme);
await expectTextContrast(
timeline.locator('.epg-timeline__heading b')
);
await app.mainWindow.screenshot({
path: test.info().outputPath(`stalker-${theme}.png`),
});
}
} finally {
await closeElectronApp(app);
}
@@ -0,0 +1,287 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import { pathToFileURL } from 'url';
import {
channelItemByTitle,
closeElectronApp,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSettings,
openGlobalFavorites,
openGlobalRecent,
openSettingsSection,
saveSettings,
test,
workspaceRoot,
} from './electron-test-fixtures';
import {
createLocalMediaServer,
installEmbeddedMpvSessionCapture,
} from './embedded-mpv-frame-copy-packaged-fixtures';
import {
applyTheme,
expectTextContrast,
expectThemeSurface,
expectOverlayContrastOnWhite,
} from './theme-contrast';
for (const engine of [
'native',
'frame-copy',
'html5',
'videojs',
'artplayer',
] as const) {
test(`@playback @theme @electron ${engine} keeps controls readable across live theme and fullscreen changes`, async ({
dataDir,
}) => {
const embedded = engine === 'native' || engine === 'frame-copy';
const media = embedded ? await createLocalMediaServer() : undefined;
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY:
engine === 'frame-copy' ? '1' : '0',
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
},
});
try {
if (embedded) {
const support = await app.mainWindow.evaluate(() =>
window.electron.getEmbeddedMpvSupport()
);
test.skip(
!support.supported || support.engine !== engine,
`Requested ${engine} runtime unavailable: ${JSON.stringify(support)}`
);
}
await openSettings(app.mainWindow);
await app.mainWindow.getByTestId('LIGHT_THEME').click();
await openSettingsSection(app.mainWindow, 'playback');
await app.mainWindow.getByTestId('select-video-player').click();
await app.mainWindow
.getByTestId(embedded ? 'embedded-mpv' : engine)
.click();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
const playlist = join(dataDir, 'theme.m3u');
const url =
media?.url ??
pathToFileURL(
join(
workspaceRoot,
'apps/web-e2e/src/fixtures/playback/episode.webm'
)
).href;
writeFileSync(
playlist,
`#EXTM3U\n#EXTINF:-1,Theme fixture\n${url}\n`
);
if (embedded) await installEmbeddedMpvSessionCapture(app);
await importM3uPlaylistFromNativeDialog(app, playlist);
if (engine === 'html5') {
await channelItemByTitle(app.mainWindow, 'Theme fixture')
.first()
.locator('.favorite-button')
.first()
.click();
}
await channelItemByTitle(app.mainWindow, 'Theme fixture')
.first()
.click();
if (engine === 'frame-copy') {
// The experimental local runtime can fail its first frame-view
// initialization. Cover the themed error UI and one user Retry;
// successful decoding is still required below.
await expect
.poll(() =>
app.mainWindow.evaluate(
() =>
(window.__packagedEmbeddedMpvSessions?.at(-1)
?.positionSeconds ?? 0) > 0 ||
!!document.querySelector(
'.embedded-mpv-player__stalled'
)
)
)
.toBe(true);
const stalled = app.mainWindow.locator(
'.embedded-mpv-player__stalled'
);
if (await stalled.isVisible()) {
test.info().annotations.push({
type: 'runtime-retry',
description:
'Frame-copy initialization required one user Retry before theme checks.',
});
for (const theme of ['dark', 'light'] as const) {
await applyTheme(app.mainWindow, theme);
await expectTextContrast(stalled.locator('p'));
await expectTextContrast(
stalled.getByRole('button', { name: 'Retry' })
);
}
await stalled
.getByRole('button', { name: 'Retry' })
.click();
}
}
if (embedded) {
await expect
.poll(() =>
app.mainWindow.evaluate(
() =>
window.__packagedEmbeddedMpvSessions?.at(-1)
?.positionSeconds ?? 0
)
)
.toBeGreaterThan(0);
} else {
await expect
.poll(() =>
app.mainWindow
.locator('app-web-player-view video')
.evaluate(
(video: HTMLVideoElement) => video.currentTime
)
)
.toBeGreaterThan(0);
}
const controls = app.mainWindow.locator(
engine === 'native'
? '.embedded-mpv-player__controls'
: 'app-player-controls'
);
const pause = controls.getByRole('button', {
name: 'Pause',
exact: true,
});
await expect(pause).toBeEnabled({ timeout: 20000 });
await pause.click();
const play = controls.getByRole('button', {
name: 'Play',
exact: true,
});
await expect(play).toBeVisible();
for (const fullscreen of [false, true]) {
if (fullscreen) {
await controls
.getByRole('button', {
name: 'Enter fullscreen',
exact: true,
})
.click();
}
for (const theme of ['light', 'dark', 'light'] as const) {
await applyTheme(app.mainWindow, theme);
await play.hover();
if (engine === 'native') await expectTextContrast(play, 3);
else await expectOverlayContrastOnWhite(controls, play);
// Leave pointer hover, then Tab back into the control to
// exercise a real keyboard focus-visible state.
await play.press('Tab');
await app.mainWindow.keyboard.press('Shift+Tab');
await expect(play).toBeFocused();
if (engine === 'native') await expectTextContrast(play, 3);
else await expectOverlayContrastOnWhite(controls, play);
if (!fullscreen) {
const timeline =
app.mainWindow.locator('app-epg-timeline');
await expectThemeSurface(timeline, theme);
await expectTextContrast(
timeline.locator('.epg-empty__title')
);
await expectTextContrast(
timeline.locator('.epg-empty__sub')
);
}
if (engine === 'native') {
await expectThemeSurface(controls, theme);
await expect(play).toHaveCSS('outline-width', '2px');
const disabled = controls
.locator(
'.embedded-mpv-player__transport button:disabled'
)
.first();
await expect(disabled).toBeDisabled();
await expectTextContrast(disabled, 1.5);
expect(
await disabled.evaluate(
(el) => getComputedStyle(el).color
)
).not.toEqual(
await play.evaluate(
(el) => getComputedStyle(el).color
)
);
await expectTextContrast(
controls.locator('.embedded-mpv-player__time')
);
}
if (engine === 'native') {
await controls
.locator('[data-embedded-mpv-menu-button="speed"]')
.click();
const panel = controls.locator(
'app-embedded-mpv-dock-panel'
);
await expectTextContrast(
panel.locator('.embedded-mpv-dock-panel__title')
);
const selected = panel.locator(
'.embedded-mpv-dock-panel__chip--selected'
);
await expectTextContrast(selected);
await selected.hover();
await expectTextContrast(selected);
await panel
.locator('.embedded-mpv-dock-panel__back')
.click();
}
const controlBox = await controls.boundingBox();
const playBox = await play.boundingBox();
expect(playBox!.x).toBeGreaterThanOrEqual(controlBox!.x);
expect(playBox!.y + playBox!.height).toBeLessThanOrEqual(
controlBox!.y + controlBox!.height + 1
);
await app.mainWindow.screenshot({
path: test
.info()
.outputPath(
`${engine}-${theme}-${fullscreen ? 'fullscreen' : 'window'}.png`
),
});
}
}
await controls
.getByRole('button', { name: 'Exit fullscreen', exact: true })
.click();
if (engine === 'html5') {
for (const openCollection of [
openGlobalFavorites,
openGlobalRecent,
]) {
await openCollection(app.mainWindow);
await channelItemByTitle(app.mainWindow, 'Theme fixture')
.first()
.click();
for (const theme of ['light', 'dark'] as const) {
await applyTheme(app.mainWindow, theme);
const timeline =
app.mainWindow.locator('app-epg-timeline');
await expect(timeline).toBeVisible();
await expectThemeSurface(timeline, theme);
await expectTextContrast(
timeline.locator('.epg-timeline__heading b')
);
}
}
}
} finally {
await closeElectronApp(app);
await media?.close();
}
});
}
@@ -0,0 +1,155 @@
import { expect, Locator, Page } from '@playwright/test';
import sharp = require('sharp');
/** Exercise the same live CSS boundary as SettingsService without navigating
* away from (and therefore destroying) the player being measured. */
export async function applyTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((dark) => {
document.body.classList.toggle('dark-theme', dark);
}, theme === 'dark');
}
/** Contrast for app panels with solid/alpha CSS background colors. Gradients,
* video and Material state layers use the raster check below instead. */
export async function expectTextContrast(
locator: Locator,
minimum = 4.5,
siblingFillSelector?: string
) {
await expect(locator).toBeVisible();
const measure = () =>
locator.evaluate((element, fillSelector) => {
type Color = [number, number, number, number];
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const parse = (css: string): Color => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = css;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return [r, g, b, a / 255];
};
const over = (front: Color, back: Color): Color => {
const alpha = front[3] + back[3] * (1 - front[3]);
return [0, 1, 2]
.map((i) =>
alpha
? (front[i] * front[3] +
back[i] * back[3] * (1 - front[3])) /
alpha
: 0
)
.concat(alpha) as Color;
};
let foreground = parse(getComputedStyle(element).color);
let background: Color = [0, 0, 0, 0];
for (
let node: Element | null = element;
node;
node = node.parentElement
) {
const style = getComputedStyle(node);
// A timeline progress fill is a sibling behind the text, not
// an ancestor. Include it before its owning card background.
const siblingFill = fillSelector
? node.querySelector(`:scope > ${fillSelector}`)
: null;
if (siblingFill) {
const layer = parse(
getComputedStyle(siblingFill).backgroundColor
);
foreground = over(foreground, layer);
background = over(background, layer);
}
const fill = parse(style.backgroundColor);
foreground = over(foreground, fill);
background = over(background, fill);
foreground[3] *= Number(style.opacity);
background[3] *= Number(style.opacity);
}
const white: Color = [255, 255, 255, 1];
const luminance = (color: Color) => {
const rgb = color.slice(0, 3).map((value) => {
const s = value / 255;
return s <= 0.04045
? s / 12.92
: ((s + 0.055) / 1.055) ** 2.4;
});
return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;
};
const a = luminance(over(foreground, white));
const b = luminance(over(background, white));
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}, siblingFillSelector);
await expect
.poll(measure, { message: `Rendered contrast of ${locator}` })
.toBeGreaterThanOrEqual(minimum);
}
export async function expectThemeSurface(
locator: Locator,
theme: 'light' | 'dark'
) {
const brightness = await locator.evaluate((element) => {
const color = getComputedStyle(element).backgroundColor;
const channels =
color
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number) ?? [];
return channels.reduce((sum, value) => sum + value, 0) / 3;
});
if (theme === 'light') expect(brightness).toBeGreaterThan(220);
else expect(brightness).toBeLessThan(70);
}
/** Put a worst-case white video frame below the real shared controls. Rasterize
* the actual icon with its gradient scrim and Material hover/focus layers, so
* losing the scrim cannot pass because a black video ancestor masks the bug. */
export async function expectOverlayContrastOnWhite(
controls: Locator,
button: Locator
) {
await controls.evaluate((element) => {
const backing = document.createElement('div');
backing.dataset['themeContrastBacking'] = '';
Object.assign(backing.style, {
position: 'absolute',
inset: '0',
background: '#fff',
zIndex: '-1',
pointerEvents: 'none',
});
element.append(backing);
});
try {
const icon = button.locator('mat-icon');
const buffer = await icon.screenshot({ animations: 'disabled' });
const { data, info } = await sharp(buffer)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const luminances: number[] = [];
for (let i = 0; i < data.length; i += info.channels) {
const linear = [data[i], data[i + 1], data[i + 2]].map((value) => {
const s = value / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
luminances.push(
linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
);
}
luminances.sort((a, b) => a - b);
// Interior background and filled glyph, excluding antialiased edges.
const background = luminances[Math.floor(luminances.length * 0.1)];
const foreground = luminances[Math.floor(luminances.length * 0.9)];
expect(
(foreground + 0.05) / (background + 0.05)
).toBeGreaterThanOrEqual(3);
} finally {
await controls.evaluate((element) =>
element.querySelector('[data-theme-contrast-backing]')?.remove()
);
}
}