feat(epg): compact timeline toolbar with icon-only Now and cycling zoom (#1557)

* feat(epg): compact timeline toolbar with icon-only Now and cycling zoom

Replace the 96px zoom range slider with a single icon button that cycles
day overview → by hour → detailed, make the Now button icon-only (label in
tooltip + aria-label), and add Ctrl/⌘ + wheel zoom around the cursor on the
ribbon (preventDefault keeps Chromium from page-zooming the same gesture).
Zoom math moves into TimelineZoomController so the component stays under
the max-lines limit. The channel/programme heading no longer carries a 60%
width cap and takes the space the controls free up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): coalesce wheel-zoom anchoring and make the e2e zoom reset deterministic

- TimelineZoomController keeps a logical pending scrollLeft across a burst
  of wheel/pinch events and applies it in one animation frame, so every
  event anchors on the same minute instead of the stale DOM position
- the e2e settles on the detail preset from whichever band the wheel
  landed in (contrast checks need the block's time line, hidden at
  narrower tiers)
- zoom tooltip advertises ⌘/Ctrl per the repo's shortcut convention

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-06 10:34:28 +02:00
1 parent 61b06b9f31
commit ef2418a2c5
30 files changed
+580 -123

No files matched your search

@@ -0,0 +1,6 @@
---
type: feature
area: epg
---
The EPG timeline toolbar is more compact: "Now" is an icon button, and the zoom slider is replaced by a single button that cycles day overview → by hour → detailed. Ctrl/⌘ + scroll (or a trackpad pinch) over the timeline zooms smoothly around the cursor. The channel and programme title now use all the space the controls free up.
@@ -78,25 +78,60 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
.first();
await expect(nowBlock).toBeVisible();
// Zooming re-renders the ribbon: block widths grow with px/minute.
const zoomInput = timeline.locator(
'.epg-timeline__zoom input[type="range"]'
// The zoom button cycles hours → detail → day; block widths follow
// px/minute, so "detail" must render the same block wider than "day".
const zoomButton = timeline.locator('.epg-timeline__zoom');
await expect(zoomButton).toBeVisible();
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'hours');
const blockWidth = async () =>
(await nowBlock.boundingBox())?.width ?? 0;
await zoomButton.click();
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
const detailZoomWidth = await blockWidth();
await zoomButton.click();
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'day');
const dayZoomWidth = await blockWidth();
expect(detailZoomWidth).toBeGreaterThan(dayZoomWidth);
// Ctrl + wheel over the ribbon fine-tunes the zoom (and must not
// trigger Chromium's page zoom, which would scale the whole window).
const ribbon = timeline.locator('.epg-timeline__ribbon');
await ribbon.hover({ position: { x: 200, y: 40 } });
const pageZoomBefore = await app.mainWindow.evaluate(
() => window.devicePixelRatio
);
await expect(zoomInput).toBeVisible();
await app.mainWindow.keyboard.down('Control');
await app.mainWindow.mouse.wheel(0, -300);
await app.mainWindow.keyboard.up('Control');
await expect
.poll(blockWidth, { timeout: 5000 })
.toBeGreaterThan(dayZoomWidth);
// Chromium page zoom scales devicePixelRatio; the ribbon must have
// swallowed the gesture instead.
expect(
await app.mainWindow.evaluate(() => window.devicePixelRatio)
).toBe(pageZoomBefore);
const blockWidthAt = async (zoom: 'min' | 'max') => {
await zoomInput.evaluate((element, target) => {
const input = element as HTMLInputElement;
input.value = target === 'min' ? input.min : input.max;
input.dispatchEvent(new Event('input', { bubbles: true }));
}, zoom);
const box = await nowBlock.boundingBox();
return box?.width ?? 0;
// The contrast checks below read the block's time line, which the
// narrower tiers hide, so settle on the "detail" preset. The
// wheel-tuned scale may sit in any band (Chromium scales the delivered
// delta), so cycle from wherever it landed rather than assuming a
// fixed number of clicks.
const clicksToDetail: Record<string, number> = {
detail: 0,
day: 2,
hours: 1,
};
const minZoomWidth = await blockWidthAt('min');
const maxZoomWidth = await blockWidthAt('max');
expect(maxZoomWidth).toBeGreaterThan(minZoomWidth);
const landedLevel =
(await zoomButton.getAttribute('data-zoom-level')) ?? '';
expect(Object.keys(clicksToDetail)).toContain(landedLevel);
for (let i = 0; i < clicksToDetail[landedLevel]; i += 1) {
await zoomButton.click();
}
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
for (const theme of ['light', 'dark', 'light'] as const) {
await applyTheme(app.mainWindow, theme);
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "نظرة عامة على اليوم",
"ZOOM_HOURS": "حسب الساعة",
"ZOOM_DETAIL": "تفصيلي",
"ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير للضبط الدقيق",
"GROUP_SHORT": "برامج قصيرة",
"GROUP_EXPAND": "عرض ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "نظرة عامة على النهار",
"ZOOM_HOURS": "بالساعة",
"ZOOM_DETAIL": "مفصّل",
"ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير باش تضبط",
"GROUP_SHORT": "برامج قصيرة",
"GROUP_EXPAND": "وري ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Агляд дня",
"ZOOM_HOURS": "Па гадзінах",
"ZOOM_DETAIL": "Падрабязна",
"ZOOM_TOOLTIP": "Маштаб: {{level}} · ⌘/Ctrl + кола для дакладнай наладкі",
"GROUP_SHORT": "кароткія перадачы",
"GROUP_EXPAND": "паказаць ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Tagesübersicht",
"ZOOM_HOURS": "Stundenweise",
"ZOOM_DETAIL": "Detailliert",
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Strg + Scrollen zum Feinabstimmen",
"GROUP_SHORT": "kurze Sendungen",
"GROUP_EXPAND": "anzeigen ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Επισκόπηση ημέρας",
"ZOOM_HOURS": "Ανά ώρα",
"ZOOM_DETAIL": "Λεπτομερής",
"ZOOM_TOOLTIP": "Ζουμ: {{level}} · ⌘/Ctrl + κύλιση για λεπτομερή ρύθμιση",
"GROUP_SHORT": "σύντ. προγρ.",
"GROUP_EXPAND": "εμφάνιση ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Day overview",
"ZOOM_HOURS": "By hour",
"ZOOM_DETAIL": "Detailed",
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + scroll to fine-tune",
"GROUP_SHORT": "short progs",
"GROUP_EXPAND": "show ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Vista del día",
"ZOOM_HOURS": "Por hora",
"ZOOM_DETAIL": "Detallado",
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + rueda para ajustar",
"GROUP_SHORT": "progs. cortos",
"GROUP_EXPAND": "ver ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Vue de la journée",
"ZOOM_HOURS": "Par heure",
"ZOOM_DETAIL": "Détaillé",
"ZOOM_TOOLTIP": "Zoom : {{level}} · ⌘/Ctrl + molette pour ajuster",
"GROUP_SHORT": "progs courts",
"GROUP_EXPAND": "afficher ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Napi áttekintés",
"ZOOM_HOURS": "Óránként",
"ZOOM_DETAIL": "Részletes",
"ZOOM_TOOLTIP": "Nagyítás: {{level}} · ⌘/Ctrl + görgetés a finomhangoláshoz",
"GROUP_SHORT": "rövid műsorok",
"GROUP_EXPAND": "megjelenítés ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Panoramica giorno",
"ZOOM_HOURS": "Per ora",
"ZOOM_DETAIL": "Dettagliato",
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + rotella per regolare",
"GROUP_SHORT": "prog. brevi",
"GROUP_EXPAND": "mostra ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "1日表示",
"ZOOM_HOURS": "時間単位",
"ZOOM_DETAIL": "詳細",
"ZOOM_TOOLTIP": "ズーム: {{level}} · ⌘/Ctrl + スクロールで微調整",
"GROUP_SHORT": "短い番組",
"GROUP_EXPAND": "表示 ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "하루 보기",
"ZOOM_HOURS": "시간별",
"ZOOM_DETAIL": "상세",
"ZOOM_TOOLTIP": "확대/축소: {{level}} · ⌘/Ctrl + 스크롤로 미세 조정",
"GROUP_SHORT": "짧은 프로그램",
"GROUP_EXPAND": "보기 ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Dagoverzicht",
"ZOOM_HOURS": "Per uur",
"ZOOM_DETAIL": "Gedetailleerd",
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + scrollen om fijn af te stellen",
"GROUP_SHORT": "korte progr.",
"GROUP_EXPAND": "toon ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Przegląd dnia",
"ZOOM_HOURS": "Co godzinę",
"ZOOM_DETAIL": "Szczegółowo",
"ZOOM_TOOLTIP": "Powiększenie: {{level}} · ⌘/Ctrl + kółko, aby dostroić",
"GROUP_SHORT": "krótkie progr.",
"GROUP_EXPAND": "pokaż ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Visão geral do dia",
"ZOOM_HOURS": "Por hora",
"ZOOM_DETAIL": "Detalhado",
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + roda para ajustar",
"GROUP_SHORT": "progs. curtos",
"GROUP_EXPAND": "mostrar ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Обзор дня",
"ZOOM_HOURS": "По часам",
"ZOOM_DETAIL": "Детально",
"ZOOM_TOOLTIP": "Масштаб: {{level}} · ⌘/Ctrl + колесо для точной настройки",
"GROUP_SHORT": "короткие передачи",
"GROUP_EXPAND": "показать ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "Gün özeti",
"ZOOM_HOURS": "Saatlik",
"ZOOM_DETAIL": "Detaylı",
"ZOOM_TOOLTIP": "Yakınlaştırma: {{level}} · İnce ayar için ⌘/Ctrl + kaydırma",
"GROUP_SHORT": "kısa progr.",
"GROUP_EXPAND": "göster ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "全天概览",
"ZOOM_HOURS": "按小时",
"ZOOM_DETAIL": "详细",
"ZOOM_TOOLTIP": "缩放:{{level}} · ⌘/Ctrl + 滚轮微调",
"GROUP_SHORT": "短节目",
"GROUP_EXPAND": "展开 ›"
}
+1
View File
@@ -837,6 +837,7 @@
"ZOOM_DAY": "日總覽",
"ZOOM_HOURS": "依小時",
"ZOOM_DETAIL": "詳細",
"ZOOM_TOOLTIP": "縮放:{{level}} · ⌘/Ctrl + 滾輪微調",
"GROUP_SHORT": "短節目",
"GROUP_EXPAND": "顯示 ›"
}
+13 -4
View File
@@ -836,12 +836,22 @@ activation, and the details dialog behave identically to the timeline.
(**vertical title**, no time) → `micro` (just a marker); (C) a **hover/focus
popover** revealing the full title + time + description for any non-`wide`
block (it flips above the block when the panel is near the screen bottom);
(D) a px-per-minute **zoom** slider (tick density adapts via
`timelineTickStepForScale`); and (E) **grouping** of ≥4 consecutive short
(D) a px-per-minute **zoom** (tick density adapts via
`timelineTickStepForScale`): the toolbar's icon-only zoom button cycles
three presets — day overview (`1`) → by hour (`1.75`, the default) →
detailed (`3.4`, the scale a group chip expands to) — snapping a
wheel-tuned scale to its band's successor (`TIMELINE_ZOOM_LEVELS`,
`nextTimelineZoomScale`), while Ctrl/⌘ + wheel (and trackpad pinch) over
the ribbon zooms continuously around the cursor within
`TIMELINE_ZOOM_MIN..MAX` and `preventDefault`s so Chromium never page-zooms
the same gesture; the current level lives in the tooltip/`aria-label` and
a `data-zoom-level` attribute; and (E) **grouping** of ≥4 consecutive short
(<10 min) programmes into one dashed "N short" chip when zoomed out
(`scale < TIMELINE_GROUP_ZOOM_MAX`), expanded by clicking it. The ribbon
canvas lives in the child `app-epg-timeline-track`; the parent owns the
scroller, toolbar (incl. the zoom slider) and state.
scroller, toolbar (icon-only "Now" + zoom buttons, both labelled through
tooltip + `aria-label`, so the channel/programme heading takes every pixel
the fixed-width controls leave) and state.
- **Panel height & titles.** Block titles wrap onto as many lines as the card
height allows and are clipped (not single-line ellipsis); the foot ("ON NOW"
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
@@ -1452,7 +1462,6 @@ resolve another retained source sharing that channel ID. Legacy programmes with
unknown (`NULL`) ownership are conservatively left alone; the existing database
initialization backfill handles rows whose channel still identifies their owner.
Renderer reconciliation fences lookups before its first asynchronous step.
Imports wait for serialized reconciliation (including playlist migration), then
filter against its committed owner set. Completion increments the data revision again
@@ -11,10 +11,30 @@ import { TIMELINE_MINUTE_MS, TimelineBlock } from './epg-timeline.utils';
export const TIMELINE_MIN_BLOCK_WIDTH_PX = 40;
/** Gap subtracted from each block so neighbours don't touch. */
export const TIMELINE_BLOCK_GAP_PX = 4;
/** Zoom (px/min) slider bounds. */
/** Zoom (px/min) bounds for the Ctrl/⌘ + wheel fine-tune gesture. */
export const TIMELINE_ZOOM_MIN = 0.8;
export const TIMELINE_ZOOM_MAX = 6;
export const TIMELINE_ZOOM_STEP = 0.1;
/**
* Multiplicative zoom per wheel pixel: one mouse notch (~100px) is about
* ×1.22, and a trackpad pinch (a few px per event) stays smooth.
*/
export const TIMELINE_WHEEL_ZOOM_RATE = 0.002;
export type TimelineZoomLevel = 'day' | 'hours' | 'detail';
/**
* The three zoom presets the toolbar button cycles through. `hours` is the
* default scale; `detail` matches the zoom a group chip expands to, so the
* cycle keeps reading consistently after an expand.
*/
export const TIMELINE_ZOOM_LEVELS: readonly {
readonly level: TimelineZoomLevel;
readonly scale: number;
}[] = [
{ level: 'day', scale: 1 },
{ level: 'hours', scale: 1.75 },
{ level: 'detail', scale: 3.4 },
];
/** Below this zoom, dense runs of short programmes collapse into a group chip. */
export const TIMELINE_GROUP_ZOOM_MAX = 1.3;
/** Zoom applied when a group chip is expanded. */
@@ -69,6 +89,24 @@ export function tierFor(widthPx: number): TimelineTier {
return 'micro';
}
/** Which preset band a (possibly wheel-tuned) scale falls into. */
export function timelineZoomLevelForScale(scale: number): TimelineZoomLevel {
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'day';
if (scale < 3) return 'hours';
return 'detail';
}
/** The preset after the current band, wrapping detail → day. */
export function nextTimelineZoomScale(scale: number): number {
const current = timelineZoomLevelForScale(scale);
const index = TIMELINE_ZOOM_LEVELS.findIndex(
(entry) => entry.level === current
);
const next =
TIMELINE_ZOOM_LEVELS[(index + 1) % TIMELINE_ZOOM_LEVELS.length];
return next.scale;
}
/** Adaptive tick spacing (minutes) — denser as the user zooms in. */
export function timelineTickStepForScale(scale: number): number {
if (scale < 2.2) return 120;
@@ -151,7 +189,8 @@ export function buildTimelineRenderItems(
if (run.length >= TIMELINE_GROUP_MIN_RUN) {
const first = run[0];
const last = run[run.length - 1];
const spanPx = ((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
const spanPx =
((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
items.push({
kind: 'group',
key: `group-${first.key}-${last.key}`,
@@ -0,0 +1,125 @@
import { TimelineZoomController } from './epg-timeline-zoom.controller';
import {
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
} from './epg-timeline-render.util';
function fakeScroller(clientWidth: number, scrollLeft: number): HTMLElement {
return {
clientWidth,
scrollLeft,
getBoundingClientRect: () => ({ left: 100 }),
} as unknown as HTMLElement;
}
describe('TimelineZoomController', () => {
let scale: number;
let scroller: HTMLElement | undefined;
let controller: TimelineZoomController;
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
scale = 2;
scroller = fakeScroller(600, 1200);
rafCallbacks = [];
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
controller = new TimelineZoomController({
ribbon: () => scroller,
scale: () => scale,
setScale: (next) => (scale = next),
});
});
afterEach(() => jest.restoreAllMocks());
function flushFrames(): void {
for (const cb of rafCallbacks.splice(0)) cb(0);
}
it('keeps the viewport centre minute fixed on a programmatic zoom', () => {
// centre = (1200 + 300) / 2 px/min = 750 min
controller.zoomTo(4);
flushFrames();
expect(scale).toBe(4);
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
});
it('clamps to the zoom bounds and ignores non-finite input', () => {
controller.zoomTo(99);
expect(scale).toBe(TIMELINE_ZOOM_MAX);
controller.zoomTo(-1);
expect(scale).toBe(TIMELINE_ZOOM_MIN);
controller.zoomTo(Number.NaN);
expect(scale).toBe(TIMELINE_ZOOM_MIN);
});
it('does nothing when the clamped scale is unchanged', () => {
controller.zoomTo(2);
expect(rafCallbacks).toHaveLength(0);
});
it('zooms around the cursor on Ctrl + wheel', () => {
// cursor 150px into the 600px viewport → anchor 0.25
const event = new WheelEvent('wheel', {
deltaY: -100,
ctrlKey: true,
clientX: 250,
cancelable: true,
});
controller.onWheel(event);
flushFrames();
expect(event.defaultPrevented).toBe(true);
expect(scale).toBeCloseTo(2 * Math.exp(0.2), 6);
// minute under the cursor before: (1200 + 150) / 2 = 675
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
});
it('keeps the cursor minute anchored across a burst of wheel events', () => {
const burst = () =>
new WheelEvent('wheel', {
deltaY: -50,
ctrlKey: true,
clientX: 250,
cancelable: true,
});
// Three events before any frame runs: the DOM scrollLeft is stale for
// the 2nd and 3rd, so the anchor must come from the logical position.
controller.onWheel(burst());
controller.onWheel(burst());
controller.onWheel(burst());
expect(rafCallbacks).toHaveLength(1); // coalesced into one frame
flushFrames();
// minute under the cursor before the burst: (1200 + 150) / 2 = 675
expect(scale).toBeCloseTo(2 * Math.exp(0.3), 6);
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
});
it('re-anchors on the DOM position once a frame has flushed', () => {
controller.zoomTo(4);
flushFrames();
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
controller.zoomTo(2);
flushFrames();
expect(scroller?.scrollLeft).toBe(750 * 2 - 300);
});
it('leaves plain wheel events alone', () => {
const event = new WheelEvent('wheel', {
deltaY: -100,
cancelable: true,
});
controller.onWheel(event);
expect(event.defaultPrevented).toBe(false);
expect(scale).toBe(2);
});
it('zooms without a rendered ribbon', () => {
scroller = undefined;
controller.cycle();
expect(scale).toBe(3.4);
expect(rafCallbacks).toHaveLength(0);
});
});
@@ -0,0 +1,102 @@
import {
nextTimelineZoomScale,
TIMELINE_WHEEL_ZOOM_RATE,
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
} from './epg-timeline-render.util';
export interface TimelineZoomContext {
/** The horizontal scroller hosting the track (undefined until rendered). */
readonly ribbon: () => HTMLElement | undefined;
readonly scale: () => number;
readonly setScale: (scale: number) => void;
}
/**
* Ribbon zoom (px per minute) owned by the toolbar button and the
* Ctrl/⌘ + wheel gesture. Every change keeps one ribbon minute anchored on
* screen: the viewport centre for button/programmatic zooms, the cursor for
* wheel zooms.
*/
export class TimelineZoomController {
/**
* Scroll position the next animation frame will apply. Rapid wheel/pinch
* events arrive faster than frames, so the DOM `scrollLeft` still reflects
* the previous scale by the time the next event computes its anchor; the
* logical position keeps every event anchored on the same minute.
*/
private pendingScrollLeft: number | null = null;
private frame = 0;
constructor(private readonly ctx: TimelineZoomContext) {}
/** Clamp + apply a scale, keeping the viewport centre stable. */
zoomTo(value: number): void {
this.applyScale(value, 0.5);
}
/** Toolbar zoom button: step to the next preset (day → hours → detail). */
cycle(): void {
this.applyScale(nextTimelineZoomScale(this.ctx.scale()), 0.5);
}
/**
* Ctrl/⌘ + wheel (and trackpad pinch, which Chromium reports the same
* way) zooms around the cursor. Plain wheel keeps scrolling.
* `preventDefault` also stops Chromium's page zoom for the same gesture.
*/
onWheel(event: WheelEvent): void {
if (!event.ctrlKey && !event.metaKey) {
return;
}
event.preventDefault();
const scroller = this.ctx.ribbon();
const anchor = scroller
? (event.clientX - scroller.getBoundingClientRect().left) /
scroller.clientWidth
: 0.5;
const factor = Math.exp(-event.deltaY * TIMELINE_WHEEL_ZOOM_RATE);
this.applyScale(this.ctx.scale() * factor, anchor);
}
/**
* Set the scale so the ribbon minute under `anchorFrac` (0 = left edge,
* 1 = right edge of the viewport) stays put.
*/
private applyScale(value: number, anchorFrac: number): void {
const requested = Number(value);
const prev = this.ctx.scale();
const next = Number.isFinite(requested)
? Math.min(
TIMELINE_ZOOM_MAX,
Math.max(TIMELINE_ZOOM_MIN, requested)
)
: prev;
if (next === prev) {
return;
}
const scroller = this.ctx.ribbon();
this.ctx.setScale(next);
if (!scroller) {
return;
}
const anchorPx = scroller.clientWidth * anchorFrac;
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
const anchorMin = (currentLeft + anchorPx) / prev;
this.pendingScrollLeft = anchorMin * next - anchorPx;
if (this.frame === 0) {
this.frame = requestAnimationFrame(() => this.flushScroll());
}
}
/** One frame applies the last coalesced position for a burst of events. */
private flushScroll(): void {
this.frame = 0;
const left = this.pendingScrollLeft;
this.pendingScrollLeft = null;
const scroller = this.ctx.ribbon();
if (scroller && left !== null) {
scroller.scrollLeft = left;
}
}
}
@@ -55,7 +55,10 @@
summary()?.title
}}</span>
@if (progress() !== null) {
<span class="epg-timeline__summary-bar" aria-hidden="true">
<span
class="epg-timeline__summary-bar"
aria-hidden="true"
>
<i [style.width.%]="progress()"></i>
</span>
}
@@ -99,29 +102,26 @@
@if (showRibbonControls()) {
<button
type="button"
class="epg-timeline__jump"
class="epg-timeline__iconbtn epg-timeline__jump"
(click)="jumpToNow()"
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
[attr.aria-label]="'EPG.TIMELINE.JUMP_NOW' | translate"
>
<mat-icon>my_location</mat-icon>
{{ 'EPG.TIMELINE.NOW' | translate }}
</button>
<label
class="epg-timeline__zoom"
[matTooltip]="zoomLabelKey() | translate"
@let zoomTooltip =
'EPG.TIMELINE.ZOOM_TOOLTIP'
| translate: { level: (zoomLabelKey() | translate) };
<button
type="button"
class="epg-timeline__iconbtn epg-timeline__zoom"
[attr.data-zoom-level]="zoomLevel()"
(click)="cycleZoom()"
[matTooltip]="zoomTooltip"
[attr.aria-label]="zoomTooltip"
>
<mat-icon>zoom_in</mat-icon>
<input
#zoomInput
type="range"
[min]="zoomMin"
[max]="zoomMax"
[step]="zoomStep"
[value]="scale()"
(input)="onZoom(zoomInput.valueAsNumber)"
[attr.aria-label]="'EPG.TIMELINE.ZOOM' | translate"
/>
</label>
<mat-icon>{{ zoomIcon() }}</mat-icon>
</button>
}
@if (showDateStepper()) {
<div class="epg-timeline__stepper">
@@ -145,7 +145,8 @@
}
</span>
<small>{{
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
viewDate()
| date: 'EEE, d MMM' : '' : currentLocale()
}}</small>
</div>
<button
@@ -166,13 +167,17 @@
@if (state === 'ribbon' && !archivePlaybackAvailable()) {
<div class="epg-timeline__notice" role="note">
<mat-icon>info</mat-icon>
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
<span>{{
'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate
}}</span>
</div>
}
@switch (state) {
@case ('loading') {
<div class="epg-timeline__ribbon epg-timeline__ribbon--skeleton">
<div
class="epg-timeline__ribbon epg-timeline__ribbon--skeleton"
>
@for (width of skeletonWidths; track $index) {
<div
class="epg-timeline__sk"
@@ -186,6 +191,7 @@
#ribbon
class="epg-timeline__ribbon app-scrollbar"
(scroll)="onRibbonScroll()"
(wheel)="onRibbonWheel($event)"
>
<app-epg-timeline-track
[items]="renderItems()"
@@ -53,8 +53,10 @@
padding: 4px 6px;
border-radius: 7px;
font: inherit;
/* Shrinks only when the toolbar overflows: the channel + programme title
take every pixel the fixed-width controls leave (no hard width cap). */
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
&:hover {
background: $surface-2;
@@ -161,32 +163,40 @@
}
}
.epg-timeline__jump {
display: inline-flex;
align-items: center;
gap: 7px;
/* icon-only toolbar buttons: "Now" + zoom level (label lives in the tooltip
and aria-label, so the channel/programme title keeps the toolbar width) */
.epg-timeline__iconbtn {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0 13px;
padding: 0;
border-radius: 999px;
background: $surface-2;
border: 1px solid $line-strong;
color: $text-secondary;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
flex: 0 0 auto;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
color: $text-primary;
border-color: $accent-blue;
}
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: 1px;
}
}
.epg-timeline__zoom[data-zoom-level='detail'] {
color: $accent-text;
}
.epg-timeline__stepper {
@@ -339,32 +349,6 @@
}
}
/* ── zoom slider ── */
.epg-timeline__zoom {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 12px;
border-radius: 999px;
background: $surface-2;
border: 1px solid $line;
color: $text-tertiary;
flex: 0 0 auto;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
input[type='range'] {
width: 96px;
accent-color: $accent-text;
cursor: pointer;
}
}
/* ── ribbon ── */
.epg-timeline__ribbon {
flex: 1 1 auto;
@@ -40,7 +40,9 @@ describe('EpgTimelineComponent', () => {
providers: [
{
provide: MatDialog,
useValue: { open: () => ({ afterClosed: () => of(undefined) }) },
useValue: {
open: () => ({ afterClosed: () => of(undefined) }),
},
},
{
provide: TranslateService,
@@ -225,6 +227,60 @@ describe('EpgTimelineComponent', () => {
expect(component.renderItems().length).toBe(before);
});
it('cycles the zoom button through day → hours → detail → day', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
expect(component.zoomLevel()).toBe('hours'); // default scale
component.cycleZoom();
expect(component.zoomLevel()).toBe('detail');
expect(component.zoomIcon()).toBe('zoom_in');
component.cycleZoom();
expect(component.zoomLevel()).toBe('day');
expect(component.zoomIcon()).toBe('zoom_out');
component.cycleZoom();
expect(component.zoomLevel()).toBe('hours');
expect(component.zoomLabelKey()).toBe('EPG.TIMELINE.ZOOM_HOURS');
});
it('snaps a wheel-tuned scale to the next preset band when cycling', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
component.onZoom(2.6); // still "hours" band, but off-preset
expect(component.zoomLevel()).toBe('hours');
component.cycleZoom();
expect(component.zoomLevel()).toBe('detail');
expect(component.scale()).toBe(3.4);
});
function wheel(init: WheelEventInit): WheelEvent {
return new WheelEvent('wheel', { cancelable: true, ...init });
}
it('zooms the ribbon with Ctrl/⌘ + wheel and blocks page zoom', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
const before = component.scale();
const zoomIn = wheel({ deltaY: -100, ctrlKey: true });
component.onRibbonWheel(zoomIn);
expect(zoomIn.defaultPrevented).toBe(true);
expect(component.scale()).toBeGreaterThan(before);
const zoomOut = wheel({ deltaY: 300, metaKey: true });
component.onRibbonWheel(zoomOut);
expect(component.scale()).toBeLessThan(before);
expect(component.scale()).toBeGreaterThanOrEqual(component.zoomMin);
});
it('leaves a plain wheel to native scrolling', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
const before = component.scale();
const plain = wheel({ deltaY: -100 });
component.onRibbonWheel(plain);
expect(plain.defaultPrevented).toBe(false);
expect(component.scale()).toBe(before);
});
it('expands a group chip by zooming in', () => {
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
component.onGroupExpand({
@@ -44,6 +44,7 @@ import {
EpgTimelineEmptyStateComponent,
} from './epg-timeline-empty-state.component';
import { TimelineScrollController } from './epg-timeline-scroll.controller';
import { TimelineZoomController } from './epg-timeline-zoom.controller';
import { EpgTimelineTrackComponent } from './epg-timeline-track.component';
import {
buildTimelineAxis,
@@ -59,10 +60,10 @@ import {
TIMELINE_MINUTE_MS,
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
TIMELINE_ZOOM_STEP,
TimelineBlock,
TimelineRenderGroup,
timelineTickStepForScale,
timelineZoomLevelForScale,
} from './epg-timeline.utils';
type RenderState = 'loading' | 'ribbon' | EpgTimelineEmptyReason;
@@ -115,7 +116,6 @@ export class EpgTimelineComponent {
readonly scale = signal(TIMELINE_DEFAULT_SCALE);
readonly zoomMin = TIMELINE_ZOOM_MIN;
readonly zoomMax = TIMELINE_ZOOM_MAX;
readonly zoomStep = TIMELINE_ZOOM_STEP;
readonly skeletonWidths = [120, 170, 150, 200, 140, 180];
private readonly nowMs = signal(Date.now());
@@ -127,6 +127,13 @@ export class EpgTimelineComponent {
return key ? key : getTodayEpgDateKey();
});
/** Zoom button + Ctrl/⌘ wheel, anchored so a ribbon minute stays put. */
private readonly zoom = new TimelineZoomController({
ribbon: () => this.ribbon()?.nativeElement,
scale: () => this.scale(),
setScale: (scale) => this.scale.set(scale),
});
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
private readonly scroll = new TimelineScrollController({
ribbon: () => this.ribbon()?.nativeElement,
@@ -196,12 +203,23 @@ export class EpgTimelineComponent {
((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
);
});
readonly zoomLevel = computed(() =>
timelineZoomLevelForScale(this.scale())
);
readonly zoomLabelKey = computed(() => {
const scale = this.scale();
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'EPG.TIMELINE.ZOOM_DAY';
if (scale < 3) return 'EPG.TIMELINE.ZOOM_HOURS';
return 'EPG.TIMELINE.ZOOM_DETAIL';
switch (this.zoomLevel()) {
case 'day':
return 'EPG.TIMELINE.ZOOM_DAY';
case 'hours':
return 'EPG.TIMELINE.ZOOM_HOURS';
default:
return 'EPG.TIMELINE.ZOOM_DETAIL';
}
});
/** `zoom_out` reads as "overview"; both finer bands share `zoom_in`. */
readonly zoomIcon = computed(() =>
this.zoomLevel() === 'day' ? 'zoom_out' : 'zoom_in'
);
readonly nowLabel = computed(() => formatClockTime(this.nowMs()));
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
@@ -301,24 +319,19 @@ export class EpgTimelineComponent {
this.collapsedChange.emit(!this.collapsed());
}
/** Clamp + apply a scale, keeping the viewport centre stable. */
onZoom(value: number): void {
const requested = Number(value);
const next = Number.isFinite(requested)
? Math.min(this.zoomMax, Math.max(this.zoomMin, requested))
: this.scale();
// Keep the viewport centre stable across a zoom change.
const scroller = this.ribbon()?.nativeElement;
const prev = this.scale();
const centreMin = scroller
? (scroller.scrollLeft + scroller.clientWidth / 2) / prev
: null;
this.scale.set(next);
if (scroller && centreMin !== null) {
requestAnimationFrame(() => {
scroller.scrollLeft =
centreMin * next - scroller.clientWidth / 2;
});
}
this.zoom.zoomTo(value);
}
/** Toolbar zoom button: next preset (day → hours → detail → day). */
cycleZoom(): void {
this.zoom.cycle();
}
/** Ctrl/⌘ + wheel over the ribbon zooms around the cursor. */
onRibbonWheel(event: WheelEvent): void {
this.zoom.onWheel(event);
}
onGroupExpand(group: TimelineRenderGroup): void {
@@ -9,12 +9,16 @@ import {
dayKeyAtOffset,
hasProgramsForDateKey,
nearestDateKeyWithPrograms,
nextTimelineZoomScale,
TIMELINE_GROUP_EXPAND_ZOOM,
TIMELINE_MINUTE_MS,
TIMELINE_MIN_BLOCK_WIDTH_PX,
TIMELINE_ZOOM_LEVELS,
TimelineRenderBlock,
TimelineRenderGroup,
tierFor,
timelineTickStepForScale,
timelineZoomLevelForScale,
} from './epg-timeline.utils';
function localIso(
@@ -66,15 +70,15 @@ describe('epg-timeline.utils', () => {
describe('classifyTimelineWhen', () => {
it('classifies past, now and future relative to now', () => {
expect(classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)).toBe(
'past'
);
expect(
classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)
).toBe('past');
expect(classifyTimelineWhen(NOW - 60000, NOW + 60000, NOW)).toBe(
'now'
);
expect(classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)).toBe(
'future'
);
expect(
classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)
).toBe('future');
});
});
@@ -91,18 +95,30 @@ describe('epg-timeline.utils', () => {
const block = blocks[0];
expect(block.when).toBe('now');
expect(block.durationMin).toBe(120);
const expectedOffset = (block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
const expectedOffset =
(block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
expect(block.offsetMin).toBeCloseTo(expectedOffset, 5);
});
it('sorts blocks chronologically', () => {
const programs = [
program(localIso(2026, 6, 28, 18), localIso(2026, 6, 28, 19), 'late'),
program(localIso(2026, 6, 28, 8), localIso(2026, 6, 28, 9), 'early'),
program(
localIso(2026, 6, 28, 18),
localIso(2026, 6, 28, 19),
'late'
),
program(
localIso(2026, 6, 28, 8),
localIso(2026, 6, 28, 9),
'early'
),
];
const axis = buildTimelineAxis(programs, NOW);
const blocks = buildTimelineBlocks(programs, axis, NOW);
expect(blocks.map((b) => b.program.title)).toEqual(['early', 'late']);
expect(blocks.map((b) => b.program.title)).toEqual([
'early',
'late',
]);
});
});
@@ -167,7 +183,8 @@ describe('epg-timeline.utils', () => {
[program(localIso(2026, 6, 28, 10), localIso(2026, 6, 28, 11))],
NOW
);
const offsetForNoon = (12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
const offsetForNoon =
(12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
expect(dayKeyAtOffset(axis, offsetForNoon)).toBe('2026-06-28');
});
@@ -177,7 +194,9 @@ describe('epg-timeline.utils', () => {
program(localIso(2026, 6, 30, 10), localIso(2026, 6, 30, 11)),
];
// Jun 30 10:00 (~1d22h away) is closer to Jun 28 12:00 than Jun 25.
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe('2026-06-30');
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe(
'2026-06-30'
);
});
it('keys, positions and picks days in display time when an offset is set', () => {
@@ -189,8 +208,12 @@ describe('epg-timeline.utils', () => {
'Late'
),
];
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(false);
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(true);
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(
false
);
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(
true
);
expect(nearestDateKeyWithPrograms(programs, NOW, 60)).toBe(
'2026-06-30'
);
@@ -205,6 +228,35 @@ describe('epg-timeline.utils', () => {
});
});
describe('zoom presets', () => {
it('maps every preset scale back to its own level', () => {
for (const { level, scale } of TIMELINE_ZOOM_LEVELS) {
expect(timelineZoomLevelForScale(scale)).toBe(level);
}
});
it('classifies off-preset scales by band', () => {
expect(timelineZoomLevelForScale(0.8)).toBe('day');
expect(timelineZoomLevelForScale(1.29)).toBe('day');
expect(timelineZoomLevelForScale(1.3)).toBe('hours');
expect(timelineZoomLevelForScale(2.99)).toBe('hours');
expect(timelineZoomLevelForScale(3)).toBe('detail');
expect(timelineZoomLevelForScale(6)).toBe('detail');
});
it('cycles to the next preset and wraps from detail to day', () => {
expect(nextTimelineZoomScale(1)).toBe(1.75);
expect(nextTimelineZoomScale(1.75)).toBe(3.4);
expect(nextTimelineZoomScale(3.4)).toBe(1);
// an expanded group chip sits on the detail preset, so the cycle
// continues from there instead of re-entering "detail"
expect(nextTimelineZoomScale(TIMELINE_GROUP_EXPAND_ZOOM)).toBe(1);
// wheel-tuned values snap to the band's successor
expect(nextTimelineZoomScale(2.6)).toBe(3.4);
expect(nextTimelineZoomScale(5)).toBe(1);
});
});
describe('short-programme strategy', () => {
it('classifies content tiers by rendered width', () => {
expect(tierFor(200)).toBe('wide');
@@ -220,11 +272,19 @@ describe('epg-timeline.utils', () => {
expect(timelineTickStepForScale(6)).toBe(15);
});
function blocksFor(durations: number[]): ReturnType<typeof buildTimelineBlocks> {
function blocksFor(
durations: number[]
): ReturnType<typeof buildTimelineBlocks> {
let startMin = 6 * 60; // 06:00
const programs = durations.map((dur) => {
const p = program(
localIso(2026, 6, 28, Math.floor(startMin / 60), startMin % 60),
localIso(
2026,
6,
28,
Math.floor(startMin / 60),
startMin % 60
),
localIso(
2026,
6,
@@ -278,7 +338,10 @@ describe('epg-timeline.utils', () => {
eh: number,
em: number
): ReturnType<typeof program> =>
program(localIso(2026, 6, 28, sh, sm), localIso(2026, 6, 28, eh, em));
program(
localIso(2026, 6, 28, sh, sm),
localIso(2026, 6, 28, eh, em)
);
const programs = [
mk(11, 38, 11, 43),
mk(11, 43, 11, 48),