mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playlist): show desktop health indicators for network sources (#1592)
This commit is contained in:
1 parent
a417826b01
commit
62655a8b5d
67 files changed
+2302
-60
No files matched your search
+37
-25
@@ -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 {
|
||||
|
||||
+33
-8
@@ -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
|
||||
|
||||
+19
-7
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user