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

+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
@@ -209,6 +209,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
@@ -835,6 +835,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,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()
);
}
}
+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.
+28 -1
View File
@@ -79,6 +79,33 @@ 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. 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:
@@ -470,7 +497,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 {
@@ -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) {