fix(angular): remove template diagnostics warnings (#1011)

* fix(angular): remove template diagnostics warnings

* fix(angular): preserve template fallback behavior
This commit is contained in:
4gray authored and GitHub committed 2026-05-26 18:33:44 +02:00
1 parent 1bbe8f794e
commit 3d4d2ca9bf
19 files changed
+184 -40

No files matched your search

@@ -3,10 +3,10 @@
<div class="account-dialog__eyebrow">
<span class="account-dialog__eyebrow-mark"></span>
@if (accountInfo(); as info) {
{{ info.server_info?.server_protocol || 'Xtream' }}
@if (info.server_info?.timezone) {
{{ info.server_info.server_protocol || 'Xtream' }}
@if (info.server_info.timezone) {
<span class="account-dialog__eyebrow-divider"></span>
{{ info.server_info?.timezone }}
{{ info.server_info.timezone }}
}
} @else {
Xtream
@@ -29,7 +29,7 @@
class="account-dialog__status-pill"
[class.account-dialog__status-pill--active]="isActive()"
>
{{ info.user_info?.status || '-' }}
{{ info.user_info.status || '-' }}
</span>
@if (isTrial()) {
@@ -122,7 +122,7 @@
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}
</p>
<h3>{{ info.user_info?.username || '-' }}</h3>
<h3>{{ info.user_info.username || '-' }}</h3>
</div>
<dl class="account-detail-list">
@@ -5,7 +5,7 @@
[title]="info.name"
[description]="info.plot"
[posterUrl]="info.cover"
[backdropUrl]="info.backdrop_path?.[0]"
[backdropUrl]="getBackdropUrl(info)"
(backClicked)="goBack()"
>
<ng-container hero-tags>
@@ -101,7 +101,7 @@
[seasons]="item.episodes"
[seriesId]="item.series_id"
[playlistId]="currentPlaylistId()"
[seriesTitle]="info.name ?? ''"
[seriesTitle]="info.name"
[playbackPositions]="episodePlaybackPositions()"
[xtreamDownloadContext]="xtreamDownloadContext()"
[openingEpisodeId]="openingEpisodeId()"
@@ -278,6 +278,26 @@ describe('SerialDetailsComponent', () => {
});
});
it('renders series metadata when backdrop_path is absent at runtime', () => {
selectedItem.set({
series_id: 103,
info: {
name: 'Series Without Backdrop',
plot: 'Series plot',
cover: 'cover.jpg',
genre: 'Drama',
},
episodes: {},
});
expect(() => fixture.detectChanges()).not.toThrow();
const hero = fixture.debugElement.query(
By.directive(StubContentHeroComponent)
).componentInstance as StubContentHeroComponent;
expect(hero.backdropUrl()).toBeUndefined();
});
it('renders quick start as the first episode action and opens that episode', async () => {
fixture.detectChanges();
await fixture.whenStable();
@@ -292,9 +312,7 @@ describe('SerialDetailsComponent', () => {
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_FIRST_EPISODE'
);
expect(quickStartButton?.textContent).toContain(
'S01E01 · Episode 1'
);
expect(quickStartButton?.textContent).toContain('S01E01 · Episode 1');
quickStartButton?.click();
@@ -349,9 +367,7 @@ describe('SerialDetailsComponent', () => {
expect(quickStartButton?.textContent).toContain(
'XTREAM.RESUME_EPISODE'
);
expect(quickStartButton?.textContent).toContain(
'S01E01 · Episode 1'
);
expect(quickStartButton?.textContent).toContain('S01E01 · Episode 1');
quickStartButton?.click();
@@ -34,6 +34,7 @@ import {
PlaybackPositionData,
PlayerContentInfo,
ResolvedPortalPlayback,
XtreamSerieInfo,
XtreamSerieEpisode,
XtreamSerieDetails,
} from '@iptvnator/shared/interfaces';
@@ -309,6 +310,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
);
}
getBackdropUrl(info: XtreamSerieInfo): string | undefined {
return info.backdrop_path?.[0];
}
goBack(): void {
this.closeInlinePlayer();
this.location.back();
@@ -4,7 +4,7 @@
[title]="info.name || item?.movie_data?.name"
[description]="info.description || info.plot"
[posterUrl]="info.movie_image || info.cover_big"
[backdropUrl]="info.backdrop_path?.[0]"
[backdropUrl]="getBackdropUrl(info)"
(backClicked)="goBack()"
>
<ng-container hero-tags>
@@ -1,9 +1,11 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { Location } from '@angular/common';
import { ContentHeroComponent } from '@iptvnator/ui/components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
@@ -37,9 +39,9 @@ describe('VodDetailsRouteComponent', () => {
const checkFavoriteStatus = jest.fn();
const setSelectedItem = jest.fn();
const toggleFavorite = jest.fn();
const constructVodStreamUrl = jest.fn().mockReturnValue(
'http://example.com/movie/650020.mp4'
);
const constructVodStreamUrl = jest
.fn()
.mockReturnValue('http://example.com/movie/650020.mp4');
const addRecentItem = jest.fn();
const downloads = signal([]);
const getPlaybackPosition = jest.fn().mockResolvedValue(null);
@@ -134,7 +136,9 @@ describe('VodDetailsRouteComponent', () => {
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition,
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
savePlaybackPosition: jest
.fn()
.mockResolvedValue(undefined),
},
},
{
@@ -190,7 +194,8 @@ describe('VodDetailsRouteComponent', () => {
const host = fixture.nativeElement as HTMLElement;
expect(host.textContent).toContain('Die Kühe sind Los! (2004) DE');
expect(
host.querySelector('[data-testid="xtream-vod-fallback"]')?.textContent
host.querySelector('[data-testid="xtream-vod-fallback"]')
?.textContent
).toContain('XTREAM.DETAIL_FALLBACK.NOTE');
expect(
host.querySelector('[data-testid="xtream-vod-fallback-status"]')
@@ -248,7 +253,35 @@ describe('VodDetailsRouteComponent', () => {
const host = fixture.nativeElement as HTMLElement;
expect(host.textContent).toContain('City of McFarland (2015)');
expect(host.querySelector('[data-testid="xtream-vod-fallback"]')).toBeNull();
expect(
host.querySelector('[data-testid="xtream-vod-fallback"]')
).toBeNull();
expect(host.querySelector('button.play-btn')).not.toBeNull();
});
it('renders usable metadata when backdrop_path is absent at runtime', () => {
selectedItem.set({
info: {
name: 'Metadata Without Backdrop',
description: 'A populated description',
movie_image: 'https://example.com/poster.jpg',
},
movie_data: {
stream_id: 678140,
name: 'Metadata Without Backdrop',
added: '1750671180',
category_id: '235',
container_extension: 'mkv',
custom_sid: null,
direct_source: '',
},
} as unknown as XtreamVodDetails);
expect(() => fixture.detectChanges()).not.toThrow();
const hero = fixture.debugElement.query(
By.directive(ContentHeroComponent)
).componentInstance as ContentHeroComponent;
expect(hero.backdropUrl()).toBeUndefined();
});
});
@@ -38,6 +38,7 @@ import {
ResolvedPortalPlayback,
XtreamCategory,
XtreamVodDetails,
XtreamVodInfo,
XtreamVodStream,
getXtreamVodInfo,
} from '@iptvnator/shared/interfaces';
@@ -470,6 +471,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
);
}
getBackdropUrl(info: XtreamVodInfo): string | undefined {
return info.backdrop_path?.[0];
}
goBack(): void {
this.closeInlinePlayer();
this.location.back();
@@ -2,12 +2,12 @@
<div class="channel-dialog__hero-copy">
<div class="channel-dialog__eyebrow">
<span class="channel-dialog__eyebrow-mark"></span>
{{ channel.group?.title || 'M3U' }}
{{ channel.group.title || 'M3U' }}
</div>
<div class="channel-dialog__headline-row">
<div class="channel-dialog__headline-text">
@if (channel.tvg?.logo) {
@if (channel.tvg.logo) {
<img
class="channel-dialog__logo"
[src]="channel.tvg.logo"
@@ -10,14 +10,14 @@
i +
1 +
'. ' +
(item.channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
(item.channel.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="item.logo"
[showEpg]="shouldShowEpg()"
[isRadio]="item.channel?.radio === 'true'"
[isRadio]="item.channel.radio === 'true'"
[epgProgram]="item.epgProgram"
[progressPercentage]="item.progressPercentage"
[selected]="activeChannelUrl() === item.channel?.url"
[selected]="activeChannelUrl() === item.channel.url"
[showDetailsContextMenu]="true"
[showAuxActionButton]="true"
[auxActionIcon]="'delete'"
@@ -53,11 +53,11 @@
<button
class="season-card"
(click)="selectSeason(season.key)"
[class.season-card--has-episodes]="season.value?.length"
[class.season-card--has-episodes]="season.value.length"
>
<div class="season-card__number">{{ season.key }}</div>
<div class="season-card__label">Season</div>
@if (season.value?.length) {
@if (season.value.length) {
<div class="season-card__episodes">
{{ season.value.length }}
{{
@@ -21,7 +21,7 @@
</span>
}
@if (item?.desc) {
@if (item.desc) {
<span
(click)="$event.stopPropagation(); showDescription(item)"
matListItemMeta
@@ -29,7 +29,7 @@
>
<mat-icon>info</mat-icon>
</button>
{{ activeChannel?.name }}
{{ activeChannel.name }}
<div class="spacer"></div>
<!-- <button
mat-icon-button
@@ -27,7 +27,7 @@
} @else if (player === 'embedded-mpv') {
<app-embedded-mpv-player
[playback]="resolvedPlayback()"
[recordingFolder]="settings()?.recordingFolder ?? ''"
[recordingFolder]="recordingFolder()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else {
@@ -7,7 +7,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
@@ -303,6 +303,31 @@ describe('WebPlayerViewComponent', () => {
);
});
it('renders embedded MPV before settings storage emits', () => {
fixture.destroy();
const pendingSettings = new Subject<unknown>();
storageMap.get.mockReturnValue(pendingSettings.asObservable());
fixture = TestBed.createComponent(WebPlayerViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
'https://example.com/archive/movie.mkv'
);
fixture.componentRef.setInput('title', 'Example Movie');
fixture.componentRef.setInput(
'playerOverride',
VideoPlayer.EmbeddedMpv
);
expect(() => fixture.detectChanges()).not.toThrow();
const player = fixture.debugElement.query(
By.directive(StubEmbeddedMpvPlayerComponent)
).componentInstance as StubEmbeddedMpvPlayerComponent;
expect(player.recordingFolder()).toBe('');
});
it('uses the PWA browser access diagnostic description key outside desktop', () => {
const issue = createBrowserAccessDiagnostic();
@@ -80,9 +80,9 @@ export class WebPlayerViewComponent {
}>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
settings = toSignal(
this.storage.get(STORE_KEY.Settings)
) as Signal<Settings>;
settings = toSignal(this.storage.get(STORE_KEY.Settings)) as Signal<
Settings | undefined
>;
channel!: Channel;
player!: VideoPlayer;
@@ -122,6 +122,9 @@ export class WebPlayerViewComponent {
this.settings()?.player ??
VideoPlayer.VideoJs
);
readonly recordingFolder = computed(
() => this.settings()?.recordingFolder ?? ''
);
constructor() {
effect(() => {
@@ -2,11 +2,14 @@
<div class="epg-list">
@for (item of epgItems; track $index) {
<article class="epg-item" [class.current-program]="isCurrentProgram(item)">
<article
class="epg-item"
[class.current-program]="isCurrentProgram(item)"
>
<div class="epg-item__meta">
<div class="epg-item__time">
{{ item.start | date: 'HH:mm' }} -
{{ item.stop ?? item.end | date: 'HH:mm' }}
{{ getProgramEnd(item) | date: 'HH:mm' }}
</div>
@if (isCurrentProgram(item)) {
<span class="epg-item__badge">Live</span>
@@ -41,7 +44,7 @@
</article>
}
@if (epgItems?.length === 0) {
@if (epgItems.length === 0) {
<div class="epg-empty">
{{ 'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate }}
</div>
@@ -0,0 +1,50 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { EpgItem } from '@iptvnator/shared/interfaces';
import { EpgViewComponent } from './epg-view.component';
describe('EpgViewComponent', () => {
let fixture: ComponentFixture<EpgViewComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EpgViewComponent, TranslateModule.forRoot()],
providers: [
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(EpgViewComponent);
});
afterEach(() => {
fixture.destroy();
});
it('renders the end timestamp when stop is absent at runtime', () => {
fixture.componentInstance.epgItems = [
{
id: 'epg-1',
epg_id: 'channel-1',
title: 'Fallback program',
lang: 'en',
start: '2026-04-05T11:00:00',
end: '2026-04-05T12:30:00',
description: 'Program description',
channel_id: 'channel-1',
start_timestamp: '1775386800',
stop_timestamp: '1775392200',
} as EpgItem,
];
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('11:00 - 12:30');
});
});
@@ -20,7 +20,7 @@ export class EpgViewComponent {
dialog = inject(MatDialog);
isCurrentProgram(item: EpgItem): boolean {
const end = item.stop ?? item.end;
const end = this.getProgramEnd(item);
const now = new Date().getTime();
const start = new Date(item.start).getTime();
const stop = new Date(end).getTime();
@@ -30,7 +30,7 @@ export class EpgViewComponent {
getProgress(item: EpgItem): number {
const now = new Date().getTime();
const start = new Date(item.start).getTime();
const end = new Date(item.stop ?? item.end).getTime();
const end = new Date(this.getProgramEnd(item)).getTime();
const total = end - start;
const current = now - start;
@@ -38,6 +38,10 @@ export class EpgViewComponent {
return Math.min(Math.max((current / total) * 100, 0), 100);
}
getProgramEnd(item: EpgItem): string {
return item.stop ?? item.end;
}
showDetails(item: EpgItem) {
this.dialog.open(EpgItemDescriptionComponent, {
width: '600px',
@@ -1,4 +1,4 @@
@if ((items()?.length ?? 0) > 0) {
@if (items().length > 0) {
<div class="nav-list">
@if (statusText()) {
<div class="context-inline-status">