Merge origin/master into the programme guide branch

Adopts the compact icon-only timeline toolbar (#1557): the Guide button is
now an icon-only `epg-timeline__iconbtn` with its label in the tooltip and
aria-label, so the unused EPG.TIMELINE.GUIDE key is dropped from all locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:59:04 +02:00
commit 49fdd40c9d
123 files changed
+5976 -474

No files matched your search

@@ -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;
}
}
}
@@ -103,39 +103,36 @@
@if (guideAvailable()) {
<button
type="button"
class="epg-timeline__guide"
class="epg-timeline__iconbtn epg-timeline__guide"
(click)="openGuide.emit()"
[matTooltip]="'EPG.TIMELINE.OPEN_GUIDE' | translate"
[attr.aria-label]="'EPG.TIMELINE.OPEN_GUIDE' | translate"
>
<mat-icon>grid_view</mat-icon>
{{ 'EPG.TIMELINE.GUIDE' | translate }}
</button>
}
<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">
@@ -205,6 +202,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,35 +163,44 @@
}
}
.epg-timeline__jump,
.epg-timeline__guide {
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;
}
/* the Guide button opens the multi-channel grid; accent so it reads as the
way "out" of the single-channel ribbon */
.epg-timeline__guide {
color: $accent-text;
border-color: $accent-blue;
@@ -345,32 +356,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;
@@ -230,6 +230,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;
@@ -118,7 +119,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());
@@ -130,6 +130,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,
@@ -199,12 +206,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()));
@@ -304,24 +322,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),
@@ -15,6 +15,7 @@ export interface ArtPlayerVideoSessionConfig {
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
/** Owns native video and ArtPlayer event listeners for one player instance. */
@@ -35,6 +36,11 @@ export class ArtPlayerVideoSession {
);
};
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = (): void => {
this.config.emitPlaybackIssue(null);
};
@@ -73,7 +79,7 @@ export class ArtPlayerVideoSession {
> = [
['error', this.handleNativePlaybackError],
['loadeddata', this.clearPlaybackIssue],
['playing', this.clearPlaybackIssue],
['playing', this.handlePlaying],
['volumechange', this.handleVolumeChange],
['ended', this.handlePlaybackEnded],
];
@@ -66,6 +66,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
}>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -187,6 +188,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
this.videoSession = videoSession;
videoSession.attach();
@@ -15,6 +15,7 @@ export interface HtmlVideoElementSessionConfig {
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
export class HtmlVideoElementSession {
@@ -34,6 +35,11 @@ export class HtmlVideoElementSession {
);
};
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = (): void => {
this.config.emitPlaybackIssue(null);
};
@@ -71,7 +77,7 @@ export class HtmlVideoElementSession {
['timeupdate', this.handleTimeUpdate],
['error', this.handleNativePlaybackError],
['loadeddata', this.clearPlaybackIssue],
['playing', this.clearPlaybackIssue],
['playing', this.handlePlaying],
['ended', this.handlePlaybackEnded],
];
@@ -87,6 +87,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
}>();
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() playbackStarted = new EventEmitter<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
@@ -382,6 +383,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
return this.videoSession;
}
@@ -90,6 +90,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -103,6 +104,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
releasePictureInPicture: !this.sharedControls,
clearPlaybackIssue: () => this.playbackIssue.emit(null),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
private readonly mpegTsSession = new VjsMpegTsSession({
player: () => this.player,
@@ -146,7 +148,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
vjsOptions,
this.handlePlayerReady
) as unknown as VideoJsPlayer;
this.bindPlayerEvents();
this.bindPlayerEvents(true);
initializeVjsPlugins(this.player);
if (!this.sharedControls) {
this.legacyShortcuts = attachVjsLegacyShortcuts({
@@ -220,7 +222,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.mpegTsSession.destroy();
this.videoSession.destroy();
if (this.player) {
this.unbindPlayerEvents();
this.bindPlayerEvents(false);
this.player.dispose();
}
}
@@ -410,21 +412,18 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.legacyTracks.bind();
}
private bindPlayerEvents(): void {
this.player.on('loadedmetadata', this.handleLoadedMetadata);
this.player.on('error', this.handleVideoJsError);
this.player.on('volumechange', this.handleVolumeChange);
this.player.on('timeupdate', this.handleTimeUpdate);
this.player.on('pause', this.handlePauseForReset);
this.player.on('playerreset', this.handlePlayerReset);
}
private unbindPlayerEvents(): void {
this.player.off('loadedmetadata', this.handleLoadedMetadata);
this.player.off('error', this.handleVideoJsError);
this.player.off('volumechange', this.handleVolumeChange);
this.player.off('timeupdate', this.handleTimeUpdate);
this.player.off('pause', this.handlePauseForReset);
this.player.off('playerreset', this.handlePlayerReset);
private bindPlayerEvents(bind: boolean): void {
const events = {
loadedmetadata: this.handleLoadedMetadata,
error: this.handleVideoJsError,
volumechange: this.handleVolumeChange,
timeupdate: this.handleTimeUpdate,
pause: this.handlePauseForReset,
playerreset: this.handlePlayerReset,
};
for (const [event, listener] of Object.entries(events)) {
if (bind) this.player.on(event, listener);
else this.player.off(event, listener);
}
}
}
@@ -4,12 +4,18 @@ export interface VjsVideoElementSessionConfig {
releasePictureInPicture?: boolean;
clearPlaybackIssue: () => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
export class VjsVideoElementSession {
private currentVideo: HTMLVideoElement | null = null;
private destroyed = false;
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = () => {
this.config.clearPlaybackIssue();
};
@@ -28,7 +34,7 @@ export class VjsVideoElementSession {
this.detach();
this.currentVideo = video;
video.addEventListener('loadeddata', this.clearPlaybackIssue);
video.addEventListener('playing', this.clearPlaybackIssue);
video.addEventListener('playing', this.handlePlaying);
video.addEventListener('ended', this.emitPlaybackEnded);
}
@@ -57,10 +63,7 @@ export class VjsVideoElementSession {
'loadeddata',
this.clearPlaybackIssue
);
this.currentVideo.removeEventListener(
'playing',
this.clearPlaybackIssue
);
this.currentVideo.removeEventListener('playing', this.handlePlaying);
this.currentVideo.removeEventListener('ended', this.emitPlaybackEnded);
this.currentVideo = null;
}
@@ -0,0 +1,30 @@
import { linkedSignal } from '@angular/core';
import type { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
/**
* The fullscreen channel panel is DOM content over the video. Embedded
* MPV's native-view engine paints a platform view above the page, where
* no DOM layer can show, so the panel exists only while the rendered
* engine is a web player or Embedded MPV's frame-copy canvas. Fails
* closed until frame-copy has been confirmed. Retain that confirmation
* while a replacement component probes support, so channel changes do
* not reset the panel. A confirmed native/unsupported result revokes it.
*/
export function createChannelPanelAvailability(
embedded: () => boolean,
support: () => EmbeddedMpvSupport | null
) {
return linkedSignal<
{ embedded: boolean; support: EmbeddedMpvSupport | null },
boolean
>({
source: () => ({ embedded: embedded(), support: support() }),
computation: ({ embedded, support }, previous) => {
if (!embedded) return true;
if (support === null) {
return previous?.source.embedded ? previous.value : false;
}
return support.supported && support.engine === 'frame-copy';
},
});
}
@@ -0,0 +1,184 @@
import { Component, Injector, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import {
VideoPlayer,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { ErrorDetails } from 'hls.js';
import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format';
@Component({ template: '' })
class Host {}
const failure: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: '',
container: 'm3u8',
player: 'videojs',
httpStatus: 403,
audioCodecs: [],
videoCodecs: [],
};
const source: ResolvedPortalPlayback = {
streamUrl: 'https://fixture.test/stream.php?token=synthetic&extension=m3u8',
liveAutoTsUrl:
'https://fixture.test/other.php?token=synthetic&extension=ts',
title: 'Fixture',
isLive: true,
userAgent: 'fixture',
headers: { Authorization: 'Bearer synthetic' },
};
describe('Auto live source-format attempt lifecycle', () => {
const playback = signal<ResolvedPortalPlayback | null>(source);
const sessionKey = signal('playlist-a/channel-1');
const player = signal(VideoPlayer.VideoJs);
const intent = signal(0);
const autoEnabled = signal(true);
let controller: WebPlayerLiveAutoFormat;
let fixture: ReturnType<typeof TestBed.createComponent<Host>>;
beforeEach(() => {
playback.set({ ...source });
sessionKey.set('playlist-a/channel-1');
player.set(VideoPlayer.VideoJs);
intent.set(0);
autoEnabled.set(true);
fixture = TestBed.createComponent(Host);
controller = new WebPlayerLiveAutoFormat({
playback,
sessionKey,
player,
intent,
autoEnabled,
injector: fixture.debugElement.injector.get(Injector),
});
fixture.detectChanges();
});
afterEach(() => {
controller.destroy();
fixture.destroy();
});
async function render() {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('waits for the old transport render, preserves the whole payload and consumes only one attempt', async () => {
expect(controller.tryFallback(failure)).toBe(true);
expect(controller.pending()).toBe(true);
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(false);
await render();
expect(controller.playback()).toEqual({
...source,
streamUrl: source.liveAutoTsUrl,
liveAutoTsUrl: undefined,
});
const mounted = controller.playback();
controller.started();
expect(controller.playback()).toBe(mounted);
expect(controller.tryFallback(failure)).toBe(false);
intent.update((n) => n + 1);
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
player.set(VideoPlayer.Html5Player);
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
});
it('does not downgrade after actual playing', () => {
controller.started();
expect(controller.tryFallback(failure)).toBe(false);
});
it.each([0, 200, undefined])(
'does not guess from HTTP status %s',
(httpStatus) => {
expect(controller.tryFallback({ ...failure, httpStatus })).toBe(
false
);
}
);
it.each([
PlaybackDiagnosticCode.UnknownPlaybackError,
PlaybackDiagnosticCode.DrmOrEncryption,
PlaybackDiagnosticCode.MediaDecodeError,
])('does not downgrade %s', (code) => {
expect(controller.tryFallback({ ...failure, code })).toBe(false);
});
it.each(['key', 'media', 'unknown'] as const)(
'does not downgrade HLS %s failures even with an HTTP status',
(stage) => {
expect(
controller.tryFallback({
...failure,
hls: {
engineType: 'unknown',
engineDetails: ErrorDetails.KEY_LOAD_ERROR,
disposition: 'fatal',
stage,
failure: 'http',
httpStatus: 403,
},
})
).toBe(false);
}
);
it('respects manual preference changes before the failure and before pending completion', async () => {
autoEnabled.set(false);
expect(controller.tryFallback(failure)).toBe(false);
autoEnabled.set(true);
expect(controller.tryFallback(failure)).toBe(true);
autoEnabled.set(false);
await render();
expect(controller.playback()).toBe(playback());
});
it('ignores a delayed completion after another playlist or channel takes ownership', async () => {
controller.tryFallback(failure);
sessionKey.set('playlist-b/channel-1');
playback.set({ ...source, title: 'Other playlist' });
await render();
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(true);
await render();
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
});
it('cancels on disposal and does not transfer attempts to another mounted session', async () => {
controller.tryFallback(failure);
controller.destroy();
await render();
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(false);
});
it('does not rearm when same-session source metadata refreshes', async () => {
controller.tryFallback(failure);
await render();
playback.set({ ...source, title: 'Refreshed' });
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
expect(controller.playback()?.title).toBe('Refreshed');
playback.set({ ...source, streamUrl: 'https://other.test/live.m3u8' });
expect(controller.playback()?.streamUrl).toBe(
'https://other.test/live.m3u8'
);
expect(controller.tryFallback(failure)).toBe(false);
});
it.each([
{ isLive: false },
{ liveAutoTsUrl: undefined },
{ drm: {} },
{ contentInfo: {} },
])(
'excludes VOD, catchup, missing advertisement and DRM: %j',
(overrides) => {
playback.set({ ...source, ...overrides } as ResolvedPortalPlayback);
expect(controller.tryFallback(failure)).toBe(false);
}
);
it('does not start an Embedded MPV attempt', () => {
player.set(VideoPlayer.EmbeddedMpv);
expect(controller.tryFallback(failure)).toBe(false);
});
});
@@ -0,0 +1,171 @@
import {
afterNextRender,
computed,
linkedSignal,
type AfterRenderRef,
type Injector,
type Signal,
} from '@angular/core';
import {
type PlaybackDiagnostic,
resolvePlaybackUrlSourceKind,
} from '@iptvnator/playback/util';
import {
type ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
/** One source-format attempt per mounted logical live session, never an engine switch. */
export class WebPlayerLiveAutoFormat {
private callback?: AfterRenderRef;
private destroyed = false;
// Metadata refreshes reuse the transport revision. The returned ownership
// value is opaque; URLs/headers remain solely in the canonical source input.
private readonly origin = linkedSignal({
source: () => this.deps.playback(),
computation: (
source,
previous?: { source: ResolvedPortalPlayback | null; value: symbol }
) =>
previous && sameLiveTransport(source, previous.source)
? previous.value
: Symbol(),
});
private readonly intent = computed(() => {
void this.deps.intent();
void this.deps.player();
void this.deps.autoEnabled();
return Symbol();
});
private readonly state = linkedSignal({
source: () => ({ key: this.deps.sessionKey(), origin: this.origin() }),
computation: (
source,
previous?: {
source: { key: string; origin: symbol };
value: {
attempted: boolean;
started: boolean;
pending: boolean;
useTs: boolean;
};
}
) => ({
attempted:
previous?.source.key === source.key &&
!!previous.value.attempted,
started:
previous?.source.key === source.key && !!previous.value.started,
pending: false,
useTs: false,
}),
});
readonly pending = computed(() => this.state().pending);
private readonly useTs = computed(() => this.state().useTs);
readonly playback = computed(() => {
const playback = this.deps.playback();
return this.useTs() && playback?.liveAutoTsUrl
? {
...playback,
streamUrl: playback.liveAutoTsUrl,
liveAutoTsUrl: undefined,
}
: playback;
});
constructor(
private readonly deps: {
playback: Signal<ResolvedPortalPlayback | null>;
sessionKey: Signal<string>;
player: Signal<VideoPlayer>;
intent: () => unknown;
autoEnabled: () => boolean;
injector: Injector;
}
) {}
started(): void {
this.state.update((state) => ({ ...state, started: true }));
}
tryFallback(issue: PlaybackDiagnostic): boolean {
const source = this.deps.playback();
const state = this.state();
const status = issue.httpStatus;
if (
this.destroyed ||
!this.deps.autoEnabled() ||
state.attempted ||
state.started ||
!this.deps.sessionKey() ||
!source?.liveAutoTsUrl ||
source.isLive !== true ||
source.contentInfo ||
source.drm ||
this.deps.player() === VideoPlayer.EmbeddedMpv ||
resolvePlaybackUrlSourceKind(source.streamUrl) !== 'hls' ||
resolvePlaybackUrlSourceKind(source.liveAutoTsUrl) !== 'mpegts' ||
issue.code !== 'network-error' ||
status === undefined ||
status < 400 ||
status > 599 ||
// Key/license requests must never cause a format downgrade.
(issue.hls &&
!['manifest', 'level', 'segment'].includes(issue.hls.stage))
)
return false;
this.state.set({ ...state, attempted: true, pending: true });
const origin = this.origin();
const key = this.deps.sessionKey();
const intent = this.intent();
this.callback?.destroy();
// Render an empty application first. All web transports synchronously destroy
// their loaders in ngOnDestroy before the TS header handoff may start.
this.callback = afterNextRender(
() => {
this.callback = undefined;
if (
this.destroyed ||
origin !== this.origin() ||
key !== this.deps.sessionKey()
)
return;
this.state.update((current) => ({
...current,
pending: false,
useTs: intent === this.intent(),
}));
},
{ injector: this.deps.injector }
);
return true;
}
destroy(): void {
this.destroyed = true;
this.callback?.destroy();
this.callback = undefined;
}
}
function sameLiveTransport(
a: ResolvedPortalPlayback | null,
b: ResolvedPortalPlayback | null
): boolean {
if (!a || !b) return a === b;
const headerKeys = new Set([
...Object.keys(a.headers ?? {}),
...Object.keys(b.headers ?? {}),
]);
return (
a.streamUrl === b.streamUrl &&
a.liveAutoTsUrl === b.liveAutoTsUrl &&
a.isLive === b.isLive &&
a.drm === b.drm &&
a.contentInfo === b.contentInfo &&
a.userAgent === b.userAgent &&
a.referer === b.referer &&
a.origin === b.origin &&
[...headerKeys].every((key) => a.headers?.[key] === b.headers?.[key])
);
}
@@ -40,6 +40,7 @@ export interface WebPlayerRecoveryControllerDeps {
readonly playbackExternallyTransferable: Signal<boolean>;
readonly alternativeSourceCount: () => number;
readonly managedExternalPlayersAvailable: () => boolean;
readonly tryAutoLiveFormat?: (issue: PlaybackDiagnostic) => boolean;
readonly emitPlaybackFailed: (code: PlaybackDiagnosticCode) => void;
readonly emitExternalFallbackRequested: (
request: PlaybackFallbackRequest
@@ -120,6 +121,12 @@ export class WebPlayerRecoveryController {
this.clearDiagnostic();
return;
}
if (this.deps.tryAutoLiveFormat?.(issue)) {
applicationHandoff.invalidate();
recoverySession.clearPlaybackBinding();
this.clearDiagnostic();
return;
}
if (!recoverySession.recordFailure(binding)) {
return;
}
@@ -14,6 +14,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -42,6 +43,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -70,6 +72,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -0,0 +1,288 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ComponentFixture,
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { DestroyRef, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
type ExternalPlayerSession,
STORE_KEY,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { of } from 'rxjs';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
jest.unstable_mockModule('video.js', () => ({ default: jest.fn() }));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
describe('WebPlayerViewComponent live format integration', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
let component: WebPlayerViewComponentInstance;
const storedSettings = { player: VideoPlayer.VideoJs };
const storage = {
get: jest.fn((key: string) =>
of(key === STORE_KEY.Settings ? storedSettings : undefined)
),
};
let runtime: { supportsManagedExternalPlayers: boolean };
let activeExternalSession: ReturnType<
typeof signal<ExternalPlayerSession | null>
>;
let closeExternalSession: jest.Mock<Promise<void>, [ExternalPlayerSession]>;
let holdHeaderHandoff: boolean;
let headerResolvers: Array<(stillCurrent: boolean) => void>;
let headerRejectors: Array<(reason?: unknown) => void>;
const streamHeaders = {
apply: jest.fn(
() =>
(holdHeaderHandoff
? new Promise<boolean>((resolve, reject) => {
headerResolvers.push(resolve);
headerRejectors.push(reject);
})
: null) as Promise<boolean> | null
),
clear: jest.fn(),
};
beforeAll(async () => {
({ WebPlayerViewComponent } =
await import('./web-player-view.component'));
});
beforeEach(async () => {
runtime = { supportsManagedExternalPlayers: true };
activeExternalSession = signal<ExternalPlayerSession | null>(null);
closeExternalSession = jest.fn(async (session) => {
activeExternalSession.set({
...session,
status: 'closed',
canClose: false,
updatedAt: '2026-08-08T10:00:02.000Z',
});
});
holdHeaderHandoff = false;
headerResolvers = [];
headerRejectors = [];
streamHeaders.apply.mockClear();
streamHeaders.clear.mockClear();
await TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Playthrough,
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
providers: [
{ provide: StorageMap, useValue: storage },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{
provide: ElectronStreamHeadersService,
useValue: streamHeaders,
},
{
provide: SettingsStore,
useValue: {
showCaptions: () => false,
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: activeExternalSession,
visibleSession: activeExternalSession,
dismissActiveSession: jest.fn(),
closeSession: closeExternalSession,
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
set: {
imports: [
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
PlaybackDiagnosticPanelComponent,
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
TranslateModule,
VodSourceRowComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
'https://example.com/live.m3u8'
);
fixture.componentRef.setInput('title', 'Recovery stream');
fixture.componentRef.setInput('playbackSessionKey', 'content-a');
});
afterEach(() => fixture.destroy());
it('tries advertised TS once after initial terminal HLS HTTP failure', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
headers: { 'User-Agent': 'fixture-agent' },
});
await render();
const oldBinding = currentBinding();
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
oldBinding
);
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/live.ts'
);
expect(component.resolvedPlayback().headers).toEqual({
'User-Agent': 'fixture-agent',
});
expect(component.selectedPlayer()).toBe(VideoPlayer.VideoJs);
const tsBinding = currentBinding();
component.handlePlaybackIssue(networkIssue('videojs'), tsBinding);
await render();
expect(component.activeBinding()).toBe(tsBinding);
expect(component.visiblePlaybackDiagnostic()).not.toBeNull();
component.handlePlaybackIssue(networkIssue('videojs'), oldBinding);
expect(component.activeBinding()).toBe(tsBinding);
});
it('uses actual playing output to suppress fallback after playback starts', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
});
await render();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
player.playbackStarted.emit();
player.playbackIssue.emit({
...networkIssue('videojs'),
httpStatus: 403,
});
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/live.m3u8'
);
expect(component.visiblePlaybackDiagnostic()).not.toBeNull();
});
it('destroys the previous transport before the TS header handoff', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
});
await render();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
);
const events: string[] = [];
player.injector
.get(DestroyRef)
.onDestroy(() => events.push('destroy-hls'));
streamHeaders.apply.mockImplementationOnce(() => {
events.push('ts-headers');
return null;
});
(player.componentInstance as StubVjsPlayerComponent).playbackIssue.emit(
{ ...networkIssue('videojs'), httpStatus: 403 }
);
await render();
expect(events).toEqual(['destroy-hls', 'ts-headers']);
});
it('ignores playing and failure from the old channel before the new render', async () => {
await render();
const oldBinding = currentBinding();
fixture.componentRef.setInput(
'playbackSessionKey',
'another-playlist/channel'
);
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/new.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/new.ts',
});
component.handlePlaybackStarted(oldBinding);
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
oldBinding
);
await render();
const binding = currentBinding();
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
binding
);
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/new.ts'
);
});
function currentBinding() {
const current = component.activeBinding();
if (!current) throw new Error('Expected a mounted playback binding');
return current;
}
async function render(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
});
function networkIssue(player: 'videojs' | 'html5'): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://example.com/live.m3u8',
container: 'm3u8',
player,
audioCodecs: [],
videoCodecs: [],
};
}
@@ -1,8 +1,8 @@
import {
Component,
ElementRef,
Injector,
OnDestroy,
type Signal,
ViewEncapsulation,
computed,
effect,
@@ -25,7 +25,6 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type Channel,
type EmbeddedMpvSupport,
type RecordingStartMetadata,
type RecordingStoppedEvent,
type ResolvedPortalPlayback,
@@ -57,6 +56,8 @@ import {
} from './web-player-application-ownership';
import { createWebPlayerApplicationState } from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import { createChannelPanelAvailability } from './web-player-channel-panel-state';
import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format';
import { WebPlayerRecoveryController } from './web-player-recovery-controller';
import {
isPlaybackExternallyTransferable,
@@ -65,11 +66,6 @@ import {
toVideoPlayer,
} from './web-player-recovery-policy';
/** What the view needs from a rendered Embedded MPV player: its engine. */
interface EmbeddedMpvEngineReporter {
readonly support: Signal<EmbeddedMpvSupport | null>;
}
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED;
@@ -113,34 +109,11 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly fullscreenSurface: HTMLElement = inject(ElementRef<HTMLElement>)
.nativeElement;
private readonly embeddedMpvPlayer =
viewChild<EmbeddedMpvEngineReporter>('embeddedMpvPlayer');
/**
* The fullscreen channel panel is DOM content over the video. Embedded
* MPV's native-view engine paints a platform view above the page, where
* no DOM layer can show, so the panel exists only while the rendered
* engine is a web player or Embedded MPV's frame-copy canvas. Fails
* closed until frame-copy has been confirmed. Retain that confirmation
* while a replacement component probes support, so channel changes do
* not reset the panel. A confirmed native/unsupported result revokes it.
*/
readonly channelPanelAvailable = linkedSignal<
{ embedded: boolean; support: EmbeddedMpvSupport | null },
boolean
>({
source: () => ({
embedded: this.renderedApplications().some(
(application) => application.embeddedMpv
),
support: this.embeddedMpvPlayer()?.support() ?? null,
}),
computation: ({ embedded, support }, previous) => {
if (!embedded) return true;
if (support === null) {
return previous?.source.embedded ? previous.value : false;
}
return support.supported && support.engine === 'frame-copy';
},
});
viewChild<EmbeddedMpvPlayerComponent>('embeddedMpvPlayer');
readonly channelPanelAvailable = createChannelPanelAvailability(
() => this.renderedApplications().some((app) => app.embeddedMpv),
() => this.embeddedMpvPlayer()?.support() ?? null
);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
@@ -223,8 +196,17 @@ export class WebPlayerViewComponent implements OnDestroy {
const temporary = this.recoverySession.temporaryPlayerOverride();
return temporary ? toVideoPlayer(temporary) : this.renderablePlayer();
});
private readonly applicationState = createWebPlayerApplicationState({
private readonly liveAutoFormat = new WebPlayerLiveAutoFormat({
playback: this.playback,
sessionKey: this.playbackSessionKey,
player: this.selectedPlayer,
intent: this.reloadToken,
autoEnabled: () =>
(this.settingsStore.streamFormat?.() ?? 'auto') === 'auto',
injector: inject(Injector),
});
private readonly applicationState = createWebPlayerApplicationState({
playback: this.liveAutoFormat.playback,
streamUrl: this.streamUrl,
title: this.title,
startTime: this.startTime,
@@ -243,7 +225,7 @@ export class WebPlayerViewComponent implements OnDestroy {
externalRecovery: this.externalRecovery,
applicationHandoff: this.applicationHandoff,
playbackSessionKey: this.playbackSessionKey,
playback: this.playback,
playback: this.liveAutoFormat.playback,
streamUrl: this.streamUrl,
startTime: this.startTime,
selectedPlayer: this.selectedPlayer,
@@ -255,6 +237,7 @@ export class WebPlayerViewComponent implements OnDestroy {
alternativeSourceCount: () => this.alternativeSources().length,
managedExternalPlayersAvailable: () =>
this.runtime.supportsManagedExternalPlayers,
tryAutoLiveFormat: (issue) => this.liveAutoFormat.tryFallback(issue),
emitPlaybackFailed: (code) => this.playbackFailed.emit(code),
emitExternalFallbackRequested: (request) =>
this.externalFallbackRequested.emit(request),
@@ -284,6 +267,7 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly renderedApplications = computed<
readonly PlaybackApplicationOwnership[]
>(() => {
if (this.liveAutoFormat.pending()) return [];
const binding = this.activeBinding();
const embeddedMpv =
this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding;
@@ -311,6 +295,7 @@ export class WebPlayerViewComponent implements OnDestroy {
// Session sync may clear a temporary player override. Run it before
// tracking intent so that reset is folded into this application.
this.recovery.syncSession();
if (this.liveAutoFormat.pending()) return;
void this.recovery.diagnosticIntentToken();
const sourceRevision = this.playbackSourceRevisionToken();
untracked(() =>
@@ -349,6 +334,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
ngOnDestroy(): void {
this.liveAutoFormat.destroy();
this.externalRecovery.destroy();
this.applicationHandoff.destroy();
}
@@ -360,6 +346,18 @@ export class WebPlayerViewComponent implements OnDestroy {
this.recovery.handlePlaybackIssue(issue, binding);
}
handlePlaybackStarted(binding: PlaybackBinding): void {
this.recovery.syncSession();
if (
this.applicationHandoff.owns(
binding,
this.playbackApplicationToken()
) &&
!this.liveAutoFormat.pending()
)
this.liveAutoFormat.started();
}
handleTimeUpdate(
event: { currentTime: number; duration: number },
ownership: PlaybackApplicationOwnership
@@ -27,6 +27,7 @@ export class StubVjsPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -48,6 +49,7 @@ export class StubHtmlVideoPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -69,6 +71,7 @@ export class StubArtPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();