feat(xtream): catch-up badge for live channels with archive (#1341)

* feat(xtream): show a catch-up badge on live channels that have archive

Live channels whose provider declares playable catch-up (tv_archive=1
with a positive tv_archive_duration) now show a small history badge in
the channel sidebar next to the name and on the all-channels grid cards,
with the archive window (days) in the tooltip.

Closes #1128

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(xtream): expose the catch-up badge status to assistive technology

The mat-icon is aria-hidden and the tooltip is pointer-only, so the
badge status was invisible to keyboard and screen-reader users. Both
badge surfaces now also render the translated status as visually-hidden
text (Codex review, P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(xtream): show the catch-up badge in favorites and recent lists

Carries tvArchive/tvArchiveDuration through UnifiedFavoriteChannel so
the shared favorites list (portal favorites/recent tabs and global
favorites) renders the same catch-up badge as the live sidebar.
Requested in PR feedback by the issue author.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): show the programme-info button in portal sidebars, stacked vertically

Adds the (i) programme-info button to the Xtream and Stalker live
sidebars and reworks the row action column: buttons stack vertically
(favorite on top, info below), so the second button costs no horizontal
space — the column is actually narrower than the previous single-button
row. The info slot is reserved (inert, visibility:hidden) while the row
has no programme, so the star never shifts when EPG data arrives.
Requested by the issue author in PR feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ui): move the archive passthrough spec out of the budget-capped file

