fix(ui): one watch-progress colour in app chrome and in the player (#1798)

This commit is contained in:
4gray authored and GitHub committed 2026-10-03 15:11:10 +02:00
1 parent a8dd1eaa97
commit 4adc3ba20f
23 files changed
+441 -20

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: ui
---
Watch progress has one colour again: your theme's accent on dashboard cards
and catalog posters, and the player's blue everywhere inside the player, so
the next episode's bar no longer turns red in the Up Next rail and the
fullscreen episode list.
+6
View File
@@ -104,6 +104,9 @@ html {
var(--app-selection-color) 34%,
transparent
);
// Watch progress in app chrome (rail cards, catalog capsules, the hero).
// Over video the player's fixed `--pc-progress` applies instead.
--app-progress-color: var(--app-selection-color);
// Live / on-air accent (broadcast indicator)
--app-live-color: #d12c2c;
@@ -190,6 +193,9 @@ html {
var(--app-selection-color) 44%,
transparent
);
// Re-declared so it resolves against this context's selection colour
// rather than inheriting the light value computed on `html`.
--app-progress-color: var(--app-selection-color);
// Live / on-air accent (broadcast indicator)
--app-live-color: #ff8f8f;
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// A custom property that reads another one is resolved where it is declared,
// so a token derived from a per-theme colour has to be declared again inside
// `.dark-theme`; inherited from `html`, it would keep the light value there.
const THEME = readFileSync(
resolve(process.cwd(), 'apps/web/src/m3-theme.scss'),
'utf8'
)
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
/** The body of the brace block that opens at `start` (an index of `{`). */
function block(source: string, start: number): string {
let depth = 0;
for (let index = start; index < source.length; index++) {
if (source[index] === '{') depth++;
if (source[index] === '}' && --depth === 0) {
return source.slice(start + 1, index);
}
}
throw new Error('Unbalanced braces in m3-theme.scss');
}
/** Light declarations (the `html` block minus `.dark-theme`) and dark ones. */
function themeContexts(): { light: string; dark: string } {
const darkStart = THEME.indexOf('.dark-theme {');
const htmlStart = THEME.lastIndexOf('html {', darkStart);
expect(darkStart).toBeGreaterThan(-1);
expect(htmlStart).toBeGreaterThan(-1);
const html = block(THEME, THEME.indexOf('{', htmlStart));
const dark = block(THEME, THEME.indexOf('{', darkStart));
return { light: html.replace(dark, ''), dark };
}
describe('m3-theme watch-progress token', () => {
it('declares --app-progress-color from the selection colour in both themes', () => {
const { light, dark } = themeContexts();
const progress =
/--app-progress-color:\s*var\(--app-selection-color\);/;
const selection = /--app-selection-color:\s*#[0-9a-f]{6};/i;
for (const context of [light, dark]) {
expect(context).toMatch(selection);
expect(context).toMatch(progress);
}
expect(light.match(selection)?.[0]).not.toBe(
dark.match(selection)?.[0]
);
});
});
@@ -211,7 +211,8 @@ becomes an "Up Next" episode rail (`app-up-next-rail`,
`libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts`).
The rail lists the currently playing episode (highlighted, click-inert)
followed by the rest of its season and a spillover into the following
seasons, with per-episode watch-progress bars from playback positions.
seasons, with per-episode watch-progress bars from playback positions,
drawn in the player's `--pc-progress` like the dock's Up next card.
- Data flow: the hosts (Xtream `SerialDetailsComponent`, Stalker
`StalkerSeriesViewComponent`) build the entries with
@@ -525,6 +525,39 @@ a cache read open and checks text contrast across live theme changes.
Channel preview progress and EPG current-program progress should stay visually aligned.
### Watch progress colour
A title's watch progress (its resume share, `progressPercent`) has exactly one
colour per context, and never a literal of its own:
- **App chrome** — dashboard rail cards and the hero, catalog grids and season
episodes (`app-progress-capsule`, and the season list rows' own fill):
`--app-progress-color`, declared per theme in `apps/web/src/m3-theme.scss`
as that theme's `--app-selection-color`, and declared again inside
`.dark-theme` because a derived custom property resolves where it is
declared. The capsule's green from 90 % marks a finished title; it is a
status, not progress.
- **Over video** — the dock timeline, the Up next card, the Up Next rail and
the fullscreen episode panel: the player's fixed `--pc-progress` (accent
blue `#4f8eff`), never an app token, because the player palette is
theme-independent (see Player And EPG Theme Boundaries). An episode
therefore reads the same in every player surface. The rail and the episode
panel render beside the controls host, outside its `--pc-*` scope, so they
declare the token on their own `:host` with the `progress-token` mixin of
`libs/ui/playback/src/lib/player-controls/_player-palette.scss`.
- ArtPlayer's legacy skin takes a colour string, not a custom property, so it
gets `PLAYER_PROGRESS_COLOR` (`player-palette.ts`), which a spec pins to the
Sass value.
- Live programme progress next to a LIVE marker (the dashboard's live rail and
live hero slides) keeps `--app-live-color`; EPG programme progress keeps the
fill described below.
Specs hold the rule in each owning project: `m3-theme.spec.ts` (web: the token
in both theme contexts), `player-progress.palette.spec.ts` (ui-playback),
`progress-capsule.component.spec.ts` and
`season-container.progress-colour.spec.ts` (components) and
`dashboard-progress-colour.spec.ts` (workspace-dashboard-feature).
### Track
- Height:
@@ -317,6 +317,10 @@ record glyph and the recording status (6.2:1 on the glass over a black
frame). They are literal on purpose: the overlay is
theme-independent (see the UI guidelines' player theme boundary), and the
app's `--app-selection-color` is a different blue that would fight the video.
Watch progress reads `--pc-progress`, the accent blue declared by the
`progress-token` mixin of `_player-palette.scss`; the Up Next rail and the
fullscreen episode panel include the same mixin, so one episode's bar has
one colour in every player surface (UI guidelines, "Watch progress colour").
- **Timeline row**: current time (`--pc-font-mono`, tabular) · drawn track
(`.player-controls__timeline-track` with one segment and an accent fill,
@@ -406,7 +410,7 @@ the native-view legacy dock keeps its plain slider.
Near the end of a series episode the dock shows an **"Up next" card**
(`app-player-up-next-card`, `data-test-id="player-controls-up-next"`) in the
bottom-right corner above the controls: the next episode's still (or its
`S01E03` label as a tile), a 3px accent progress line when it was partly
`S01E03` label as a tile), a 3px `--pc-progress` line when it was partly
watched, "Up next · in 7 min" and the title. The host supplies the item
through the optional `upNext` input (`PlayerUpNextItem { label, title,
thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows —
@@ -879,7 +883,7 @@ be.
rows: a 16:9 still or, without one, a large numeral tile so the no-TMDB
case still looks designed; the `S01E03` label, runtime, watched check or
"Now playing" marker; the title (label as fallback); a 3-line clamped
overview when there is one; a progress bar on the thumbnail. Rows are
overview when there is one; a `--pc-progress` bar on the thumbnail. Rows are
buttons inside `<li>`s of a `<ul>`, so their button role survives for
assistive technology. A loading season shows a spinner row, a loaded empty
one the season-empty copy, and an unanswered one a "could not be loaded"
@@ -1652,6 +1656,8 @@ libs/ui/playback/src/lib/player-controls/
├── player-controls.component.ts
├── player-controls.component.html
├── player-controls.component.scss
├── _player-palette.scss
├── player-palette.ts
├── controls-feedback.ts
├── controls-format.utils.ts
├── controls-layout.ts
@@ -0,0 +1,47 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ProgressCapsuleComponent } from './progress-capsule.component';
describe('ProgressCapsuleComponent', () => {
let fixture: ComponentFixture<ProgressCapsuleComponent>;
function render(progress: number): HTMLElement {
fixture = TestBed.createComponent(ProgressCapsuleComponent);
fixture.componentRef.setInput('progress', progress);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
it('fills the capsule to the watched share', () => {
const host = render(42);
expect(
host.querySelector<HTMLElement>('.progress-capsule__fill')?.style
.width
).toBe('42%');
expect(host.querySelector('.progress-capsule--watched')).toBeNull();
});
it('marks an item watched from 90 %', () => {
expect(
render(90).querySelector('.progress-capsule--watched')
).not.toBeNull();
});
// The capsule sits in app chrome (catalog grids, season episodes), so its
// fill is the theme's watch-progress token, like the dashboard rail card.
it('draws progress with the app watch-progress token', () => {
const source = readFileSync(
resolve(
process.cwd(),
'libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts'
),
'utf8'
);
const fill = source.match(/&__fill\s*\{([^}]*)\}/)?.[1] ?? '';
expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
expect(source).not.toMatch(/#e50914|#ff4d4d/i);
});
});
@@ -34,11 +34,7 @@ import {
&__fill {
height: 100%;
background: linear-gradient(
90deg,
#e50914 0%,
#ff4d4d 100%
);
background: var(--app-progress-color);
transition: width 0.3s ease-out;
border-radius: 0 2px 2px 0;
}
@@ -671,7 +671,7 @@ app-season-tabs {
&__progress-fill {
height: 100%;
background: var(--mat-sys-primary);
background: var(--app-progress-color);
border-radius: 2px;
transition: width 0.3s ease;
}
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// Watch progress in app chrome reads one theme token in both season view
// modes; the list rows draw their own fill rather than the grid's capsule
// (see the UI guidelines' Progress Bars section).
const DIR = resolve(
process.cwd(),
'libs/ui/components/src/lib/season-container'
);
function read(file: string): string {
return readFileSync(resolve(DIR, file), 'utf8');
}
describe('season container watch-progress colour', () => {
it('fills list rows with the app watch-progress token', () => {
const styles = read('season-container.component.scss');
const listItem = styles.slice(styles.indexOf('.episode-list-item {'));
const fill = listItem.match(/&__progress-fill\s*\{([^}]*)\}/)?.[1];
expect(read('season-container.component.html')).toContain(
'class="episode-list-item__progress-fill"'
);
expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
});
});
@@ -287,6 +287,8 @@ describe('ArtPlayerComponent', () => {
setting: true,
fullscreen: true,
fullscreenWeb: true,
// The skin's progress bar matches the shared dock's fill.
theme: '#4f8eff',
})
);
// The app-level legacy shortcuts own the keyboard instead of
@@ -15,6 +15,7 @@ import {
import Artplayer from 'artplayer';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import { releaseVideoPictureInPicture } from '../player-controls/web-video-picture-in-picture-lifecycle';
import { PLAYER_PROGRESS_COLOR } from '../player-controls/player-palette';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
type LegacyPlayerShortcuts,
@@ -179,7 +180,9 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
playsInline: true,
backdrop: true,
mutex: true,
theme: '#ff0000',
// Colours the vendor chrome (legacy skin only; shared controls
// hide it) with the player's progress colour, not a stray red.
theme: PLAYER_PROGRESS_COLOR,
...buildArtPlayerChrome(this.sharedControls),
customType: sourceSession.customType,
});
@@ -1,7 +1,12 @@
@use '../player-controls/player-palette' as palette;
// Stamped into the fullscreen panel's body, which is its single flex child;
// the list below is the body's only scroll owner. The panel wears the
// `dark-theme` context class, so the light-on-dark palette here is fixed.
// The panel sits beside the controls host, outside its `--pc-*` scope, so it
// declares the player's progress colour itself.
:host {
@include palette.progress-token;
display: flex;
flex-direction: column;
min-height: 0;
@@ -238,7 +243,7 @@
.episode-panel__progress-bar {
display: block;
height: 100%;
background: #e50914;
background: var(--pc-progress);
}
// ─── Text ────────────────────────────────────────────────────────────────────
@@ -0,0 +1,19 @@
// Player colours that surfaces outside the controls host need too. The
// controls declare their `--pc-*` palette on `app-player-controls`' `:host`;
// the Up Next rail and the fullscreen episode panel render beside that host,
// not inside it, so they include `progress-token` on their own `:host` to
// draw watch progress in the same colour as the dock.
//
// ArtPlayer's vendor chrome takes the same value as a string at construction:
// `PLAYER_PROGRESS_COLOR` in `player-palette.ts`, pinned to `$accent-blue` by
// `player-progress.palette.spec.ts`.
// Accent blue: the primary action and watch progress over video.
$accent-blue: #4f8eff;
// `--pc-progress`, the watch-progress fill over video. Literal like the rest
// of the overlay palette: the player is theme-independent, and the app's
// selection blue would change with the theme and fight the video.
@mixin progress-token {
--pc-progress: #{$accent-blue};
}
@@ -1,10 +1,15 @@
@use 'player-palette' as palette;
:host {
// Overlay palette. Deliberately literal and theme-independent: the shared
// controls always sit on video, and the UI guidelines keep them on one
// light-on-dark palette in both app themes. The values are the design
// tokens of the "Hybrid" controls concept (accent blue = primary action
// and progress; cyan = something is on; violet = a value was changed).
--pc-accent-blue: #4f8eff;
// Accent blue and the progress fill come from the shared player palette,
// which the Up Next rail and the fullscreen episode panel also read.
--pc-accent-blue: #{palette.$accent-blue};
@include palette.progress-token;
// Fills that carry a white glyph (play, active tune) use deeper blues:
// white on #4f8eff is only 3.2:1, too close to the 3:1 floor once a
// 1x display antialiases the glyph. These give 4.4:1 and 5.3:1.
@@ -0,0 +1,7 @@
/**
* The player's watch-progress colour (`--pc-progress`) for code that needs a
* colour string rather than a CSS custom property, such as ArtPlayer's
* `theme` option. It must equal `$accent-blue` in `_player-palette.scss`;
* `player-progress.palette.spec.ts` keeps the two in step.
*/
export const PLAYER_PROGRESS_COLOR = '#4f8eff';
@@ -0,0 +1,156 @@
import { readdirSync, readFileSync } from 'node:fs';
import { relative, resolve, sep } from 'node:path';
import { PLAYER_PROGRESS_COLOR } from './player-palette';
// Watch progress inside the player is one colour: the fixed `--pc-progress`,
// never the app theme and never a literal of its own. The same episode must
// read the same in the dock's Up next card, the Up Next rail and the
// fullscreen episode panel (see the UI guidelines' Progress Bars section).
const LIB_DIR = resolve(process.cwd(), 'libs/ui/playback/src/lib');
const CONTROLS_DIR = resolve(LIB_DIR, 'player-controls');
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
}
function listSources(dir: string, extension: string): string[] {
return readdirSync(dir, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith(extension))
.filter((file) => !/\.spec(-[a-z]+)?\.ts$/.test(file))
.map((file) => resolve(dir, file));
}
function read(file: string): string {
return stripComments(readFileSync(file, 'utf8'));
}
/** The declarations of the first rule whose selector list is exactly `selector`. */
function ruleBody(source: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return (
source.match(
new RegExp(`(?:^|[}\\s])${escaped}\\s*\\{([^}]*)\\}`)
)?.[1] ?? ''
);
}
const PALETTE = read(resolve(CONTROLS_DIR, '_player-palette.scss'));
const STYLESHEETS = listSources(LIB_DIR, '.scss');
const PROGRESS_FILLS: ReadonlyArray<[file: string, selector: string]> = [
[
'player-controls/player-timeline.component.scss',
'.player-controls__timeline-fill',
],
[
'player-controls/player-up-next-card.component.scss',
'.player-up-next__progress-bar',
],
[
'portal-inline-player/up-next-rail.component.scss',
'.up-next__progress-bar',
],
[
'fullscreen-episode-panel/fullscreen-episode-panel.component.scss',
'.episode-panel__progress-bar',
],
];
describe('player watch-progress colour', () => {
it('draws every progress fill inside the player with --pc-progress', () => {
for (const [file, selector] of PROGRESS_FILLS) {
expect({
file,
body: ruleBody(read(resolve(LIB_DIR, file)), selector),
}).toEqual({
file,
body: expect.stringMatching(
/background:\s*var\(--pc-progress\);/
),
});
}
});
it('leaves no other BEM progress fill on a colour of its own', () => {
const fills = STYLESHEETS.flatMap((file) =>
[
...read(file).matchAll(
/([^{}]*__progress-(?:bar|fill))\s*\{([^}]*)\}/g
),
].map(([, selector, body]) => ({
fill: `${relative(LIB_DIR, file)} ${selector.trim()}`,
background: body.match(
/background(?:-color)?:\s*([^;]+);/
)?.[1],
}))
);
expect(fills.length).toBeGreaterThanOrEqual(3);
expect(
fills.filter(
({ background }) => background !== 'var(--pc-progress)'
)
).toEqual([]);
});
it('declares --pc-progress wherever it is read outside the controls host', () => {
// A top-level `:host` block runs to the first unindented `}`; Sass
// interpolation (`#{...}`) inside it would end `ruleBody` too early.
const declaresOnHost = (source: string) =>
/@include palette\.progress-token;/.test(
source.match(/^:host\s*\{([\s\S]*?)^\}/m)?.[1] ?? ''
);
// The host declares the palette its own children (timeline, card) read.
expect(
declaresOnHost(
read(resolve(CONTROLS_DIR, 'player-controls.component.scss'))
)
).toBe(true);
const readers = STYLESHEETS.filter(
(file) =>
!file.startsWith(CONTROLS_DIR + sep) &&
read(file).includes('var(--pc-progress')
);
expect(readers.map((file) => relative(LIB_DIR, file)).sort()).toEqual([
'fullscreen-episode-panel/fullscreen-episode-panel.component.scss',
'portal-inline-player/up-next-rail.component.scss',
]);
for (const file of readers) {
expect({ file, declared: declaresOnHost(read(file)) }).toEqual({
file,
declared: true,
});
}
});
it('gives ArtPlayer the same colour as the Sass palette', () => {
const accent = PALETTE.match(/\$accent-blue:\s*(#[0-9a-f]{6});/i)?.[1];
expect(accent).toBeDefined();
expect(PALETTE).toMatch(
/@mixin progress-token\s*\{\s*--pc-progress:\s*#\{\$accent-blue\};\s*\}/
);
expect(PLAYER_PROGRESS_COLOR.toLowerCase()).toBe(accent?.toLowerCase());
expect(
read(resolve(LIB_DIR, 'art-player/art-player.component.ts'))
).toMatch(/theme:\s*PLAYER_PROGRESS_COLOR,/);
});
it('keeps the old progress reds out of the player', () => {
const sources = [
...STYLESHEETS,
...listSources(LIB_DIR, '.ts'),
...listSources(LIB_DIR, '.html'),
];
const offenders = sources
.filter((file) => /#e50914|#ff0000\b/i.test(read(file)))
.map((file) => relative(LIB_DIR, file));
expect(sources.length).toBeGreaterThan(50);
expect(offenders).toEqual([]);
});
});
@@ -63,7 +63,7 @@
.player-controls__timeline-fill {
height: 100%;
background: var(--pc-accent-blue);
background: var(--pc-progress);
border-radius: inherit;
}
@@ -75,7 +75,8 @@
margin: -7px 0 0 -7px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3);
box-shadow: 0 0 0 5px
color-mix(in srgb, var(--pc-progress) 30%, transparent);
pointer-events: none;
transition: transform 120ms ease-out;
}
@@ -228,6 +229,7 @@
width: 11px;
height: 11px;
margin: -5.5px 0 0 -5.5px;
box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.3);
box-shadow: 0 0 0 4px
color-mix(in srgb, var(--pc-progress) 30%, transparent);
}
}
@@ -106,7 +106,7 @@
.player-up-next__progress-bar {
display: block;
height: 100%;
background: var(--pc-accent-blue, #4f8eff);
background: var(--pc-progress);
}
.player-up-next__meta {
@@ -1,7 +1,11 @@
@use '../player-controls/player-palette' as palette;
// Opaque panel docked on the stage's right column — it sits on top of the
// black stage (or the ambient fill), so its own dark surface works for both
// app themes without :host-context branching.
// app themes without :host-context branching. Watch progress takes the
// player's `--pc-progress`, so an episode's bar matches the dock's Up next card.
:host {
@include palette.progress-token;
display: block;
height: 100%;
min-height: 0;
@@ -112,7 +116,7 @@
.up-next__progress-bar {
display: block;
height: 100%;
background: #e50914;
background: var(--pc-progress);
}
.up-next__meta {
@@ -342,7 +342,7 @@
width: 100%;
height: 100%;
border-radius: inherit;
background: var(--app-selection-color, #78adff);
background: var(--app-progress-color);
// A full-width fill slid into place, neither a width nor animated: a
// live-EPG tick moves it by under one percent, so an animation was
// invisible yet cost a layout pass (width) or a burst of compositor
@@ -0,0 +1,40 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// Watch progress in app chrome reads one theme token, so a title's bar on a
// rail card, in the hero and in a catalog grid is the same colour. Live
// programme progress keeps the live accent (see the UI guidelines).
function styles(file: string): string {
return readFileSync(resolve(__dirname, file), 'utf8');
}
/** The declarations of the first `selector { … }` rule, nesting excluded. */
function ruleBody(source: string, selector: RegExp): string {
return (
source.match(new RegExp(`${selector.source}\\s*\\{([^}]*)\\}`))?.[1] ??
''
);
}
describe('dashboard watch-progress colour', () => {
it('fills the rail card artwork bar with the watch-progress token', () => {
const rail = styles('dashboard-rail.component.scss');
const bar = rail.slice(rail.indexOf('.rail__art-progress {'));
expect(ruleBody(bar, /\bi/)).toMatch(
/background:\s*var\(--app-progress-color\);/
);
});
it('fills the hero resume bar with the token and live bars with the live accent', () => {
const hero = styles('dashboard-hero.component.scss');
const bar = hero.slice(hero.indexOf('.hero__progress {'));
expect(ruleBody(bar, /\bi/)).toMatch(
/background:\s*var\(--app-progress-color\);/
);
expect(ruleBody(bar, /&--live i/)).toMatch(
/background:\s*var\(--app-live-color\b/
);
});
});
@@ -273,7 +273,7 @@
i {
display: block;
height: 100%;
background: var(--app-selection-color, #78adff);
background: var(--app-progress-color);
transition: width 0.4s ease;
}
}