mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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:
1 parent
87dc45957b
commit
d5f84fb130
42 files changed
+689
-22
No files matched your search
+36
-14
@@ -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>
|
||||
+58
-6
@@ -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;
|
||||
}
|
||||
+70
@@ -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', '');
|
||||
|
||||
+20
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user