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>
This commit is contained in:
StefanandClaude authored and GitHub committed 2026-07-19 06:38:19 +02:00
1 parent c266eaa680
commit 2f8aee72df
25 files changed
+459 -48

No files matched your search

@@ -64,10 +64,19 @@ describe('epg-archive.util', () => {
).toBe(false);
});
it('denies when the programme is not in the past', () => {
it('allows start-over on the currently-airing programme', () => {
expect(
canCatchUpProgramme('now', inWindowStart, true, 7, NOW)
).toBe(true);
});
it('denies start-over when archive playback is unavailable', () => {
expect(
canCatchUpProgramme('now', inWindowStart, false, 7, NOW)
).toBe(false);
});
it('denies future programmes', () => {
expect(
canCatchUpProgramme('future', NOW + 60_000, true, 7, NOW)
).toBe(false);
@@ -91,8 +100,8 @@ describe('epg-archive.util', () => {
expect(epgDialogActionFor('now', false)).toBe('live');
});
it('prefers live over timeshift for an on-air programme', () => {
expect(epgDialogActionFor('now', true)).toBe('live');
it('prefers start-over timeshift for a catch-up-able on-air programme', () => {
expect(epgDialogActionFor('now', true)).toBe('timeshift');
});
it('maps a catch-up-able past programme to timeshift', () => {
@@ -21,7 +21,8 @@ export function isWithinArchiveWindow(
return startMs >= nowMs - archiveDays * DAY_MS;
}
/** Whether a past programme is playable from the catch-up archive. */
/** Whether a programme is playable from the catch-up archive —
* both past programmes and the currently-airing programme (start-over). */
export function canCatchUpProgramme(
when: TimelineWhen,
startMs: number,
@@ -31,7 +32,7 @@ export function canCatchUpProgramme(
): boolean {
return (
archivePlaybackAvailable &&
when === 'past' &&
(when === 'past' || when === 'now') &&
isWithinArchiveWindow(startMs, archiveDays, nowMs)
);
}
@@ -41,7 +42,7 @@ export function epgDialogActionFor(
when: TimelineWhen,
canCatchUp: boolean
): EpgItemDialogAction | null {
if (when === 'now') {
if (when === 'now' && !canCatchUp) {
return 'live';
}
if (canCatchUp) {
@@ -109,7 +109,7 @@ function toRenderBlock(
nowFillPercent: nowFillFor(block, nowMs),
canCatchUp:
archivePlaybackAvailable &&
block.when === 'past' &&
(block.when === 'past' || block.when === 'now') &&
block.startMs >= archiveWindowStartMs,
};
}
@@ -80,6 +80,15 @@
<span class="epg-timeline__tag is-now">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
@if (item.canCatchUp) {
<button
type="button"
class="epg-timeline__watch"
(click)="watchClick.emit(item.block); $event.stopPropagation()"
>
<mat-icon>replay</mat-icon>
</button>
}
} @else if (isPlaying(item)) {
<span class="epg-timeline__tag is-arch">{{
'EPG.TIMELINE.FROM_ARCHIVE' | translate
@@ -167,6 +167,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.epg-timeline__block-time {
@@ -316,15 +316,15 @@ export class EpgTimelineComponent {
onBlockClick(block: TimelineBlock): void {
this.selectedKey.set(block.key);
if (block.when === 'now') {
this.returnToLive.emit();
return;
}
if (this.canCatchUp(block)) {
this.programActivated.emit({
program: block.program,
type: 'timeshift',
});
return;
}
if (block.when === 'now') {
this.returnToLive.emit();
}
}