mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(playback): apply themes to player and EPG panels
This commit is contained in:
1 parent
eba68d687e
commit
2a1c04f8a8
17 files changed
+803
-191
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+15
-25
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user