diff --git a/.changes/playback-light-theme-panels.md b/.changes/playback-light-theme-panels.md new file mode 100644 index 000000000..908c75598 --- /dev/null +++ b/.changes/playback-light-theme-panels.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index f9c07e19d..191931d5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 10ce63485..335351e87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts index 7d214dbf2..e89f5e484 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -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); } diff --git a/apps/electron-backend-e2e/src/player-theme.e2e.ts b/apps/electron-backend-e2e/src/player-theme.e2e.ts new file mode 100644 index 000000000..d7df3f324 --- /dev/null +++ b/apps/electron-backend-e2e/src/player-theme.e2e.ts @@ -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(); + } + }); +} diff --git a/apps/electron-backend-e2e/src/theme-contrast.ts b/apps/electron-backend-e2e/src/theme-contrast.ts new file mode 100644 index 000000000..ab8ac2cf0 --- /dev/null +++ b/apps/electron-backend-e2e/src/theme-contrast.ts @@ -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); + } +} diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index c85ddafeb..59b1497f4 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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=` passes through to mpv's `audio-delay` for lip-sync tuning until a calibration flow exists. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index b904d9f19..a1e1963e3 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/libs/ui/epg/src/lib/_epg-theme.scss b/libs/ui/epg/src/lib/_epg-theme.scss new file mode 100644 index 000000000..454f6634a --- /dev/null +++ b/libs/ui/epg/src/lib/_epg-theme.scss @@ -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); diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss index 210f93bdb..b65314b1c 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss @@ -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); diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.scss b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.scss index a9c06d62a..4f08f17a9 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.scss +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.scss @@ -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); } diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss index ecc2cf272..a925d4c47 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss @@ -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; diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.scss index 3f59d614a..d6adfff44 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.scss +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.scss @@ -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; } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss index 57cb96ef6..7f44db599 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss @@ -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; } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss index 70421dd4c..3b5cc030b 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss @@ -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 { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss index d4a021e3f..2c58dfd22 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss @@ -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; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss index 3c4151932..ccb225b06 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss @@ -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) {