Files
iptvnator/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts
T
StefanandClaude 2f8aee72df feat(xtream): add catch-up playback to favorites and recent tabs (#1166)
Enables Xtream catch-up/timeshift from the Favorites and Recent surfaces (per-playlist and global), not just Live TV, and adds start-over replay of the currently-airing programme. Carries tv_archive/tv_archive_duration through the favorites and recently-viewed DB projections and maps them onto UnifiedCollectionItem; tv_archive_duration is interpreted as days, matching live-stream-layout.controlledArchiveDays.

Closes #1138.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 06:38:19 +02:00

401 lines
14 KiB
TypeScript

import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
linkedSignal,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
EpgDateNavigationDirection,
getTodayEpgDateKey,
parseEpgDateKey,
shiftEpgDateKey,
} from '../epg-date';
import { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component';
import type { EpgProgramActivationEvent } from '../epg-program-activation-event';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import {
EpgTimelineSummary,
formatClockTime,
summaryHasTimeRange,
summaryHasTitle,
summaryMinutesLeft,
summaryProgress,
} from './epg-summary.util';
import {
canCatchUpProgramme,
epgDialogActionFor,
} from './epg-archive.util';
import {
EpgTimelineEmptyReason,
EpgTimelineEmptyStateComponent,
} from './epg-timeline-empty-state.component';
import { TimelineScrollController } from './epg-timeline-scroll.controller';
import { EpgTimelineTrackComponent } from './epg-timeline-track.component';
import {
buildTimelineAxis,
buildTimelineBlocks,
buildTimelineDayDividers,
buildTimelineRenderItems,
buildTimelineTicks,
hasProgramsForDateKey,
nearestDateKeyWithPrograms,
TIMELINE_DEFAULT_SCALE,
TIMELINE_GROUP_EXPAND_ZOOM,
TIMELINE_GROUP_ZOOM_MAX,
TIMELINE_MINUTE_MS,
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
TIMELINE_ZOOM_STEP,
TimelineBlock,
TimelineRenderGroup,
timelineTickStepForScale,
} from './epg-timeline.utils';
type RenderState = 'loading' | 'ribbon' | EpgTimelineEmptyReason;
export type { EpgTimelineSummary } from './epg-summary.util';
@Component({
selector: 'app-epg-timeline',
templateUrl: './epg-timeline.component.html',
styleUrl: './epg-timeline.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DatePipe,
EpgTimelineEmptyStateComponent,
EpgTimelineTrackComponent,
MatIcon,
MatTooltip,
TranslatePipe,
],
})
export class EpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly sourceLabel = input('Timeline');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly emptyReason = input<EpgTimelineEmptyReason>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
private readonly programmeDialog = inject(EpgProgrammeDialogService);
private readonly translate = inject(TranslateService);
readonly ribbon = viewChild<ElementRef<HTMLElement>>('ribbon');
/** px-per-minute zoom (D). */
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());
readonly selectedKey = signal<string | null>(null);
/** Day centred in the ribbon, seeded from the controlled `selectedDate` so
* a non-today date survives (re)mount and follows host changes. */
private readonly viewDayKey = linkedSignal(() => {
const key = this.selectedDate()?.trim();
return key ? key : getTodayEpgDateKey();
});
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
private readonly scroll = new TimelineScrollController({
ribbon: () => this.ribbon()?.nativeElement,
scale: () => this.scale(),
axis: () => this.axis(),
blocks: () => this.blocks(),
nowMs: () => this.nowMs(),
viewDayKey: () => this.viewDayKey(),
commitDay: (dayKey) => this.commitDay(dayKey),
hasProgramsForDay: (dayKey) =>
hasProgramsForDateKey(this.programs(), dayKey),
});
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
readonly axis = computed(() =>
buildTimelineAxis(this.programs(), this.nowMs())
);
readonly blocks = computed(() =>
buildTimelineBlocks(this.programs(), this.axis(), this.nowMs())
);
private readonly archiveWindowStartMs = computed(() => {
const days = this.archiveDays();
return days > 0
? this.nowMs() - days * 24 * 60 * TIMELINE_MINUTE_MS
: Number.NEGATIVE_INFINITY;
});
readonly renderItems = computed(() =>
buildTimelineRenderItems(this.blocks(), this.scale(), {
allowGroup: this.scale() < TIMELINE_GROUP_ZOOM_MAX,
nowMs: this.nowMs(),
archivePlaybackAvailable: this.archivePlaybackAvailable(),
archiveWindowStartMs: this.archiveWindowStartMs(),
})
);
readonly ticks = computed(() =>
buildTimelineTicks(this.axis(), timelineTickStepForScale(this.scale()))
);
readonly dividers = computed(() => buildTimelineDayDividers(this.axis()));
readonly trackWidthPx = computed(() => {
const axis = this.axis();
return ((axis.endMs - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale();
});
readonly playheadLeftPx = computed(() => {
const axis = this.axis();
return ((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * 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';
});
readonly nowLabel = computed(() => formatClockTime(this.nowMs()));
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
readonly isViewToday = computed(
() => this.viewDayKey() === getTodayEpgDateKey()
);
readonly renderState = computed<RenderState>(() => {
if (this.loading()) {
return 'loading';
}
const reason = this.emptyReason();
if (reason !== 'none') {
return reason;
}
if (this.programs().length === 0) {
return 'channel-unmapped';
}
if (!hasProgramsForDateKey(this.programs(), this.viewDayKey())) {
return 'empty-day';
}
return 'ribbon';
});
readonly emptyStateReason = computed<EpgTimelineEmptyReason>(() => {
const state = this.renderState();
return state === 'loading' || state === 'ribbon' ? 'none' : state;
});
readonly hasOtherDays = computed(() => this.programs().length > 0);
/**
* "Now" + zoom act on the ribbon, so they only make sense when a ribbon is
* rendered. Hidden (not disabled) otherwise — a disabled control implies it
* could become usable, but with no programmes for the day there is nothing
* to jump to or zoom.
*/
readonly showRibbonControls = computed(() => this.renderState() === 'ribbon');
/**
* The date stepper navigates between days, which is only meaningful when the
* channel has EPG on *some* day: the live ribbon, or `empty-day` (data
* exists, just not the viewed day). Hidden for the no-EPG-anywhere states
* (`channel-unmapped`, `provider-no-epg`, `m3u-needs-setup`, `error`) and
* while loading.
*/
readonly showDateStepper = computed(() => {
const state = this.renderState();
return state === 'ribbon' || state === 'empty-day';
});
// ── collapsed-summary state ──
readonly hasSummary = computed(() => summaryHasTitle(this.summary()));
readonly hasTimeRange = computed(() =>
summaryHasTimeRange(this.summary())
);
readonly progress = computed(() =>
summaryProgress(this.summary(), this.nowMs())
);
readonly minutesLeft = computed(() =>
summaryMinutesLeft(this.summary(), this.nowMs())
);
constructor() {
effect((onCleanup) => {
const intervalId = window.setInterval(
() => this.nowMs.set(Date.now()),
30_000
);
onCleanup(() => clearInterval(intervalId));
});
// Auto-focus the current programme whenever a channel's EPG (re)loads
// or the ribbon (re)mounts — this is what makes selecting a channel land
// on "now" without the user pressing the Now button. Tracked deps are
// only `ribbon` + `programs`; the body runs untracked so 30s "now" ticks
// and zoom changes never re-trigger a jump.
effect(() => {
const scroller = this.ribbon()?.nativeElement;
const programs = this.programs();
untracked(() => this.scroll.maybeAutoFocus(scroller, programs));
});
}
toggleCollapsed(): void {
this.collapsedChange.emit(!this.collapsed());
}
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;
});
}
}
onGroupExpand(group: TimelineRenderGroup): void {
this.scale.set(TIMELINE_GROUP_EXPAND_ZOOM);
const axis = this.axis();
const centreMs = (group.startMs + group.stopMs) / 2;
const offsetMin = (centreMs - axis.startMs) / TIMELINE_MINUTE_MS;
this.scroll.scrollToOffset(offsetMin, 0.5);
}
canCatchUp(block: TimelineBlock): boolean {
return canCatchUpProgramme(
block.when,
block.startMs,
this.archivePlaybackAvailable(),
this.archiveDays(),
this.nowMs()
);
}
onBlockClick(block: TimelineBlock): void {
this.selectedKey.set(block.key);
if (this.canCatchUp(block)) {
this.programActivated.emit({
program: block.program,
type: 'timeshift',
});
return;
}
if (block.when === 'now') {
this.returnToLive.emit();
}
}
onWatch(block: TimelineBlock): void {
this.selectedKey.set(block.key);
this.programActivated.emit({
program: block.program,
type: 'timeshift',
});
}
openDetails(block: TimelineBlock): void {
this.programmeDialog
.open({
...block.program,
channelName: this.channelName(),
channelLogo: this.channelLogo(),
primaryAction: this.dialogActionFor(block),
archiveUnavailableNote:
block.when === 'past' && !this.archivePlaybackAvailable(),
})
.subscribe((result: EpgItemDialogAction | undefined) => {
if (result === 'live') {
this.returnToLive.emit();
} else if (result === 'timeshift') {
this.selectedKey.set(block.key);
this.programActivated.emit({
program: block.program,
type: 'timeshift',
});
}
});
}
stepDay(direction: EpgDateNavigationDirection): void {
const nextKey = shiftEpgDateKey(this.viewDayKey(), direction);
this.commitDay(nextKey);
this.scroll.scrollToDateKey(nextKey, 0.5);
}
jumpToNow(): void {
this.commitDay(getTodayEpgDateKey());
// Deliberate user action → animate so the movement reads.
this.scroll.focusCurrentProgram(true);
}
jumpToNearestDay(): void {
const nearest = nearestDateKeyWithPrograms(
this.programs(),
this.nowMs()
);
if (nearest) {
this.commitDay(nearest);
this.scroll.scrollToDateKey(nearest, 0.5);
}
}
onRibbonScroll(): void {
this.scroll.onRibbonScroll();
}
private commitDay(dayKey: string): void {
if (dayKey === this.viewDayKey()) {
return;
}
this.viewDayKey.set(dayKey);
this.selectedDateChange.emit(dayKey);
}
private dialogActionFor(block: TimelineBlock): EpgItemDialogAction | null {
return epgDialogActionFor(block.when, this.canCatchUp(block));
}
}