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

@@ -40,6 +40,8 @@
[isRadio]="ch.radio === 'true'"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[catchupAvailable]="catchupAvailable(ch)"
[catchupDays]="catchupDays(ch)"
[showFavoriteButton]="true"
[isFavorite]="isChannelFavorite(ch)"
[showProgramInfoButton]="true"
@@ -75,6 +75,33 @@ describe('GlobalFavoritesListComponent', () => {
expect(icons).toEqual(['star_outline', 'star']);
});
it('shows the catch-up badge on Xtream rows with a playable archive', () => {
fixture.componentRef.setInput('channels', [
buildChannel('x1', 'Archive Channel', {
sourceType: 'xtream',
xtreamId: 42,
tvArchive: 1,
tvArchiveDuration: 7,
}),
buildChannel('x2', 'Plain Channel', {
sourceType: 'xtream',
xtreamId: 43,
tvArchive: 0,
tvArchiveDuration: 0,
}),
buildChannel('m1', 'M3U Channel'),
]);
fixture.detectChanges();
const badges = fixture.nativeElement.querySelectorAll(
'[data-test-id="catchup-badge"]'
);
expect(badges).toHaveLength(1);
const badgeRow = badges[0].closest('[data-test-id="channel-item"]');
expect(badgeRow?.textContent).toContain('Archive Channel');
});
it('renders radio rows as compact without a false EPG placeholder', () => {
fixture.componentRef.setInput('channels', [
buildChannel('radio', 'Radio One', { radio: 'true' }),
@@ -35,6 +35,8 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
getXtreamCatchupDays,
isXtreamCatchupAvailable,
sortFavoriteChannelItems,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
@@ -144,6 +146,21 @@ export class GlobalFavoritesListComponent {
});
});
/** Catch-up fields arrive camelCase on unified rows — adapt for the
* shared snake_case helper. Only Xtream rows carry them. */
protected catchupAvailable(channel: UnifiedFavoriteChannel): boolean {
return isXtreamCatchupAvailable({
tv_archive: channel.tvArchive,
tv_archive_duration: channel.tvArchiveDuration,
});
}
protected catchupDays(channel: UnifiedFavoriteChannel): number {
return getXtreamCatchupDays({
tv_archive_duration: channel.tvArchiveDuration,
});
}
onChannelClick(channel: UnifiedFavoriteChannel): void {
this.channelSelected.emit(channel);
}
@@ -107,6 +107,41 @@ mat-card:hover {
}
// ─── Rating badge ─────────────────────────────────────────────────────────────
.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;
}
.catchup-badge {
position: absolute;
top: 8px;
left: 8px;
width: 24px;
height: 24px;
// Solid dark pill like .rating — no expensive backdrop-filter
background: rgba(0, 0, 0, 0.85);
color: rgba(255, 255, 255, 0.88);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
line-height: 15px;
}
}
.rating {
position: absolute;
top: 6px;
@@ -173,6 +173,70 @@ describe('GridListComponent', () => {
);
});
it('shows the catch-up badge on live cards with a playable archive', () => {
fixture.componentRef.setInput('items', [
{
name: 'Archive Channel',
stream_icon: 'channel-logo.png',
tv_archive: 1,
tv_archive_duration: 7,
},
]);
fixture.componentRef.setInput('variant', 'logo');
fixture.componentRef.setInput('type', 'live');
fixture.detectChanges();
const badge = fixture.debugElement.query(
By.css('[data-test-id="grid-catchup-badge"]')
);
expect(badge).not.toBeNull();
expect(badge.nativeElement.textContent).toContain('history');
// The icon is aria-hidden — the status must also exist as
// visually-hidden text for assistive technology.
const srText = badge.query(By.css('.visually-hidden'));
expect(srText.nativeElement.textContent).toContain(
'CATCHUP_AVAILABLE'
);
});
it('hides the catch-up badge for live cards without a playable archive', () => {
fixture.componentRef.setInput('items', [
{ name: 'Flag Only', tv_archive: 1, tv_archive_duration: 0 },
{ name: 'No Archive', tv_archive: 0, tv_archive_duration: 7 },
{ name: 'Legacy Row' },
]);
fixture.componentRef.setInput('type', 'live');
fixture.detectChanges();
expect(
fixture.debugElement.queryAll(
By.css('[data-test-id="grid-catchup-badge"]')
)
).toHaveLength(0);
});
it('never shows the catch-up badge on VOD grids', () => {
fixture.componentRef.setInput('items', [
{
title: 'Some Movie',
tv_archive: 1,
tv_archive_duration: 7,
},
]);
fixture.componentRef.setInput('type', 'vod');
fixture.detectChanges();
expect(
fixture.debugElement.query(
By.css('[data-test-id="grid-catchup-badge"]')
)
).toBeNull();
});
it('renders raw titles while prefix stripping is disabled', () => {
fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]);
fixture.componentRef.setInput('type', 'live');
@@ -13,6 +13,10 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import {
getXtreamCatchupDays,
isXtreamCatchupAvailable,
} from '@iptvnator/portal/shared/util';
import { SettingsStore } from '@iptvnator/services';
import {
ProgressCapsuleComponent,
@@ -38,6 +42,8 @@ interface GridListItem {
progress?: number;
isWatched?: boolean;
hasSeriesProgress?: boolean;
tv_archive?: number | string | null;
tv_archive_duration?: number | string | null;
[key: string]: unknown;
}
@@ -136,6 +142,24 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
@if (i.progress && i.progress > 0) {
<app-progress-capsule [progress]="i.progress" />
}
@if (showCatchupBadge(i)) {
<div
class="catchup-badge"
data-test-id="grid-catchup-badge"
[matTooltip]="
catchupLabelKey(i)
| translate: { days: catchupDays(i) }
"
>
<mat-icon>history</mat-icon>
<!-- mat-icon is aria-hidden; expose the
status as text for AT users -->
<span class="visually-hidden">{{
catchupLabelKey(i)
| translate: { days: catchupDays(i) }
}}</span>
</div>
}
@if (i.isWatched) {
<app-watched-badge
[isWatched]="true"
@@ -250,6 +274,16 @@ export class GridListComponent {
private readonly isLiveGrid = computed(() =>
['live', 'itv', 'radio'].includes(this.type())
);
protected readonly catchupDays = getXtreamCatchupDays;
/** Catch-up badge is live-grid only; VOD/series rows never carry it. */
protected showCatchupBadge(item: GridListItem): boolean {
return this.isLiveGrid() && isXtreamCatchupAvailable(item);
}
protected catchupLabelKey(item: GridListItem): string {
return getXtreamCatchupDays(item) > 0
? 'CHANNELS.CATCHUP_AVAILABLE_DAYS'
: 'CHANNELS.CATCHUP_AVAILABLE';
}
protected readonly channelTitle = (item: GridListItem): string => {
const raw = item.title ?? item.o_name ?? item.name ?? '';
const stripped = applyChannelNameStrip(
@@ -0,0 +1,122 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import {
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
/**
* Focused spec for the `channelsForList` row mapping — kept separate from
* the main layout spec, which sits at the max-lines test budget.
*/
describe('UnifiedLiveTabComponent channelsForList', () => {
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent],
providers: [
{
provide: StreamResolverService,
useValue: {
resolveLiveDetail: jest.fn(),
loadEpgForItems: jest
.fn()
.mockResolvedValue(new Map()),
},
},
{
provide: UnifiedRecentDataService,
useValue: { recordLivePlayback: jest.fn() },
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsEpg: false },
},
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
player: signal(VideoPlayer.VideoJs),
stripCountryPrefix: signal(false),
resolvedEpgViewMode: signal('timeline'),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
},
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
})
.overrideComponent(UnifiedLiveTabComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(UnifiedLiveTabComponent);
});
afterEach(() => fixture.destroy());
it('passes the archive fields through to the sidebar rows', () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::pl-1::42',
name: 'Archive Channel',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'pl-1',
playlistName: 'Portal One',
logo: null,
posterUrl: null,
xtreamId: 42,
tvArchive: 1,
tvArchiveDuration: 5,
addedAt: '2026-04-30T12:00:00.000Z',
position: 0,
};
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
const row = fixture.componentInstance.channelsForList()[0];
expect(row.tvArchive).toBe(1);
expect(row.tvArchiveDuration).toBe(5);
});
it('normalises absent archive fields to null', () => {
const item = {
uid: 'm3u::pl-2::chan',
name: 'Plain Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'pl-2',
playlistName: 'Playlist Two',
logo: null,
posterUrl: null,
addedAt: '2026-04-30T12:00:00.000Z',
position: 0,
} as UnifiedCollectionItem;
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
const row = fixture.componentInstance.channelsForList()[0];
expect(row.tvArchive).toBeNull();
expect(row.tvArchiveDuration).toBeNull();
});
});
@@ -285,6 +285,8 @@ export class UnifiedLiveTabComponent {
m3uChannel: item.m3uChannel,
radio: item.radio,
xtreamId: item.xtreamId,
tvArchive: item.tvArchive ?? null,
tvArchiveDuration: item.tvArchiveDuration ?? null,
tvgId: item.tvgId,
stalkerCmd: item.stalkerCmd,
stalkerPortalUrl: item.stalkerPortalUrl,
+1
View File
@@ -26,6 +26,7 @@ export * from './lib/navigation/provider-detail-mode';
export * from './lib/navigation/portal-route.utils';
export * from './lib/navigation/workspace-portal-navigation';
export * from './lib/collection';
export * from './lib/xtream-catchup.util';
export * from './lib/external-playback-button-state';
export * from './lib/inline-playback-position-writer';
export * from './lib/downloads/download-metadata-snapshot';
@@ -40,6 +40,12 @@ export interface UnifiedFavoriteChannel {
/** Xtream numeric stream ID (Xtream channels only) */
xtreamId?: number;
/** Provider catch-up flag (Xtream live channels only; 1 = archive) */
tvArchive?: number | null;
/** Provider catch-up window in days (Xtream live channels only) */
tvArchiveDuration?: number | null;
/**
* TVG id for M3U EPG lookup.
* For Xtream channels, also used for short-EPG lookup.
@@ -0,0 +1,82 @@
import {
getXtreamCatchupDays,
isXtreamCatchupAvailable,
} from './xtream-catchup.util';
describe('xtream-catchup.util', () => {
describe('getXtreamCatchupDays', () => {
it('returns the numeric archive window', () => {
expect(
getXtreamCatchupDays({ tv_archive: 1, tv_archive_duration: 7 })
).toBe(7);
});
it('parses string durations sent by some panels', () => {
expect(
getXtreamCatchupDays({
tv_archive: '1',
tv_archive_duration: '3',
})
).toBe(3);
});
it('returns 0 for absent, null, or unparsable values', () => {
expect(getXtreamCatchupDays(undefined)).toBe(0);
expect(getXtreamCatchupDays(null)).toBe(0);
expect(getXtreamCatchupDays({})).toBe(0);
expect(
getXtreamCatchupDays({ tv_archive_duration: null })
).toBe(0);
expect(
getXtreamCatchupDays({ tv_archive_duration: 'soon' })
).toBe(0);
});
it('clamps negative durations to 0', () => {
expect(
getXtreamCatchupDays({ tv_archive_duration: -2 })
).toBe(0);
});
});
describe('isXtreamCatchupAvailable', () => {
it('requires both the flag and a positive window', () => {
expect(
isXtreamCatchupAvailable({
tv_archive: 1,
tv_archive_duration: 7,
})
).toBe(true);
});
it('accepts string field values', () => {
expect(
isXtreamCatchupAvailable({
tv_archive: '1',
tv_archive_duration: '5',
})
).toBe(true);
});
it('rejects a flag without a playable window', () => {
expect(
isXtreamCatchupAvailable({
tv_archive: 1,
tv_archive_duration: 0,
})
).toBe(false);
});
it('rejects channels without the archive flag', () => {
expect(
isXtreamCatchupAvailable({
tv_archive: 0,
tv_archive_duration: 7,
})
).toBe(false);
expect(isXtreamCatchupAvailable({})).toBe(false);
expect(isXtreamCatchupAvailable(null)).toBe(false);
expect(isXtreamCatchupAvailable(undefined)).toBe(false);
});
});
});
@@ -0,0 +1,33 @@
/**
* Provider catch-up (archive/timeshift) availability for Xtream live streams.
*
* The gate mirrors the playback-side checks in `live-stream-layout`
* (`archivePlaybackAvailable`) and `unified-live-tab`
* (`timelineArchiveAvailable`): the provider must flag the channel with
* `tv_archive === 1` AND expose a positive archive window. A bare flag with a
* zero-day window is not playable through the catch-up URL builder, so it
* must not be advertised as available.
*/
export interface XtreamCatchupFields {
readonly tv_archive?: number | string | null;
readonly tv_archive_duration?: number | string | null;
}
/**
* Catch-up window in days, `0` when absent or unparsable. Xtream panels
* report `tv_archive_duration` in days; some send it as a string.
*/
export function getXtreamCatchupDays(
item: XtreamCatchupFields | null | undefined
): number {
return Math.max(0, Number(item?.tv_archive_duration ?? 0) || 0);
}
/** Whether the provider declares playable catch-up for this channel. */
export function isXtreamCatchupAvailable(
item: XtreamCatchupFields | null | undefined
): boolean {
return (
Number(item?.tv_archive ?? 0) === 1 && getXtreamCatchupDays(item) > 0
);
}
@@ -113,7 +113,7 @@
[showEpg]="supportsEpg && !isRadioMode()"
[isRadio]="isRadioMode()"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showProgramInfoButton]="true"
[showDetailsContextMenu]="
supportsEpgMapping && !isRadioMode()
"
@@ -42,8 +42,10 @@
: 0
"
[showEpg]="supportsEpg"
[catchupAvailable]="isCatchupAvailable(item)"
[catchupDays]="catchupDays(item)"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showProgramInfoButton]="true"
[showDetailsContextMenu]="supportsEpgMapping"
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
(clicked)="playClicked.emit(item)"
@@ -38,6 +38,8 @@ import {
EpgMappingDialogComponent,
} from '@iptvnator/ui/components';
import {
getXtreamCatchupDays,
isXtreamCatchupAvailable,
PortalChannelSortMode,
sortPortalChannelItems,
} from '@iptvnator/portal/shared/util';
@@ -57,6 +59,8 @@ export interface XtreamChannelListItem {
readonly type?: 'live' | 'movie' | 'series' | 'vod';
readonly xtream_id: number;
readonly epg_channel_id?: string | null;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | string | null;
}
interface XtreamCategoryLike {
@@ -193,6 +197,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
return item.xtream_id;
}
protected readonly isCatchupAvailable = isXtreamCatchupAvailable;
protected readonly catchupDays = getXtreamCatchupDays;
ngOnInit(): void {
const { categoryId } = this.route.snapshot.params;
if (categoryId && !this.channelsOverride())
@@ -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);
@@ -38,6 +38,8 @@ interface XtreamGlobalFavoriteRow {
readonly position?: number | null;
readonly poster_url?: string | null;
readonly title: string;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | null;
readonly xtream_id: number;
}
@@ -220,6 +222,8 @@ export class GlobalFavoritesService {
playlistId: item.playlist_id,
playlistName: item.playlist_name,
xtreamId: item.xtream_id,
tvArchive: item.tv_archive ?? null,
tvArchiveDuration: item.tv_archive_duration ?? null,
tvgId: String(item.xtream_id),
addedAt: item.added_at ?? new Date(0).toISOString(),
position: item.position ?? 0,