CI lints the merge with master, where unified-live-tab.component.spec.ts
grew (#1374) to one line under the 1200 max-lines test budget — the
archive passthrough test added here tipped the merged result over. The
test moves to a focused template-less spec (plus a null-normalisation
case), leaving the main spec at master's size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): constrain Material touch targets to the stacked button bounds

mat-icon-button keeps a 48px touch target; stacked 28px buttons
overlapped by 20px and the later sibling (programme info) stole clicks
from the lower third of the favorite star. Verified via
document.elementFromPoint before/after: the star's visual bounds now hit
the star, and clicks left of the column reach the row again instead of
the button's oversized target (Codex review).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-09 10:47:44 +02:00
1 parent 87dc45957b
commit d5f84fb130
42 files changed
+689 -22

No files matched your search

@@ -28,7 +28,24 @@
}
</div>
<div class="channel-details">
<div class="channel-name">{{ displayName() }}</div>
<div class="channel-name-row">
<div class="channel-name">{{ displayName() }}</div>
@if (catchupAvailable()) {
<mat-icon
class="catchup-badge"
data-test-id="catchup-badge"
[matTooltip]="
catchupLabelKey()
| translate: { days: catchupDays() }
"
>history</mat-icon
>
<!-- mat-icon is aria-hidden; expose the status as text -->
<span class="visually-hidden">{{
catchupLabelKey() | translate: { days: catchupDays() }
}}</span>
}
</div>
@if (showEpg()) {
@if (epgProgram(); as program) {
<div class="epg-title">
@@ -65,23 +82,11 @@
</div>
</div>
@if (
(showProgramInfoButton() && showEpg() && epgProgram()) ||
(showProgramInfoButton() && showEpg()) ||
showFavoriteButton() ||
showAuxActionButton()
) {
<div class="action-buttons">
@if (showProgramInfoButton() && showEpg() && epgProgram(); as program) {
<button
mat-icon-button
class="program-info-button"
[matTooltip]="
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
"
(click)="showProgramDescription(program, $event)"
>
<mat-icon>info</mat-icon>
</button>
}
@if (showFavoriteButton()) {
<button
mat-icon-button
@@ -109,6 +114,23 @@
<mat-icon>{{ auxActionIcon() }}</mat-icon>
</button>
}
@if (showProgramInfoButton() && showEpg()) {
<!-- The slot is reserved even before the programme loads so
the buttons above never shift when EPG data arrives -->
<button
mat-icon-button
class="program-info-button"
[class.slot-empty]="!epgProgram()"
[disabled]="!epgProgram()"
[attr.aria-hidden]="!epgProgram()"
[matTooltip]="
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
"
(click)="onProgramInfoClick($event)"
>
<mat-icon>info</mat-icon>
</button>
}
</div>
}
</div>
@@ -123,6 +123,13 @@
gap: 3px;
}
.channel-name-row {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.channel-name {
min-width: 0;
overflow: hidden;
@@ -134,6 +141,27 @@
line-height: 1.2;
}
.catchup-badge {
flex-shrink: 0;
width: 15px;
height: 15px;
font-size: 15px;
line-height: 15px;
opacity: 0.62;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.epg-title {
min-width: 0;
overflow: hidden;
@@ -208,13 +236,20 @@
.action-buttons {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
justify-content: center;
gap: 0;
margin-inline-start: auto;
.channel-list-item.compact & {
flex-direction: row;
gap: 2px;
}
button {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
/* border-radius: 10px; */
color: inherit;
opacity: 0.76;
@@ -231,9 +266,19 @@
}
mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
}
/* Material keeps a 48px touch target on icon buttons; stacked 28px
buttons would overlap by 20px and the later sibling would steal
clicks from the lower third of the button above. Constrain the
target to the button bounds. */
button ::ng-deep .mat-mdc-button-touch-target {
width: 100%;
height: 100%;
}
}
@@ -326,3 +371,10 @@
.program-info-button {
padding: 0;
}
/* Reserved slot: keeps the column geometry stable while the row has no
programme yet (or none at all), so the star never jumps. */
.program-info-button.slot-empty {
visibility: hidden;
pointer-events: none;
}
@@ -98,6 +98,76 @@ describe('ChannelListItemComponent', () => {
).toBe(true);
});
it('stacks the favorite button above the reserved programme-info slot', () => {
const program: EpgProgram = {
start: '2026-04-05 05:30:00',
stop: '2026-04-05 06:00:00',
channel: 'channel-1',
title: 'Current Show',
desc: 'Current description',
category: null,
};
fixture.componentRef.setInput('name', 'Ordered Channel');
fixture.componentRef.setInput('showFavoriteButton', true);
fixture.componentRef.setInput('epgProgram', program);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.action-buttons button'),
(element: Element) => element.className
);
expect(buttons).toHaveLength(2);
expect(buttons[0]).toContain('favorite-button');
expect(buttons[1]).toContain('program-info-button');
expect(buttons[1]).not.toContain('slot-empty');
});
it('keeps an inert info slot while the row has no programme', () => {
fixture.componentRef.setInput('name', 'No EPG Yet');
fixture.componentRef.setInput('showFavoriteButton', true);
fixture.detectChanges();
// The slot must exist (reserving the geometry) but be inert, so the
// star above it never shifts when EPG data arrives later.
const slot = fixture.nativeElement.querySelector(
'.program-info-button'
);
expect(slot).not.toBeNull();
expect(slot.classList.contains('slot-empty')).toBe(true);
expect(slot.disabled).toBe(true);
slot.click();
expect(dialog.open).not.toHaveBeenCalled();
});
it('renders the catch-up badge only when catch-up is available', () => {
fixture.componentRef.setInput('name', 'Archive Channel');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="catchup-badge"]'
)
).toBeNull();
fixture.componentRef.setInput('catchupAvailable', true);
fixture.componentRef.setInput('catchupDays', 7);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector(
'[data-test-id="catchup-badge"]'
);
expect(badge).not.toBeNull();
expect(badge.textContent).toContain('history');
// The icon is aria-hidden — the status must also exist as
// visually-hidden text for assistive technology.
const srText = fixture.nativeElement.querySelector(
'.channel-name-row .visually-hidden'
);
expect(srText?.textContent).toContain('CATCHUP_AVAILABLE');
});
it('shows the generic fallback icon when no logo is available', () => {
fixture.componentRef.setInput('name', 'Channel Without Logo');
fixture.componentRef.setInput('logo', '');
@@ -57,6 +57,15 @@ export class ChannelListItemComponent {
readonly selected = input(false);
readonly showEpg = input(true);
readonly isRadio = input(false);
/** Shows the provider catch-up (archive) badge next to the channel name */
readonly catchupAvailable = input(false);
/** Archive window in days for the badge tooltip; 0 hides the day count */
readonly catchupDays = input(0);
readonly catchupLabelKey = computed(() =>
this.catchupDays() > 0
? 'CHANNELS.CATCHUP_AVAILABLE_DAYS'
: 'CHANNELS.CATCHUP_AVAILABLE'
);
readonly epgProgram = input<EpgProgram | null | undefined>();
/** Progress percentage pre-computed by parent for performance */
readonly progressPercentage = input(0);
@@ -88,6 +97,17 @@ export class ChannelListItemComponent {
});
}
/** Guarded click for the reserved (possibly still empty) info slot. */
onProgramInfoClick(event: MouseEvent): void {
const program = this.epgProgram();
if (!program) {
event.stopPropagation();
return;
}
this.showProgramDescription(program, event);
}
onFavoriteClick(event: MouseEvent): void {
event.stopPropagation();
this.favoriteToggled.emit(event);