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

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

* test(playback): verify active recording icon theme

* fix(epg): keep loading shimmer visible in both themes
This commit is contained in:
4gray authored and GitHub committed 2026-09-05 15:32:22 +02:00
1 parent eb602db5fc
commit 8eda5370eb
17 files changed
+913 -194

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: playback
issues: [1530]
---
Embedded MPV controls and programme-guide panels now follow the visual theme and stay readable in light and dark mode. Video overlays retain their contrasting dark backgrounds.
+7
View File
@@ -232,6 +232,13 @@ Key files:
## Shared Player Controls
- The Embedded MPV native-view dock follows app theme tokens as a solid app
surface, including Material icon-button disabled states. Over-video loading,
stalled and feedback overlays keep a paired light-on-dark palette. Video
viewports remain black in windowed and fullscreen modes. Shared EPG panels
use the library-local app-token palette in `libs/ui/epg/src/lib/_epg-theme.scss`.
Theme/contrast contract: `docs/architecture/iptvnator-ui-guidelines.md`.
- `libs/ui/playback/src/lib/player-controls/` contains the additive,
engine-neutral `PlayerController` contract, standalone
`app-player-controls`, generic web-video adapter/helper, and component-scoped
+7
View File
@@ -847,6 +847,13 @@ app as a real argument, so it is not an option.
**Video Players**:
- The Embedded MPV native-view dock follows app theme tokens as a solid app
surface, including Material icon-button disabled states. Over-video loading,
stalled and feedback overlays keep a paired light-on-dark palette. Video
viewports remain black in windowed and fullscreen modes. Shared EPG panels
use the library-local app-token palette in `libs/ui/epg/src/lib/_epg-theme.scss`.
Theme/contrast contract: `docs/architecture/iptvnator-ui-guidelines.md`.
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer. The HTML5
player and ArtPlayer pick their source engine from one URL rule,
`resolvePlaybackUrlSourceKind()` in `libs/playback/util` (`mpd` → Shaka,
@@ -1,16 +1,31 @@
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,
expectSkeletonContrast,
} from './theme-contrast';
const epgCredentials = {
username: 'epg',
@@ -32,6 +47,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 +98,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 +143,113 @@ 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()
);
}
// Keep a fresh channel's EPG IPC pending so the real list loading
// template stays mounted through both theme changes.
await app.electronApp.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('XTREAM_REQUEST');
ipcMain.handle(
'XTREAM_REQUEST',
() =>
new Promise(() => {
// Released when this isolated Electron test app closes.
})
);
});
await app.mainWindow
.locator('[data-test-id="channel-item"]')
.nth(1)
.click();
const skeleton = guide.locator('.sk-time').first();
await expect(skeleton).toBeVisible();
for (const theme of ['light', 'dark'] as const) {
await applyTheme(app.mainWindow, theme);
await expectSkeletonContrast(skeleton, guide);
await expectSkeletonContrast(
guide.locator('.sk-title').first(),
guide
);
}
} 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,320 @@
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);
const record = controls.locator(
'.embedded-mpv-player__record-button'
);
await expect(record).toBeVisible();
// Recording behavior has its own IPC coverage; exercise
// the state class here without creating a recording file.
await record.evaluate((element) =>
element.classList.add(
'embedded-mpv-player__record-button--active'
)
);
const liveColor = await record.evaluate((element) =>
getComputedStyle(element)
.getPropertyValue('--app-live-color')
.trim()
);
await expect(record.locator('mat-icon')).toHaveCSS(
'color',
await record.evaluate((element, color) => {
const probe = document.createElement('span');
probe.style.color = color;
element.append(probe);
const resolved = getComputedStyle(probe).color;
probe.remove();
return resolved;
}, liveColor)
);
await expectTextContrast(record, 3);
await record.evaluate((element) =>
element.classList.remove(
'embedded-mpv-player__record-button--active'
)
);
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,200 @@
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()
);
}
}
/** Skeletons are decorative, but their faintest gradient stop must remain
* distinguishable from the guide's background in either theme. */
export async function expectSkeletonContrast(
skeleton: Locator,
guide: Locator
) {
const background = await guide.evaluate((element) =>
getComputedStyle(element)
.backgroundColor.match(/[\d.]+/g)!
.slice(0, 3)
.map(Number)
);
const { data, info } = await sharp(
await skeleton.screenshot({ animations: 'disabled' })
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const luminance = (rgb: number[]) =>
rgb
.map((value) => {
const s = value / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) =>
sum + value * [0.2126, 0.7152, 0.0722][index],
0
);
const bg = luminance(background);
// Middle row avoids the rounded transparent corners.
for (let x = 8; x < info.width - 8; x++) {
const offset =
(Math.floor(info.height / 2) * info.width + x) * info.channels;
const fg = luminance([
data[offset],
data[offset + 1],
data[offset + 2],
]);
expect(
(Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05)
).toBeGreaterThanOrEqual(1.3);
}
}
+2 -2
View File
@@ -399,8 +399,8 @@ Rendering size: the helper renders at the **aspect-fit** size of the video
(observed `dwidth`/`dheight`) inside the requested viewport and bumps a shm
generation when it changes — letterbox bars are never baked into frames,
frames stay as small as possible, and the canvas letterboxes with a
transparent background (app surface shows at the sides; fullscreen keeps a
black backdrop). Snapshots carry `videoWidth`/`videoHeight`.
transparent canvas over a black video viewport in both windowed and
fullscreen mode. Snapshots carry `videoWidth`/`videoHeight`.
`IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY=<seconds>` passes through to mpv's
`audio-delay` for lip-sync tuning until a calibration flow exists.
+30 -1
View File
@@ -79,6 +79,35 @@ as migration debt, not patterns to copy.
Do not hardcode unrelated accent colors for selected state when these tokens already exist.
## Player And EPG Theme Boundaries
The native-view Embedded MPV dock is app chrome: its solid widget background,
text, separators, sliders and interaction states resolve app tokens together.
Material icon buttons override their component tokens, including disabled
icons. The dock must never pair a dark fallback surface with inherited light
app text. Loader/stall and transient feedback overlays own a light foreground
and dark scrim because they cover video. Video viewports remain black in both
themes and fullscreen; frame-copy and built-in shared controls keep their
existing light-on-dark overlay palette.
EPG timeline, list, empty states and programme details use the library-local
`libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators,
selection and live accents. Text pairs with the actual surface in both themes;
current/playing titles must not force white onto a light selection tint.
Past programme text remains readable without reducing opacity on the whole
card. List loading shimmer uses translucent primary text stops so placeholders
remain visible on either theme’s content surface. Theme changes resolve through
CSS on the mounted components immediately.
Electron E2E measures app-panel foreground/background contrast (including
translucency, ancestor opacity and the timeline’s sibling progress fill),
surface brightness and control geometry.
Shared overlay icons are separately rasterized over a white test frame to
include gradient scrims and Material hover/focus layers in their contrast check.
Synthetic media is used for visual artifacts. Native-view video is composited
outside Chromium screenshots, so playback is also verified from session
position; a black screenshot viewport alone is not proof of failed decoding.
## Selection Pattern
Apply the same visual recipe to selected list items, active channels, and current EPG items:
@@ -492,7 +521,7 @@ When updating IPTVnator UI:
Avoid these:
- introducing a new selected-state color unrelated to the theme tokens
- copying the shared EPG's hard-coded dark/blue fallbacks into new surfaces
- introducing independent dark-only EPG surface palettes
- duplicating channel row markup in portal-specific views
- showing placeholder logos behind real logos
- making entire panes scroll when only the list should scroll
+24
View File
@@ -0,0 +1,24 @@
// App-owned EPG panels share the same live light/dark palette.
$line: var(--app-widget-header-border);
$line-strong: var(--app-separator);
$surface-1: var(--app-widget-bg);
$surface-2: var(--app-widget-header-bg);
$surface-3: var(--app-card-hover-bg);
$text-primary: var(--app-on-surface);
$text-secondary: color-mix(in srgb, var(--app-on-surface) 82%, transparent);
$text-tertiary: color-mix(in srgb, var(--app-on-surface) 76%, transparent);
$accent-blue: var(--app-selection-color);
$accent-cyan: var(--app-selection-color);
// Filled accents and small accent text need different contrast on pale tints.
$accent-text: color-mix(
in srgb,
var(--app-selection-color) 60%,
var(--app-on-surface)
);
$live-text: color-mix(
in srgb,
var(--app-live-color) 40%,
var(--app-on-surface)
);
$accent-live: var(--app-live-color);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@@ -1,15 +1,4 @@
// Matches the EPG timeline design system (same tokens + fallbacks).
$surface-1: var(--surface-1, #0f141c);
$surface-2: var(--surface-2, #161d28);
$surface-3: var(--surface-3, #1d2533);
$text-primary: var(--text-primary, #e7ecf3);
$text-secondary: var(--text-secondary, #9aa3b2);
$text-tertiary: var(--text-tertiary, #6b7384);
$accent-blue: var(--accent-blue, #4f8eff);
$accent-cyan: var(--accent-cyan, #5cd6ff);
$line: var(--line, rgba(255, 255, 255, 0.06));
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@use '../epg-theme' as *;
.epg-dialog {
box-sizing: border-box;
@@ -20,7 +9,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
border: 1px solid $line-strong;
border-radius: var(--r-lg, 14px);
overflow: hidden;
box-shadow: 0 40px 90px rgba(0, 0, 0, 0.6);
box-shadow: var(--app-widget-shadow);
* {
box-sizing: border-box;
@@ -34,37 +23,24 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
display: grid;
place-items: center;
border-bottom: 1px solid $line;
background:
radial-gradient(
120% 150% at 78% 22%,
rgba(110, 60, 180, 0.4),
transparent 60%
),
radial-gradient(
130% 160% at 18% 18%,
rgba(30, 40, 70, 0.55),
transparent 55%
),
#0b0e16;
background: $surface-2;
}
.epg-dialog__hero-glyph {
font-size: 40px;
width: 40px;
height: 40px;
color: rgba(255, 255, 255, 0.16);
color: $text-tertiary;
}
.epg-dialog__logo {
max-width: 56%;
max-height: 76px;
object-fit: contain;
/* a soft plate so logos on transparent/white art stay legible on the
dark gradient */
/* Keep transparent channel artwork on an app-owned plate. */
padding: 8px 12px;
border-radius: 10px;
background: rgba(8, 11, 17, 0.42);
background: $surface-1;
backdrop-filter: blur(2px);
}
@@ -78,7 +54,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
gap: 7px;
padding: 5px 11px;
border-radius: 999px;
background: rgba(8, 11, 17, 0.72);
background: $surface-1;
border: 1px solid $line;
font-size: 12px;
color: $text-secondary;
@@ -88,7 +64,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
font-size: 15px;
width: 15px;
height: 15px;
color: $accent-blue;
color: $accent-text;
flex: 0 0 auto;
}
}
@@ -109,7 +85,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
place-items: center;
border: none;
border-radius: 8px;
background: rgba(8, 11, 17, 0.72);
background: $surface-1;
color: $text-secondary;
cursor: pointer;
backdrop-filter: blur(6px);
@@ -199,7 +175,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&--rating {
color: $accent-cyan;
color: $accent-text;
}
}
@@ -239,8 +215,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&--primary {
background: $accent-blue;
color: #fff;
background: var(--app-selection-surface);
border-color: var(--app-selection-border);
color: $text-primary;
&:hover {
filter: brightness(1.06);
@@ -1,14 +1,4 @@
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
$surface-1: var(--surface-1, #0f141c);
$surface-2: var(--surface-2, #161d28);
$surface-3: var(--surface-3, #1d2533);
$text-primary: var(--text-primary, #e7ecf3);
$text-secondary: var(--text-secondary, #9aa3b2);
$text-tertiary: var(--text-tertiary, #6b7384);
$accent-blue: var(--accent-blue, #4f8eff);
$accent-cyan: var(--accent-cyan, #5cd6ff);
$accent-live: var(--accent-live, #ff5b6e);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@use '../../epg-theme' as *;
:host {
box-sizing: border-box;
@@ -47,7 +37,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $accent-live;
color: $live-text;
}
.time-now::before {
content: '';
@@ -80,7 +70,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
.g-now-bar {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
background: var(--app-separator);
margin-top: 8px;
max-width: 320px;
overflow: hidden;
@@ -100,7 +90,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $accent-blue;
color: $accent-text;
}
.playtag mat-icon {
width: 14px;
@@ -170,7 +160,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
.watch:hover {
border-color: $accent-blue;
color: $accent-blue;
color: $accent-text;
}
/* ── PAST — dimmed ── */
@@ -189,14 +179,14 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
/* ── NOW — accent bar + fill ── */
:host([data-when='now']) {
align-items: start;
background: rgba(79, 142, 255, 0.08);
border-left-color: #4f8eff;
background: var(--app-selection-surface);
border-left-color: var(--app-selection-border);
}
:host([data-when='now']) .time {
color: $accent-blue;
color: $accent-text;
}
:host([data-when='now']) .title {
color: #fff;
color: $text-primary;
font-weight: 600;
}
:host([data-when='now']) .desc {
@@ -205,20 +195,20 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
/* ── PLAYING from archive — distinct from now ── */
:host(.playing) {
background: rgba(79, 142, 255, 0.13);
border-left-color: #4f8eff;
background: var(--app-selection-surface);
border-left-color: var(--app-selection-border);
}
:host(.playing) .title {
color: #fff;
color: $text-primary;
font-weight: 600;
}
:host(.playing) .watch {
border-color: $accent-blue;
background: rgba(79, 142, 255, 0.18);
color: $accent-blue;
background: var(--app-selection-surface);
color: $accent-text;
}
/* ── SELECTED (focus) — ring only, independent of temporal/playing state ── */
:host(.sel) {
box-shadow: inset 0 0 0 1px rgba(79, 142, 255, 0.55);
box-shadow: inset 0 0 0 1px var(--app-selection-border);
}
@@ -1,15 +1,4 @@
$line: var(--line, rgba(255, 255, 255, 0.06));
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
$surface-1: var(--surface-1, #0f141c);
$surface-2: var(--surface-2, #161d28);
$surface-3: var(--surface-3, #1d2533);
$text-primary: var(--text-primary, #e7ecf3);
$text-secondary: var(--text-secondary, #9aa3b2);
$text-tertiary: var(--text-tertiary, #6b7384);
$accent-blue: var(--accent-blue, #4f8eff);
$accent-cyan: var(--accent-cyan, #5cd6ff);
$accent-live: var(--accent-live, #ff5b6e);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@use '../epg-theme' as *;
/* This app has no global border-box reset; the list relies on it. */
.guide,
@@ -22,7 +11,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
flex-direction: column;
height: 100%;
min-height: 0;
background: #0a0e15;
background: var(--app-content-bg);
}
.guide {
@@ -109,7 +98,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
border-radius: 999px;
background: rgba(255, 91, 110, 0.14);
border: 1px solid rgba(255, 91, 110, 0.32);
color: $accent-live;
color: $live-text;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
@@ -207,7 +196,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
align-items: center;
gap: 11px;
padding: 10px 18px;
background: rgba(79, 142, 255, 0.07);
background: var(--app-selection-surface);
border-bottom: 1px solid $line;
color: $text-secondary;
font-size: 12.5px;
@@ -217,7 +206,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
width: 16px;
height: 16px;
font-size: 16px;
color: $accent-blue;
color: $accent-text;
flex: 0 0 auto;
}
@@ -236,7 +225,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
flex: 0 0 auto;
}
.cn-tag.live {
color: $accent-live;
color: $live-text;
}
.cn-tag.live::before {
content: '';
@@ -247,7 +236,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
box-shadow: 0 0 6px $accent-live;
}
.cn-tag.arch {
color: $accent-blue;
color: $accent-text;
}
.cn-tag mat-icon {
width: 12px;
@@ -272,7 +261,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
width: 84px;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
background: var(--app-separator);
overflow: hidden;
flex: 0 0 auto;
}
@@ -312,7 +301,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
padding: 9px 18px;
border: none;
border-bottom: 1px solid $line-strong;
background: rgba(13, 18, 26, 0.93);
background: $surface-1;
cursor: pointer;
text-align: left;
animation: epg-nowstrip-in 180ms ease;
@@ -347,7 +336,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $accent-live;
color: $live-text;
flex: 0 0 auto;
}
.ns-title {
@@ -364,7 +353,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
width: 84px;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
background: var(--app-separator);
overflow: hidden;
flex: 0 0 auto;
}
@@ -385,7 +374,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
gap: 4px;
font-size: 11.5px;
font-weight: 600;
color: $accent-blue;
color: $accent-text;
flex: 0 0 auto;
}
.ns-jump mat-icon {
@@ -413,9 +402,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
border-radius: 6px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0.06)
color-mix(in srgb, $text-primary 18%, transparent),
color-mix(in srgb, $text-primary 28%, transparent),
color-mix(in srgb, $text-primary 18%, transparent)
);
background-size: 200% 100%;
animation: epg-list-shimmer 1.4s linear infinite;
@@ -1,3 +1,5 @@
@use '../epg-theme' as *;
:host {
/* Fill the area below the toolbar and centre within THAT, instead of a fixed
min-height that overflowed the compact inline panel and pushed the icon /
@@ -9,7 +11,7 @@
width: 100%;
overflow: hidden;
padding: 14px 22px;
background: #0a0e15;
background: var(--app-content-bg);
}
.epg-empty {
@@ -23,9 +25,9 @@
display: grid;
place-items: center;
margin: 0 auto 10px;
background: var(--surface-2, #161d28);
border: 1px solid var(--line-strong, rgba(255, 255, 255, 0.1));
color: var(--text-tertiary, #6b7384);
background: $surface-2;
border: 1px solid $line-strong;
color: $text-tertiary;
mat-icon {
width: 24px;
@@ -36,26 +38,26 @@
&.tone-action {
background: rgba(79, 142, 255, 0.12);
border-color: rgba(79, 142, 255, 0.3);
color: var(--accent-blue, #4f8eff);
color: $accent-text;
}
&.tone-warn {
background: rgba(255, 91, 110, 0.1);
border-color: rgba(255, 91, 110, 0.28);
color: var(--accent-live, #ff5b6e);
color: $live-text;
}
}
&__title {
font-size: 15.5px;
font-weight: 600;
color: var(--text-primary, #e7ecf3);
color: $text-primary;
margin-bottom: 5px;
}
&__sub {
font-size: 13px;
color: var(--text-secondary, #9aa3b2);
color: $text-secondary;
line-height: 1.45;
}
@@ -1,15 +1,4 @@
$line: var(--line, rgba(255, 255, 255, 0.06));
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
$surface-1: var(--surface-1, #0f141c);
$surface-2: var(--surface-2, #161d28);
$surface-3: var(--surface-3, #1d2533);
$text-primary: var(--text-primary, #e7ecf3);
$text-secondary: var(--text-secondary, #9aa3b2);
$text-tertiary: var(--text-tertiary, #6b7384);
$accent-blue: var(--accent-blue, #4f8eff);
$accent-cyan: var(--accent-cyan, #5cd6ff);
$accent-live: var(--accent-live, #ff5b6e);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@use '../epg-theme' as *;
:host {
box-sizing: border-box;
@@ -45,7 +34,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
.epg-timeline__daydiv {
border-left: 1px solid rgba(79, 142, 255, 0.5);
border-left: 1px solid var(--app-selection-border);
span {
position: absolute;
@@ -55,7 +44,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: $accent-blue;
color: $accent-text;
white-space: nowrap;
text-transform: uppercase;
}
@@ -96,7 +85,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&.is-past {
opacity: 0.52;
opacity: 1;
background: transparent;
&:hover {
@@ -105,23 +94,23 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&.is-now {
background: rgba(79, 142, 255, 0.1);
background: var(--app-selection-surface);
border-color: $accent-blue;
opacity: 1;
.epg-timeline__block-title {
color: #fff;
color: $text-primary;
font-weight: 600;
}
}
&.is-playing {
background: rgba(79, 142, 255, 0.18);
background: var(--app-selection-surface-strong);
border-color: $accent-blue;
opacity: 1;
.epg-timeline__block-title {
color: #fff;
color: $text-primary;
font-weight: 600;
}
}
@@ -145,7 +134,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
pointer-events: none;
&--live {
background: rgba(92, 214, 255, 0.12);
background: var(--app-selection-surface);
border-right: 2px solid $accent-cyan;
}
}
@@ -269,7 +258,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
&.is-now .epg-timeline__block-title,
&.is-playing .epg-timeline__block-title {
color: #fff;
color: $text-primary;
}
}
@@ -313,7 +302,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
text-transform: uppercase;
&.is-now {
color: $accent-live;
color: $live-text;
&::before {
content: '';
@@ -326,7 +315,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&.is-arch {
color: $accent-blue;
color: $accent-text;
}
}
@@ -353,7 +342,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
&:hover {
border-color: $accent-blue;
color: $accent-blue;
color: $accent-text;
}
}
@@ -372,7 +361,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
place-items: center;
border: none;
border-radius: 7px;
background: rgba(8, 11, 17, 0.66);
background: $surface-2;
color: $text-secondary;
cursor: pointer;
opacity: 0;
@@ -445,7 +434,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
font-size: 9px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $accent-blue;
color: $accent-text;
white-space: nowrap;
}
@@ -480,7 +469,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
font-family: $font-mono;
font-size: 9.5px;
font-weight: 700;
color: #fff;
color: var(--app-widget-bg);
background: $accent-live;
padding: 1px 5px;
border-radius: 4px;
@@ -496,7 +485,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
background: $surface-1;
border: 1px solid $line-strong;
border-radius: var(--r-md, 10px);
box-shadow: 0 20px 44px rgba(0, 0, 0, 0.6);
box-shadow: var(--app-widget-shadow);
padding: 13px 14px;
pointer-events: none;
}
@@ -504,7 +493,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
.epg-timeline__pop-time {
font-family: $font-mono;
font-size: 10.5px;
color: $accent-cyan;
color: $accent-text;
margin-bottom: 5px;
}
@@ -1,15 +1,4 @@
$line: var(--line, rgba(255, 255, 255, 0.06));
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
$surface-1: var(--surface-1, #0f141c);
$surface-2: var(--surface-2, #161d28);
$surface-3: var(--surface-3, #1d2533);
$text-primary: var(--text-primary, #e7ecf3);
$text-secondary: var(--text-secondary, #9aa3b2);
$text-tertiary: var(--text-tertiary, #6b7384);
$accent-blue: var(--accent-blue, #4f8eff);
$accent-cyan: var(--accent-cyan, #5cd6ff);
$accent-live: var(--accent-live, #ff5b6e);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@use '../epg-theme' as *;
/* This app has no global border-box reset; the timeline relies on it. */
.epg-timeline,
@@ -22,7 +11,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
flex-direction: column;
height: 100%;
min-height: 0;
background: #0a0e15;
background: var(--app-content-bg);
}
.epg-timeline {
@@ -140,7 +129,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&.is-arch {
color: $accent-cyan;
color: $accent-text;
}
}
}
@@ -155,7 +144,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
border-radius: 999px;
background: rgba(255, 91, 110, 0.14);
border: 1px solid rgba(255, 91, 110, 0.32);
color: $accent-live;
color: $live-text;
font: inherit;
font-size: 12.5px;
font-weight: 600;
@@ -274,7 +263,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&.is-live {
color: $accent-live;
color: $live-text;
&::before {
content: '';
@@ -287,7 +276,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&.is-arch {
color: $accent-blue;
color: $accent-text;
}
}
@@ -311,7 +300,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
width: 84px;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
background: var(--app-separator);
overflow: hidden;
flex: 0 0 auto;
@@ -335,7 +324,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
align-items: center;
gap: 10px;
padding: 9px 16px;
background: rgba(79, 142, 255, 0.07);
background: var(--app-selection-surface);
border-bottom: 1px solid $line;
color: $text-secondary;
font-size: 12.5px;
@@ -345,7 +334,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
font-size: 16px;
width: 16px;
height: 16px;
color: $accent-blue;
color: $accent-text;
flex: 0 0 auto;
}
}
@@ -371,7 +360,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
input[type='range'] {
width: 96px;
accent-color: $accent-blue;
accent-color: $accent-text;
cursor: pointer;
}
}
@@ -382,7 +371,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
min-height: 96px;
overflow-x: auto;
overflow-y: hidden;
background: #0a0e15;
background: var(--app-content-bg);
}
.epg-timeline__ribbon > app-epg-timeline-track {
@@ -31,7 +31,7 @@
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
color: color-mix(in srgb, var(--mat-sys-on-surface) 70%, transparent);
color: var(--embedded-mpv-text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -67,7 +67,7 @@
max-width: 220px;
height: 34px;
padding: 0 14px;
color: var(--mat-sys-on-surface);
color: var(--embedded-mpv-text);
background: transparent;
border: 1px solid var(--embedded-mpv-border);
border-radius: 999px;
@@ -4,17 +4,27 @@
}
.embedded-mpv-player {
--embedded-mpv-accent: var(--mat-sys-primary, #7dd3fc);
--embedded-mpv-track: rgba(255, 255, 255, 0.28);
--embedded-mpv-thumb-ring: rgba(255, 255, 255, 0.95);
--embedded-mpv-glass: color-mix(
// The native-view dock is app chrome; overlays over video own a separate
// fixed contrast pair. Never mix a dark fallback with inherited app text.
--embedded-mpv-accent: var(--app-selection-color);
--embedded-mpv-track: color-mix(
in srgb,
var(--mat-sys-surface, #0f1620) 78%,
var(--app-on-surface) 28%,
transparent
);
--embedded-mpv-border: var(
--mat-sys-outline-variant,
rgba(255, 255, 255, 0.16)
--embedded-mpv-thumb-ring: var(--app-widget-bg);
--embedded-mpv-glass: var(--app-widget-bg);
--embedded-mpv-border: var(--app-separator);
--embedded-mpv-text: var(--app-on-surface);
--embedded-mpv-text-muted: color-mix(
in srgb,
var(--app-on-surface) 76%,
transparent
);
--embedded-mpv-disabled: color-mix(
in srgb,
var(--app-on-surface) 45%,
transparent
);
--embedded-mpv-controls-height: 64px;
@@ -22,7 +32,7 @@
display: block;
height: 100%;
min-height: 0;
background: var(--mat-sys-surface);
background: #000;
border-radius: inherit;
overflow: hidden;
}
@@ -48,15 +58,14 @@
position: absolute;
inset: 0;
min-height: 0;
background: var(--mat-sys-surface);
background: #000;
}
// Frame-copy engine: video frames are painted onto this canvas by the
// preload frame pump. The helper renders at the aspect-fit size of the
// video inside the viewport (no letterbox bars are baked into frames), so
// object-fit: contain centers the content and the sides show the app
// surface instead of TV-style black bars. Fullscreen keeps a black
// backdrop via the viewport rule above.
// object-fit: contain centers the content over the black video viewport in
// both windowed and fullscreen mode.
.embedded-mpv-player__frame-canvas {
position: absolute;
inset: 0;
@@ -78,14 +87,14 @@
place-content: center;
justify-items: center;
gap: 12px;
color: var(--mat-sys-on-surface);
color: var(--embedded-mpv-text);
font-size: 0.85rem;
pointer-events: none;
z-index: 1;
}
.embedded-mpv-player__loader span {
color: color-mix(in srgb, var(--mat-sys-on-surface) 80%, transparent);
color: var(--embedded-mpv-text-muted);
}
.embedded-mpv-player__stalled {
@@ -98,12 +107,12 @@
gap: 14px;
padding: 24px;
text-align: center;
color: var(--mat-sys-on-surface);
color: var(--embedded-mpv-text);
z-index: 1;
}
.embedded-mpv-player__stalled mat-icon {
color: var(--mat-sys-error);
.embedded-mpv-player__stalled > mat-icon {
color: var(--app-live-color);
font-size: 36px;
width: 36px;
height: 36px;
@@ -112,7 +121,7 @@
.embedded-mpv-player__stalled p {
margin: 0;
max-width: 360px;
color: color-mix(in srgb, var(--mat-sys-on-surface) 84%, transparent);
color: var(--embedded-mpv-text-muted);
}
.embedded-mpv-player__feedback {
@@ -124,7 +133,7 @@
align-items: center;
gap: 10px;
padding: 10px 18px;
color: var(--mat-sys-on-surface);
color: var(--embedded-mpv-text);
background: color-mix(in srgb, #000 65%, transparent);
border-radius: 999px;
font-size: 0.95rem;
@@ -165,7 +174,7 @@
inset: 16px 16px auto auto;
max-width: min(60%, 360px);
padding: 10px 12px;
color: var(--mat-sys-on-surface);
color: var(--embedded-mpv-text);
background: var(--embedded-mpv-glass);
border: 1px solid var(--embedded-mpv-border);
border-radius: 12px;
@@ -224,7 +233,7 @@
justify-content: space-between;
align-items: center;
gap: 12px;
color: color-mix(in srgb, var(--mat-sys-on-surface) 84%, transparent);
color: var(--embedded-mpv-text-muted);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
@@ -234,7 +243,7 @@
align-items: center;
gap: 6px;
min-width: 0;
color: color-mix(in srgb, var(--mat-sys-on-surface) 78%, transparent);
color: var(--embedded-mpv-text-muted);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
@@ -244,7 +253,7 @@
width: 16px;
height: 16px;
font-size: 16px;
color: var(--mat-sys-primary);
color: var(--embedded-mpv-accent);
}
.embedded-mpv-player__recording-status span {
@@ -255,12 +264,12 @@
}
.embedded-mpv-player__recording-status--active {
color: var(--mat-sys-error);
color: var(--app-live-color);
font-weight: 700;
}
.embedded-mpv-player__recording-status--active mat-icon {
color: var(--mat-sys-error);
color: var(--app-live-color);
}
.embedded-mpv-player__live-badge {
@@ -269,8 +278,8 @@
gap: 6px;
padding: 2px 8px;
border-radius: 4px;
color: var(--mat-sys-on-error);
background: var(--mat-sys-error);
color: var(--app-widget-bg);
background: var(--app-live-color);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -318,7 +327,7 @@
}
.embedded-mpv-player__record-button--active {
color: var(--mat-sys-error) !important;
color: var(--app-live-color);
}
.embedded-mpv-player__slider {
@@ -371,8 +380,8 @@
}
.embedded-mpv-player__slider:disabled::-webkit-slider-thumb {
background: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.6);
background: var(--embedded-mpv-disabled);
border-color: var(--embedded-mpv-disabled);
box-shadow: none;
}
@@ -405,8 +414,8 @@
}
.embedded-mpv-player__slider:disabled::-moz-range-thumb {
background: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.6);
background: var(--embedded-mpv-disabled);
border-color: var(--embedded-mpv-disabled);
box-shadow: none;
}
@@ -436,7 +445,7 @@
}
.embedded-mpv-player__volume-value {
color: color-mix(in srgb, var(--mat-sys-on-surface) 80%, transparent);
color: var(--embedded-mpv-text-muted);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
min-width: 34px;
@@ -444,13 +453,38 @@
white-space: nowrap;
}
.embedded-mpv-player :is(button[mat-icon-button]) {
color: var(--mat-sys-on-surface);
// Material-owned icons use their component tokens, including disabled state.
.embedded-mpv-player__controls {
--mat-icon-button-icon-color: var(--embedded-mpv-text);
--mat-icon-button-disabled-icon-color: var(--embedded-mpv-disabled);
--mat-icon-button-state-layer-color: var(--embedded-mpv-text);
color: var(--embedded-mpv-text);
}
.embedded-mpv-player :is(button[mat-icon-button]:hover) {
color: var(--mat-sys-on-surface);
background: color-mix(in srgb, var(--embedded-mpv-accent) 14%, transparent);
.embedded-mpv-player__controls button[mat-icon-button]:enabled:hover {
background: var(--app-hover-overlay);
}
.embedded-mpv-player__controls button[mat-icon-button]:focus-visible {
outline: 2px solid var(--embedded-mpv-accent);
outline-offset: -2px;
}
// These surfaces cover video, so their foreground and scrim stay paired.
.embedded-mpv-player__loader,
.embedded-mpv-player__stalled {
--embedded-mpv-text: #fff;
--embedded-mpv-text-muted: rgba(255, 255, 255, 0.85);
background: rgba(0, 0, 0, 0.82);
}
.embedded-mpv-player__stalled button {
--mat-button-outlined-label-text-color: #fff;
--mat-button-outlined-outline-color: rgba(255, 255, 255, 0.6);
}
.embedded-mpv-player__feedback {
color: #fff;
}
@media (max-width: 720px) {