feat(playlist): show desktop health indicators for network sources (#1592)

This commit is contained in:
4gray authored and GitHub committed 2026-09-12 23:05:17 +02:00
1 parent a417826b01
commit 62655a8b5d
67 files changed
+2302 -60

No files matched your search

@@ -25,9 +25,7 @@
(click)="onRefreshClick($event)"
(mousedown)="$event.stopPropagation()"
[matTooltip]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
[attr.aria-label]="
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
"
[attr.aria-label]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
>
<mat-icon
class="trigger-refresh-icon"
@@ -72,26 +70,26 @@
</div>
@if (showSearchField()) {
<div class="search-container" (click)="$event.stopPropagation()">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="setSearchQuery($event)"
/>
@if (searchQuery()) {
<button
mat-icon-button
class="clear-btn"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
<div class="search-container" (click)="$event.stopPropagation()">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="setSearchQuery($event)"
/>
@if (searchQuery()) {
<button
mat-icon-button
class="clear-btn"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
}
@if (showTypeFilters()) {
@@ -146,7 +144,12 @@
<mat-icon class="playlist-type-icon">
{{ getPlaylistIcon(playlist) }}
</mat-icon>
@if (playlist.serverUrl) {
@if (runtime.supportsSourceHealth) {
<app-source-health-indicator
[playlist]="playlist"
[enabled]="isMenuOpen()"
/>
} @else if (playlist.serverUrl) {
<span
class="status-dot"
[class]="getStatusClass(playlist._id)"
@@ -256,8 +259,17 @@
<span>{{ 'WORKSPACE.SHELL.ACCOUNT_INFO' | translate }}</span>
</button>
}
@if (runtime.supportsSourceHealth && sourceHealthType(playlist)) {
<button mat-menu-item (click)="recheckSource(playlist)">
<mat-icon>network_check</mat-icon
><span>{{ 'SOURCE_HEALTH.RECHECK' | translate }}</span>
</button>
}
@if (canRefreshPlaylist(playlist)) {
<button mat-menu-item (click)="refreshPlaylistFor(playlist, $event)">
<button
mat-menu-item
(click)="refreshPlaylistFor(playlist, $event)"
>
<mat-icon>sync</mat-icon>
<span>{{
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
@@ -1,3 +1,8 @@
import { Injector } from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { sourceHealthType } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component';
import { DatePipe, DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
@@ -57,6 +62,7 @@ const DEFAULT_PLAYLIST_TYPE_FILTERS: Record<PlaylistFilterType, boolean> = {
styleUrls: ['./playlist-switcher.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SourceHealthIndicatorComponent,
DatePipe,
FormsModule,
MatDivider,
@@ -87,6 +93,13 @@ export class PlaylistSwitcherComponent {
{ initialValue: null }
);
readonly runtime = inject(RuntimeCapabilitiesService);
private readonly healthInjector = inject(Injector);
readonly sourceHealthType = sourceHealthType;
recheckSource(playlist: PlaylistMeta): void {
void this.healthInjector.get(SourceHealthService).recheck(playlist);
}
readonly currentTitle = input.required<string>();
readonly subtitle = input<string>('');
readonly showPlaylistInfo = input(false);
@@ -210,7 +223,8 @@ export class PlaylistSwitcherComponent {
if (this.showSearchField()) {
this.focusSearchField();
}
void this.checkPortalStatuses(this.playlists());
if (!this.runtime.supportsSourceHealth)
void this.checkPortalStatuses(this.playlists());
}
onMenuClosed(): void {
@@ -11,6 +11,12 @@
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
}
<div class="playlist-icon">
@if (runtime.supportsSourceHealth) {
<app-source-health-indicator
[playlist]="item"
[enabled]="!isBusy()"
/>
}
@if (item.url) {
<div class="icon-container">
<mat-icon
@@ -58,12 +64,14 @@
[matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate"
>public</mat-icon
>
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
></span>
@if (!runtime.supportsSourceHealth) {
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
></span>
}
</div>
}
@if (item.macAddress) {
@@ -83,7 +91,10 @@
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date: undefined : undefined : currentLocale() }}
{{
item.importDate
| date: undefined : undefined : currentLocale()
}}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
@@ -113,7 +124,10 @@
}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date: undefined : undefined : currentLocale() }}
{{
item.importDate
| date: undefined : undefined : currentLocale()
}}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
@@ -152,6 +166,17 @@
@if (showActions()) {
<div class="action-buttons">
@if (runtime.supportsSourceHealth && sourceHealthType(item)) {
<button
mat-icon-button
[disabled]="isBusy()"
(click)="$event.stopPropagation(); recheckSource()"
[matTooltip]="'SOURCE_HEALTH.RECHECK' | translate"
[attr.aria-label]="'SOURCE_HEALTH.RECHECK' | translate"
>
<mat-icon>network_check</mat-icon>
</button>
}
@if (canCancelBusyAction()) {
<button
mat-icon-button
@@ -1,3 +1,7 @@
import { Injector } from '@angular/core';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { sourceHealthType } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../../source-health/source-health-indicator.component';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common';
import {
@@ -30,6 +34,7 @@ import { PlaylistMeta } from '@iptvnator/shared/interfaces';
templateUrl: './playlist-item.component.html',
styleUrls: ['./playlist-item.component.scss'],
imports: [
SourceHealthIndicatorComponent,
DatePipe,
DragDropModule,
MatIconButton,
@@ -60,7 +65,7 @@ export class PlaylistItemComponent implements OnInit {
portalStatus: PortalStatus = 'unavailable';
private readonly portalStatusService = inject(PortalStatusService);
private readonly runtime = inject(RuntimeCapabilitiesService);
readonly runtime = inject(RuntimeCapabilitiesService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -77,17 +82,24 @@ export class PlaylistItemComponent implements OnInit {
);
});
private readonly healthInjector = inject(Injector);
readonly sourceHealthType = sourceHealthType;
recheckSource(): void {
void this.healthInjector.get(SourceHealthService).recheck(this.item);
}
async ngOnInit() {
await this.checkPortalStatus();
if (!this.runtime.supportsSourceHealth) await this.checkPortalStatus();
}
private async checkPortalStatus() {
if (this.item.serverUrl && this.item.username && this.item.password) {
this.portalStatus = await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
);
this.portalStatus =
await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
);
}
}
@@ -0,0 +1,65 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { SourceHealthSnapshot } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from './source-health-indicator.component';
describe('SourceHealthIndicatorComponent', () => {
it.each(['en', 'zhtw'])(
'updates the dot and localized time without restarting its request (%s)',
(language) => {
const snapshot = signal<SourceHealthSnapshot | undefined>(
undefined
);
const check = jest.fn().mockImplementation(() => {
snapshot(); // The real coordinator reads its signal-backed cache.
return Promise.resolve();
});
TestBed.configureTestingModule({
imports: [
SourceHealthIndicatorComponent,
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSourceHealth: true },
},
{
provide: SourceHealthService,
useValue: { get: snapshot, check },
},
],
});
TestBed.inject(TranslateService).use(language);
const fixture = TestBed.createComponent(
SourceHealthIndicatorComponent
);
fixture.componentRef.setInput('playlist', {
_id: 'a',
url: 'https://source.test/list',
});
fixture.detectChanges();
const requestSignal = check.mock.calls[0][1].signal as AbortSignal;
snapshot.set({
state: 'active',
reason: 'available',
confirmedInactive: false,
checkedAt: Date.now(),
});
fixture.detectChanges();
expect(fixture.componentInstance.description()).toContain(' · ');
expect(check).toHaveBeenCalledTimes(1);
expect(requestSignal.aborted).toBe(false);
expect(
fixture.nativeElement.querySelector('.health-dot').dataset.state
).toBe('active');
fixture.destroy();
expect(requestSignal.aborted).toBe(true);
}
);
});
@@ -0,0 +1,111 @@
import {
Component,
Injector,
computed,
effect,
inject,
input,
untracked,
} from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { toSignal } from '@angular/core/rxjs-interop';
import { startWith } from 'rxjs';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-source-health-indicator',
imports: [MatTooltip],
template: `@if (supported()) {
<span
class="health-dot"
[attr.data-state]="snapshot()?.state ?? 'unknown'"
[matTooltip]="description()"
[attr.aria-label]="description()"
role="img"
></span>
}`,
styles: [
`
:host {
display: inline-flex;
vertical-align: middle;
}
.health-dot {
display: block;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--mat-sys-on-surface-variant);
border: 2px solid var(--mat-sys-surface);
}
[data-state='active'] {
background: #4caf50;
}
[data-state='expired'] {
background: #ff9800;
}
[data-state='inactive'] {
background: #f44336;
}
[data-state='checking'] {
opacity: 0.45;
}
`,
],
})
export class SourceHealthIndicatorComponent {
readonly playlist = input.required<PlaylistMeta>();
readonly enabled = input(true);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly injector = inject(Injector);
private readonly translate = inject(TranslateService);
private readonly language = toSignal(
this.translate.onLangChange.pipe(startWith(null))
);
readonly supported = computed(
() =>
this.runtime.supportsSourceHealth &&
!!sourceHealthType(this.playlist())
);
readonly snapshot = computed(() =>
this.supported()
? this.injector.get(SourceHealthService).get(this.playlist())
: undefined
);
readonly description = computed(() => {
this.language();
const snapshot = this.snapshot();
const reason =
snapshot?.state === 'checking'
? 'checking'
: (snapshot?.reason ?? 'unknown');
const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`);
const time = snapshot?.checkedAt
? new Date(snapshot.checkedAt).toLocaleTimeString(
normalizeDateLocale(this.translate.currentLang || 'en')
)
: '';
const scope =
sourceHealthType(this.playlist()) === 'm3u'
? this.translate.instant('SOURCE_HEALTH.M3U_SCOPE')
: '';
return [label, scope, time].filter(Boolean).join(' · ');
});
constructor() {
effect((onCleanup) => {
const p = this.playlist();
if (!this.enabled() || !this.supported()) return;
const controller = new AbortController();
untracked(() => {
void this.injector
.get(SourceHealthService)
.check(p, { signal: controller.signal });
});
onCleanup(() => controller.abort());
});
}
}