feat(epg): improve catchup row playback handling

This commit is contained in:
4gray committed 2026-06-27 12:02:00 +02:00
1 parent 12f3f1fc05
commit d43bc83531
11 files changed
+189 -1

No files matched your search

@@ -221,6 +221,7 @@
[controlledPrograms]="controlledEpgPrograms()"
[controlledArchiveDays]="controlledArchiveDays()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[activeProgram]="activeCatchupProgram()"
[selectedDate]="
unifiedDateNavigator ? selectedLiveEpgDate() : null
"
@@ -102,6 +102,7 @@ class StubEpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly activeProgram = input<EpgProgram | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly programActivated = output<EpgProgramActivationEvent>();
@@ -763,6 +764,46 @@ describe('LiveStreamLayoutComponent', () => {
);
});
it('passes the active catchup program to the EPG list until live playback resumes', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
};
epgItems.set([
buildEpgItem(
'1',
'Archived Show',
archivedProgram.start,
archivedProgram.stop
),
]);
await component.onProgramActivated({
type: 'timeshift',
program: archivedProgram,
});
fixture.detectChanges();
let epgList = fixture.debugElement.query(
By.directive(StubEpgListComponent)
);
expect(epgList.componentInstance.activeProgram()).toEqual(
archivedProgram
);
component.playLive(sampleChannel);
fixture.detectChanges();
epgList = fixture.debugElement.query(By.directive(StubEpgListComponent));
expect(epgList.componentInstance.activeProgram()).toBeNull();
});
it('starts external playback from remote channel navigation when double-click opening is enabled', () => {
const nextChannel = {
...sampleChannel,
@@ -268,6 +268,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly activeStreamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
readonly activeCatchupProgram = signal<EpgProgram | null>(null);
favorites = new Map<number, boolean>();
constructor() {
@@ -426,6 +427,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
) {
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.activeCatchupProgram.set(null);
// Keep both root/recently-added playback and same-category replays in
// sync with the category rail. For already-selected channels this is a
// store no-op.
@@ -633,6 +635,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
playlist.serverTimezone
);
this.activeCatchupProgram.set(program);
this.activePlayback.set({
streamUrl: catchupUrl,
title: this.getCatchupPlaybackTitle(item, program),
@@ -10,6 +10,7 @@
@if (showArchiveBadge) {
<span
class="archive-playback badge"
[class.archive-playback--active]="isActive"
[matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
>
<mat-icon class="archive-playback__icon" aria-hidden="true"
@@ -60,6 +60,19 @@
padding-inline: 7px 10px;
}
.archive-playback--active {
color: var(--app-selection-on-color);
background: var(--app-selection-color);
border-color: color-mix(
in srgb,
var(--app-selection-color) 88%,
transparent
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 10px 18px -14px var(--app-selection-glow);
}
.archive-playback__icon {
width: 14px;
height: 14px;
@@ -93,4 +93,17 @@ describe('EpgListItemComponent', () => {
expect(chip.textContent).toContain('Play');
expect(icon.textContent.trim()).toBe('replay');
});
it('marks the archive playback chip as active when the program is active', () => {
fixture = TestBed.createComponent(EpgListItemComponent);
component = fixture.componentInstance;
component.item = EPG_PROGRAM_ITEM;
component.showArchiveBadge = true;
component.isActive = true;
fixture.detectChanges();
const chip = fixture.nativeElement.querySelector('.archive-playback');
expect(chip.classList).toContain('archive-playback--active');
});
});
@@ -22,6 +22,9 @@ export class EpgListItemComponent {
/** Whether the program is currently live */
@Input() isLive = false;
/** Whether the program is the active playback target */
@Input() isActive = false;
/** Whether archive playback is available for the program */
@Input() showArchiveBadge = false;
@@ -45,6 +45,7 @@
<app-epg-list-item
[item]="program"
[isLive]="isProgramPlaying(program)"
[isActive]="isProgramActive(program)"
[showArchiveBadge]="
canPlayArchivedProgram(program)
"
@@ -73,13 +74,23 @@
<div
class="program-item clickable"
[class.current-program]="isProgramPlaying(program)"
[class.active]="isProgramPlaying(program)"
[class.archive-program]="
canPlayArchivedProgram(program)
"
[class.active]="isProgramActive(program)"
role="button"
tabindex="0"
(click)="activateProgram(program)"
(keydown.enter)="activateProgram(program)"
(keydown.space)="
activateProgramFromKeyboard($event, program)
"
>
<div class="program-meta">
<app-epg-list-item
[item]="program"
[isLive]="isProgramPlaying(program)"
[isActive]="isProgramActive(program)"
[showArchiveBadge]="
canPlayArchivedProgram(program)
"
@@ -56,6 +56,7 @@
}
.program-item {
position: relative;
display: flex;
flex-direction: column;
gap: 5px;
@@ -117,6 +118,47 @@
transparent
);
}
&:focus-visible {
outline: 2px solid var(--app-selection-border);
outline-offset: 2px;
background: color-mix(
in srgb,
var(--app-selection-color) 8%,
transparent
);
&::before,
& + .program-item::before {
display: none;
}
}
}
&.archive-program {
&::after {
content: '';
position: absolute;
inset-block: 12px;
inset-inline-start: 0;
width: 3px;
border-radius: 999px;
background: var(--app-selection-color);
opacity: 0.48;
transform: scaleY(0.68);
transition:
opacity 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
}
&:hover::after,
&:focus-visible::after,
&.active::after {
opacity: 1;
transform: scaleY(1);
box-shadow: 0 0 12px -4px var(--app-selection-glow);
}
}
&.active {
@@ -24,6 +24,7 @@ registerLocaleData(localeDe, 'de');
class StubEpgListItemComponent {
readonly item = input<EpgProgram>();
readonly isLive = input(false);
readonly isActive = input(false);
readonly showArchiveBadge = input(false);
}
@@ -229,6 +230,42 @@ describe('EpgListComponent', () => {
).toBe(2);
});
it('highlights an externally active archived program without marking the live program active', () => {
const programs = buildPrograms();
fixture.componentRef.setInput('controlledPrograms', programs);
fixture.componentRef.setInput('archivePlaybackAvailable', true);
fixture.componentRef.setInput('activeProgram', programs[0]);
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.program-item');
expect(rows[0].classList).toContain('active');
expect(rows[1].classList).toContain('current-program');
expect(rows[1].classList).not.toContain('active');
});
it('exposes playable rows as keyboard-activatable buttons', () => {
const programs = buildPrograms();
const emitted: unknown[] = [];
component.programActivated.subscribe((event) => emitted.push(event));
fixture.componentRef.setInput('controlledPrograms', programs);
fixture.componentRef.setInput('archivePlaybackAvailable', true);
fixture.detectChanges();
const archivedRow = fixture.nativeElement.querySelector(
'.program-item.clickable'
) as HTMLElement;
archivedRow.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
);
expect(archivedRow.getAttribute('role')).toBe('button');
expect(archivedRow.getAttribute('tabindex')).toBe('0');
expect(emitted).toEqual([{ program: programs[0], type: 'timeshift' }]);
});
it('updates the selected-day header when the app language changes', () => {
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
fixture.detectChanges();
@@ -55,6 +55,7 @@ export class EpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly activeProgram = input<EpgProgram | null>(null);
readonly selectedDateInput = input<string | null>(null, {
alias: 'selectedDate',
});
@@ -235,6 +236,11 @@ export class EpgListComponent {
this.timeNow.set(new Date().toISOString());
}
activateProgramFromKeyboard(event: Event, program: EpgProgram): void {
event.preventDefault();
this.activateProgram(program);
}
canActivateProgram(program: EpgProgram): boolean {
return (
this.isProgramPlaying(program) ||
@@ -278,6 +284,13 @@ export class EpgListComponent {
return stop < now && start >= archiveLimit;
}
isProgramActive(program: EpgProgram): boolean {
const activeProgram = this.activeProgram();
return activeProgram
? areProgramsSame(program, activeProgram)
: this.isProgramPlaying(program);
}
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined {
const now = this.currentTimeMs();
return programs.find((program) => {
@@ -402,3 +415,13 @@ function getProgramDateKey(
return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
}
function areProgramsSame(left: EpgProgram, right: EpgProgram): boolean {
return (
(left.channel ?? '') === (right.channel ?? '') &&
getProgramTimeMs(left.start, left.startTimestamp) ===
getProgramTimeMs(right.start, right.startTimestamp) &&
getProgramTimeMs(left.stop, left.stopTimestamp) ===
getProgramTimeMs(right.stop, right.stopTimestamp)
);
}