mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(epg): improve catchup row playback handling
This commit is contained in:
1 parent
12f3f1fc05
commit
d43bc83531
11 files changed
+189
-1
No files matched your search
+1
@@ -221,6 +221,7 @@
|
||||
[controlledPrograms]="controlledEpgPrograms()"
|
||||
[controlledArchiveDays]="controlledArchiveDays()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
[selectedDate]="
|
||||
unifiedDateNavigator ? selectedLiveEpgDate() : null
|
||||
"
|
||||
|
||||
+41
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user