mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
+2
@@ -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"
|
||||
|
||||
+27
@@ -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' }),
|
||||
|
||||
+17
@@ -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(
|
||||
|
||||
+122
@@ -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();
|
||||
});
|
||||
});
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -113,7 +113,7 @@
|
||||
[showEpg]="supportsEpg && !isRadioMode()"
|
||||
[isRadio]="isRadioMode()"
|
||||
[showFavoriteButton]="true"
|
||||
[showProgramInfoButton]="false"
|
||||
[showProgramInfoButton]="true"
|
||||
[showDetailsContextMenu]="
|
||||
supportsEpgMapping && !isRadioMode()
|
||||
"
|
||||
|
||||
+3
-1
@@ -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)"
|
||||
|
||||
+7
@@ -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())
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user