mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
Merge remote-tracking branch 'origin/master' into perf/zoneless-flip
# Conflicts: # tools/performance/journey-baselines.json
This commit is contained in:
commit
8e807242db
191 files changed
+15429
-6865
No files matched your search
@@ -93,10 +93,3 @@ export const FavoritesActions = createActionGroup({
|
||||
'Hydrate Favorites': props<{ channelIds: string[] }>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const FilterActions = createActionGroup({
|
||||
source: 'Filters',
|
||||
events: {
|
||||
'Set Selected Filters': props<{ selectedFilters: string[] }>(),
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,6 @@ export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
|
||||
selectedId: string;
|
||||
allPlaylistsLoaded: boolean;
|
||||
loadFailed: boolean;
|
||||
selectedFilters: string[];
|
||||
}
|
||||
|
||||
export const initialPlaylistMetaState: PlaylistMetaState =
|
||||
@@ -18,7 +17,6 @@ export const initialPlaylistMetaState: PlaylistMetaState =
|
||||
selectedId: '',
|
||||
allPlaylistsLoaded: false,
|
||||
loadFailed: false,
|
||||
selectedFilters: ['m3u', 'xtream', 'stalker'],
|
||||
});
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { on } from '@ngrx/store';
|
||||
import { FilterActions } from '../actions';
|
||||
import { PlaylistState } from '../state';
|
||||
|
||||
export const filterReducers = [
|
||||
on(
|
||||
FilterActions.setSelectedFilters,
|
||||
(state, { selectedFilters }): PlaylistState => {
|
||||
return {
|
||||
...state,
|
||||
playlists: {
|
||||
...state.playlists,
|
||||
selectedFilters,
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
@@ -3,7 +3,6 @@ import { initialState, PlaylistState } from '../state';
|
||||
import { channelReducers } from './channel.reducers';
|
||||
import { epgReducers } from './epg.reducers';
|
||||
import { favoritesReducers } from './favorites.reducers';
|
||||
import { filterReducers } from './filter.reducers';
|
||||
import { playlistReducers } from './playlist.reducers';
|
||||
|
||||
export const playlistReducer = createReducer(
|
||||
@@ -11,8 +10,7 @@ export const playlistReducer = createReducer(
|
||||
...epgReducers,
|
||||
...channelReducers,
|
||||
...playlistReducers,
|
||||
...favoritesReducers,
|
||||
...filterReducers
|
||||
...favoritesReducers
|
||||
);
|
||||
|
||||
// Selectors
|
||||
|
||||
@@ -63,11 +63,6 @@ export const selectAllPlaylistsMeta = createSelector(
|
||||
fromPlaylistMetaState.getAllPlaylistsMeta
|
||||
);
|
||||
|
||||
export const selectActiveTypeFilters = createSelector(
|
||||
selectPlaylistsMetaState,
|
||||
(state) => state.selectedFilters
|
||||
);
|
||||
|
||||
export const selectPlaylistEntity = (id: string) =>
|
||||
createSelector(selectPlaylistEntities, (entities) => entities[id]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/add-playlist-menu/playlist-type';
|
||||
export * from './lib/playlist-switcher/playlist-switcher.component';
|
||||
export * from './lib/playlist-switcher/playlist-switcher-open.service';
|
||||
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
|
||||
export * from './lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token';
|
||||
export * from './lib/recent-playlists/recent-playlists.component';
|
||||
@@ -8,3 +9,6 @@ export * from './lib/playlist-refresh-action.service';
|
||||
export * from './lib/xtream-refresh-flow.service';
|
||||
|
||||
export * from './lib/source-health/source-cleanup-dialog.component';
|
||||
export * from './lib/source-row/source-row.component';
|
||||
export * from './lib/source-row/source-tile.component';
|
||||
export * from './lib/source-removal/source-removal.service';
|
||||
@@ -312,8 +312,15 @@ describe('PlaylistRefreshActionService', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the browser URL refresh path when the refresh bridge is unavailable', () => {
|
||||
it('uses the browser URL refresh path when the refresh bridge is unavailable', async () => {
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
let answer!: () => void;
|
||||
dataService.sendIpcEvent.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
);
|
||||
const activity = TestBed.inject(SourceActivityService);
|
||||
const playlist = createPlaylistMeta({
|
||||
_id: 'playlist-url',
|
||||
title: 'URL playlist',
|
||||
@@ -333,6 +340,15 @@ describe('PlaylistRefreshActionService', () => {
|
||||
userAgent: 'IPTVnator-Test/1.0',
|
||||
});
|
||||
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
|
||||
// Busy, and held for removal and other refreshes, until it answers.
|
||||
expect(service.isSourceBusy('playlist-url')).toBe(true);
|
||||
expect(activity.isBusy('playlist-url')).toBe(true);
|
||||
|
||||
answer();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.isSourceBusy('playlist-url')).toBe(false);
|
||||
expect(activity.isBusy('playlist-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks a header M3U refresh by source until the operation settles', async () => {
|
||||
@@ -349,13 +365,27 @@ describe('PlaylistRefreshActionService', () => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
const activity = TestBed.inject(SourceActivityService);
|
||||
service.refresh(playlist);
|
||||
expect(service.isSourceBusy('busy')).toBe(true);
|
||||
expect(service.isSourceBusy('other')).toBe(false);
|
||||
// One refresh at a time: no other source is offered meanwhile.
|
||||
const other = createPlaylistMeta({
|
||||
_id: 'other',
|
||||
serverUrl: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
url: 'https://source.test/other',
|
||||
});
|
||||
expect(service.canRefresh(other)).toBe(false);
|
||||
// The Sources page and the removal guard see it too.
|
||||
expect(activity.isBusy('busy')).toBe(true);
|
||||
resolve({ _id: 'busy', playlist: { items: [] } } as Playlist);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.isSourceBusy('busy')).toBe(false);
|
||||
expect(activity.isBusy('busy')).toBe(false);
|
||||
expect(service.canRefresh(other)).toBe(true);
|
||||
});
|
||||
|
||||
it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => {
|
||||
|
||||
@@ -64,7 +64,13 @@ export class PlaylistRefreshActionService {
|
||||
readonly refreshPreparation = this.refreshPreparationState.asReadonly();
|
||||
|
||||
canRefresh(playlist: PlaylistMeta | null): boolean {
|
||||
if (!playlist || this.activity.isBusy(playlist._id)) {
|
||||
// The same guards as refresh(): one refresh runs at a time, so an
|
||||
// offer made meanwhile would be a silent no-op.
|
||||
if (
|
||||
!playlist ||
|
||||
this.isRefreshing() ||
|
||||
this.activity.isBusy(playlist._id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -94,14 +100,32 @@ export class PlaylistRefreshActionService {
|
||||
) {
|
||||
void this.refreshM3u(playlist);
|
||||
} else if (playlist.url) {
|
||||
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
|
||||
id: playlist._id,
|
||||
title: playlist.title,
|
||||
url: playlist.url,
|
||||
...(playlist.userAgent
|
||||
? { userAgent: playlist.userAgent }
|
||||
: {}),
|
||||
void this.refreshViaBackend(playlist, playlist.url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser build refreshes through the backend, which reports its own
|
||||
* success or failure; the source stays busy until it has answered.
|
||||
*/
|
||||
private async refreshViaBackend(
|
||||
item: PlaylistMeta,
|
||||
url: string
|
||||
): Promise<void> {
|
||||
this.m3uRefreshId.set(item._id);
|
||||
this.isRefreshing.set(true);
|
||||
const release = this.activity.begin([item._id]);
|
||||
try {
|
||||
await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
url,
|
||||
...(item.userAgent ? { userAgent: item.userAgent } : {}),
|
||||
});
|
||||
} finally {
|
||||
this.m3uRefreshId.set(null);
|
||||
this.isRefreshing.set(false);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +172,8 @@ export class PlaylistRefreshActionService {
|
||||
|
||||
this.m3uRefreshId.set(item._id);
|
||||
this.isRefreshing.set(true);
|
||||
// Seen by the Sources page and the removal guard as well.
|
||||
const release = this.activity.begin([item._id]);
|
||||
if (isActiveM3uRoute) {
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannelsLoading({ loading: true })
|
||||
@@ -214,6 +240,7 @@ export class PlaylistRefreshActionService {
|
||||
}
|
||||
this.m3uRefreshId.set(null);
|
||||
this.isRefreshing.set(false);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildSwitcherGroups,
|
||||
moveHighlight,
|
||||
readSwitcherType,
|
||||
SWITCHER_TYPE_STORAGE_KEY,
|
||||
writeSwitcherType,
|
||||
} from './playlist-switcher-list';
|
||||
|
||||
const source = (id: string, fields: Partial<PlaylistMeta> = {}) =>
|
||||
({
|
||||
_id: id,
|
||||
title: id,
|
||||
count: 0,
|
||||
importDate: '',
|
||||
autoRefresh: false,
|
||||
...fields,
|
||||
}) as PlaylistMeta;
|
||||
|
||||
const many = [
|
||||
source('a', { url: 'http://a.example/list.m3u' }),
|
||||
source('b', {
|
||||
serverUrl: 'http://b.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
}),
|
||||
source('c', { macAddress: 'm', portalUrl: 'http://c.example/c/' }),
|
||||
source('d'),
|
||||
source('e'),
|
||||
source('f'),
|
||||
];
|
||||
|
||||
describe('buildSwitcherGroups', () => {
|
||||
it('lists recent sources first, newest first, and all of them below', () => {
|
||||
const groups = buildSwitcherGroups(many, ['c', 'gone', 'a'], 'all', '');
|
||||
expect(groups.recent.map((o) => o.playlist._id)).toEqual(['c', 'a']);
|
||||
expect(groups.all).toHaveLength(6);
|
||||
expect(groups.recent[0].id).not.toBe(
|
||||
groups.all.find((o) => o.playlist._id === 'c')?.id
|
||||
);
|
||||
});
|
||||
|
||||
it('hides Recent while searching and for a short list', () => {
|
||||
expect(buildSwitcherGroups(many, ['a'], 'all', 'b').recent).toEqual([]);
|
||||
expect(
|
||||
buildSwitcherGroups(many.slice(0, 3), ['a'], 'all', '').recent
|
||||
).toEqual([]);
|
||||
// A long library filtered down to one type is a short list too.
|
||||
expect(buildSwitcherGroups(many, ['b'], 'xtream', '').recent).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by type and by name or host', () => {
|
||||
expect(
|
||||
buildSwitcherGroups(many, [], 'xtream', '').all.map(
|
||||
(o) => o.playlist._id
|
||||
)
|
||||
).toEqual(['b']);
|
||||
expect(
|
||||
buildSwitcherGroups(many, [], 'all', 'c.example').all.map(
|
||||
(o) => o.playlist._id
|
||||
)
|
||||
).toEqual(['c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveHighlight', () => {
|
||||
it('wraps at both ends and starts from either end', () => {
|
||||
expect(moveHighlight(-1, 1, 3)).toBe(0);
|
||||
expect(moveHighlight(-1, -1, 3)).toBe(2);
|
||||
expect(moveHighlight(2, 1, 3)).toBe(0);
|
||||
expect(moveHighlight(0, -1, 3)).toBe(2);
|
||||
expect(moveHighlight(1, 1, 0)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('switcher type memory', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('remembers the chosen type', () => {
|
||||
writeSwitcherType('stalker');
|
||||
expect(readSwitcherType()).toBe('stalker');
|
||||
});
|
||||
|
||||
it('migrates the old checkboxes to one type or All', () => {
|
||||
localStorage.setItem(
|
||||
'playlist-switcher:type-filters',
|
||||
JSON.stringify({ m3u: false, stalker: false, xtream: true })
|
||||
);
|
||||
localStorage.setItem('playlist-switcher:search-query', 'old query');
|
||||
expect(readSwitcherType()).toBe('xtream');
|
||||
expect(localStorage.getItem(SWITCHER_TYPE_STORAGE_KEY)).toBe('xtream');
|
||||
expect(
|
||||
localStorage.getItem('playlist-switcher:type-filters')
|
||||
).toBeNull();
|
||||
expect(
|
||||
localStorage.getItem('playlist-switcher:search-query')
|
||||
).toBeNull();
|
||||
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
'playlist-switcher:type-filters',
|
||||
JSON.stringify({ m3u: true, stalker: true, xtream: false })
|
||||
);
|
||||
expect(readSwitcherType()).toBe('all');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
matchesSourceQuery,
|
||||
matchesSourceType,
|
||||
SourcesTypeFilter,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const SWITCHER_TYPE_STORAGE_KEY = 'playlist-switcher:type:v2';
|
||||
/** The three checkboxes the segmented type filter replaced. */
|
||||
const LEGACY_TYPE_STORAGE_KEY = 'playlist-switcher:type-filters';
|
||||
/** The search is no longer kept between openings. */
|
||||
const LEGACY_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
|
||||
const TYPES: readonly SourcesTypeFilter[] = ['all', 'm3u', 'xtream', 'stalker'];
|
||||
/** Below this many sources the "Recent" group would repeat the whole list. */
|
||||
export const SWITCHER_RECENT_MIN_SOURCES = 6;
|
||||
|
||||
/**
|
||||
* The remembered type. The old checkbox set maps to the one type it left
|
||||
* on, or to "All" when it left several (the only states it could save).
|
||||
*/
|
||||
export function readSwitcherType(): SourcesTypeFilter {
|
||||
try {
|
||||
const stored = localStorage.getItem(SWITCHER_TYPE_STORAGE_KEY);
|
||||
if (TYPES.includes(stored as SourcesTypeFilter)) {
|
||||
return stored as SourcesTypeFilter;
|
||||
}
|
||||
const legacy = JSON.parse(
|
||||
localStorage.getItem(LEGACY_TYPE_STORAGE_KEY) ?? 'null'
|
||||
) as Record<string, boolean> | null;
|
||||
localStorage.removeItem(LEGACY_QUERY_STORAGE_KEY);
|
||||
if (legacy && typeof legacy === 'object') {
|
||||
const on = (['m3u', 'xtream', 'stalker'] as const).filter(
|
||||
(type) => legacy[type] !== false
|
||||
);
|
||||
localStorage.removeItem(LEGACY_TYPE_STORAGE_KEY);
|
||||
const type = on.length === 1 ? on[0] : 'all';
|
||||
writeSwitcherType(type);
|
||||
return type;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the default.
|
||||
}
|
||||
return 'all';
|
||||
}
|
||||
|
||||
export function writeSwitcherType(type: SourcesTypeFilter): void {
|
||||
try {
|
||||
localStorage.setItem(SWITCHER_TYPE_STORAGE_KEY, type);
|
||||
} catch {
|
||||
// The choice still applies while the app runs.
|
||||
}
|
||||
}
|
||||
|
||||
export interface SwitcherOption {
|
||||
/** Unique per group: a recent source is also listed under "All". */
|
||||
id: string;
|
||||
group: 'recent' | 'all';
|
||||
playlist: PlaylistMeta;
|
||||
}
|
||||
|
||||
export interface SwitcherGroups {
|
||||
recent: SwitcherOption[];
|
||||
all: SwitcherOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The switcher's two groups: "Recent" (newest first, only without a search
|
||||
* and when enough sources are shown to be worth it) and "All" in the given
|
||||
* order, both narrowed by type and search.
|
||||
*/
|
||||
export function buildSwitcherGroups(
|
||||
ordered: readonly PlaylistMeta[],
|
||||
recentIds: readonly string[],
|
||||
type: SourcesTypeFilter,
|
||||
query: string
|
||||
): SwitcherGroups {
|
||||
const visible = ordered.filter(
|
||||
(playlist) =>
|
||||
matchesSourceType(playlist, type) &&
|
||||
matchesSourceQuery(playlist, query)
|
||||
);
|
||||
const option =
|
||||
(group: SwitcherOption['group']) =>
|
||||
(playlist: PlaylistMeta): SwitcherOption => ({
|
||||
id: `playlist-switcher-${group}-${playlist._id}`,
|
||||
group,
|
||||
playlist,
|
||||
});
|
||||
// Counted after the type filter: a short filtered list would show the
|
||||
// same sources twice, and the keyboard would visit each twice.
|
||||
const showRecent =
|
||||
!query.trim() && visible.length >= SWITCHER_RECENT_MIN_SOURCES;
|
||||
const byId = new Map(visible.map((playlist) => [playlist._id, playlist]));
|
||||
const recent = showRecent
|
||||
? recentIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((playlist): playlist is PlaylistMeta => !!playlist)
|
||||
.map(option('recent'))
|
||||
: [];
|
||||
return { recent, all: visible.map(option('all')) };
|
||||
}
|
||||
|
||||
/** The next highlight for ↑/↓, wrapping at both ends. */
|
||||
export function moveHighlight(
|
||||
current: number,
|
||||
delta: 1 | -1,
|
||||
count: number
|
||||
): number {
|
||||
if (count === 0) {
|
||||
return -1;
|
||||
}
|
||||
if (current < 0) {
|
||||
return delta > 0 ? 0 : count - 1;
|
||||
}
|
||||
return (current + delta + count) % count;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Opens the toolbar playlist switcher from outside it: the ⌘P / Ctrl+P
|
||||
* shortcut. The switcher subscribes while it is on screen (it is hidden on
|
||||
* Settings), so a request with no switcher does nothing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistSwitcherOpenService {
|
||||
private readonly requests = new Subject<void>();
|
||||
readonly openRequests = this.requests.asObservable();
|
||||
private listeners = 0;
|
||||
|
||||
/** Whether a switcher is on screen to take the request. */
|
||||
get available(): boolean {
|
||||
return this.listeners > 0;
|
||||
}
|
||||
|
||||
request(): void {
|
||||
this.requests.next();
|
||||
}
|
||||
|
||||
/** Called by the switcher; the returned function unregisters it. */
|
||||
register(): () => void {
|
||||
this.listeners += 1;
|
||||
return () => {
|
||||
this.listeners -= 1;
|
||||
};
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
<div
|
||||
class="switcher"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="playlist-switcher-title"
|
||||
cdkTrapFocus
|
||||
>
|
||||
<header class="switcher__header">
|
||||
<h2 id="playlist-switcher-title" class="switcher__title">
|
||||
{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}
|
||||
</h2>
|
||||
<span class="switcher__count">{{ playlists().length }}</span>
|
||||
<span class="switcher__spacer"></span>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="switcher__action"
|
||||
data-test-id="playlist-switcher-add"
|
||||
(click)="addPlaylist()"
|
||||
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="switcher__action"
|
||||
(click)="openAllSources()"
|
||||
[matTooltip]="'WORKSPACE.SWITCHER.ALL_SOURCES' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SWITCHER.ALL_SOURCES' | translate"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<label class="switcher__search">
|
||||
<mat-icon aria-hidden="true">search</mat-icon>
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="true"
|
||||
aria-keyshortcuts="Shift+F10"
|
||||
[attr.aria-controls]="listId"
|
||||
[attr.aria-activedescendant]="activeOptionId()"
|
||||
[attr.aria-label]="'WORKSPACE.SWITCHER.SEARCH' | translate"
|
||||
[placeholder]="'WORKSPACE.SWITCHER.SEARCH' | translate"
|
||||
[value]="query()"
|
||||
(input)="setQuery(searchInput.value)"
|
||||
(keydown)="onSearchKeydown($event)"
|
||||
/>
|
||||
<kbd aria-hidden="true">↑↓ ↵</kbd>
|
||||
</label>
|
||||
|
||||
@if (showTypeFilter()) {
|
||||
<mat-button-toggle-group
|
||||
class="switcher__types"
|
||||
hideSingleSelectionIndicator
|
||||
[value]="activeType()"
|
||||
(change)="setType($event.value)"
|
||||
[attr.aria-label]="'WORKSPACE.SOURCES.TYPE' | translate"
|
||||
>
|
||||
<mat-button-toggle value="all">{{
|
||||
'WORKSPACE.SOURCES.ALL' | translate
|
||||
}}</mat-button-toggle>
|
||||
@for (kind of kinds; track kind) {
|
||||
@if (typeCounts()[kind] > 0) {
|
||||
<mat-button-toggle [value]="kind"
|
||||
>{{ kindLabels[kind] }}
|
||||
<span class="switcher__type-count">{{
|
||||
typeCounts()[kind]
|
||||
}}</span></mat-button-toggle
|
||||
>
|
||||
}
|
||||
}
|
||||
</mat-button-toggle-group>
|
||||
}
|
||||
|
||||
<div
|
||||
#list
|
||||
class="switcher__list"
|
||||
role="listbox"
|
||||
[id]="listId"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.MY_PLAYLISTS' | translate"
|
||||
>
|
||||
@if (groups().recent.length) {
|
||||
<div role="group" aria-labelledby="playlist-switcher-recent">
|
||||
<div id="playlist-switcher-recent" class="switcher__group">
|
||||
{{ 'WORKSPACE.SWITCHER.RECENT' | translate }}
|
||||
</div>
|
||||
@for (option of groups().recent; track option.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
row;
|
||||
context: { $implicit: option, index: $index }
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div role="group" aria-labelledby="playlist-switcher-all">
|
||||
@if (groups().recent.length) {
|
||||
<div id="playlist-switcher-all" class="switcher__group">
|
||||
{{ 'WORKSPACE.SOURCES.ALL' | translate }}
|
||||
</div>
|
||||
}
|
||||
@for (option of groups().all; track option.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
row;
|
||||
context: {
|
||||
$implicit: option,
|
||||
index: groups().recent.length + $index,
|
||||
}
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!options().length) {
|
||||
<p class="switcher__empty" role="status">
|
||||
{{ 'WORKSPACE.SWITCHER.NO_MATCHES' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<footer class="switcher__footer">
|
||||
<span class="switcher__hints" aria-hidden="true">
|
||||
<kbd>↑↓</kbd>{{ 'WORKSPACE.SWITCHER.HINT_SELECT' | translate }}
|
||||
<kbd>↵</kbd>{{ 'WORKSPACE.SWITCHER.HINT_OPEN' | translate }}
|
||||
<kbd>esc</kbd>{{ 'WORKSPACE.SWITCHER.HINT_CLOSE' | translate }}
|
||||
</span>
|
||||
<span class="switcher__spacer"></span>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="switcher__all"
|
||||
(click)="openAllSources()"
|
||||
>
|
||||
{{ 'WORKSPACE.SWITCHER.ALL_SOURCES' | translate }}
|
||||
<mat-icon iconPositionEnd>arrow_forward</mat-icon>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<ng-template #row let-option let-index="index">
|
||||
<app-source-row
|
||||
size="compact"
|
||||
role="option"
|
||||
[attr.id]="option.id"
|
||||
[attr.aria-selected]="index === highlight()"
|
||||
[attr.aria-current]="option.playlist._id === currentId() || null"
|
||||
[attr.aria-disabled]="isRefreshing(option.playlist) || null"
|
||||
[playlist]="option.playlist"
|
||||
[current]="option.playlist._id === currentId()"
|
||||
[highlighted]="index === highlight()"
|
||||
[canRefresh]="canRefresh(option.playlist)"
|
||||
[refreshing]="isRefreshing(option.playlist)"
|
||||
[removable]="!isBusy(option.playlist)"
|
||||
(activated)="select(option.playlist)"
|
||||
(refreshRequested)="refresh(option.playlist)"
|
||||
(playlistInfoRequested)="openInfo(option.playlist)"
|
||||
(accountInfoRequested)="openAccountInfo(option.playlist)"
|
||||
(removeRequested)="remove(option.playlist)"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
/>
|
||||
</ng-template>
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
@use '../../../../../../ui/styles/app-controls' as controls;
|
||||
|
||||
// 440px popover on the widget surface; only the list scrolls.
|
||||
:host {
|
||||
display: block;
|
||||
width: min(440px, calc(100vw - 16px));
|
||||
}
|
||||
|
||||
.switcher {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: var(--playlist-switcher-max-height, calc(100vh - 88px));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-line-strong);
|
||||
border-radius: 14px;
|
||||
background: var(--app-widget-bg);
|
||||
box-shadow: var(--app-popover-shadow);
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
// Only the list shrinks and scrolls. A column flex container shrinks its
|
||||
// items in proportion to their size, so with a long list (hundreds of
|
||||
// sources) the fixed parts lost nearly all their height too: the type
|
||||
// filter, which clips its overflow, collapsed to 2px and the search field
|
||||
// to its text line.
|
||||
.switcher__header,
|
||||
.switcher__search,
|
||||
.switcher__types,
|
||||
.switcher__empty,
|
||||
.switcher__footer {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.switcher__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
}
|
||||
|
||||
.switcher__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.switcher__count {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.switcher__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.switcher__action {
|
||||
@include controls.ghost-icon-button(32px, 18px);
|
||||
}
|
||||
|
||||
// The same search field as the header and the side panels: 36px, the
|
||||
// shared search tokens, focus on the field (token border and halo) rather
|
||||
// than as an outline on the input.
|
||||
.switcher__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
margin: 0 14px 10px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--app-search-border, var(--app-line-strong));
|
||||
border-radius: 10px;
|
||||
background: var(--app-search-bg, var(--app-content-bg));
|
||||
color: var(--app-muted-color);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--app-focus-ring);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--app-focus-ring) 12%, transparent);
|
||||
}
|
||||
|
||||
.mat-icon {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: var(--app-heading-color);
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--app-font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.switcher__types {
|
||||
@include controls.segmented-control(30px);
|
||||
align-self: flex-start;
|
||||
margin: 0 14px 8px;
|
||||
}
|
||||
|
||||
.switcher__type-count {
|
||||
margin-inline-start: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.switcher__list {
|
||||
// Positioned, so options' offsetTop is relative to the list it scrolls.
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 8px;
|
||||
border-top: 1px solid var(--app-widget-header-border);
|
||||
scrollbar-width: thin;
|
||||
|
||||
app-source-row {
|
||||
--source-row-base: var(--app-widget-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.switcher__group {
|
||||
padding: 10px 8px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.switcher__empty {
|
||||
margin: 0;
|
||||
padding: 20px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.switcher__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--app-widget-header-border);
|
||||
background: color-mix(in srgb, var(--app-shell-bg) 40%, transparent);
|
||||
}
|
||||
|
||||
.switcher__hints {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 6px;
|
||||
font-family: var(--app-font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--app-muted-color);
|
||||
|
||||
kbd {
|
||||
margin-inline-start: 4px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--app-line-strong);
|
||||
border-radius: 3px;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
kbd:first-child {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.switcher__all {
|
||||
@include controls.ghost-button(sm);
|
||||
}
|
||||
|
||||
// Phones: the hints need a keyboard; the link stays.
|
||||
@media (max-width: 480px) {
|
||||
.switcher__hints {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
PlaylistContextFacade,
|
||||
PlaylistRecentHistoryService,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
SortBy,
|
||||
SortOptions,
|
||||
SortOrder,
|
||||
SortService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
|
||||
import { SourceRemovalService } from '../source-removal/source-removal.service';
|
||||
import { XtreamRefreshFlowService } from '../xtream-refresh-flow.service';
|
||||
import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component';
|
||||
|
||||
const source = (id: string, fields: Partial<PlaylistMeta> = {}) =>
|
||||
({
|
||||
_id: id,
|
||||
title: id,
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
...fields,
|
||||
}) as PlaylistMeta;
|
||||
|
||||
const ALPHA = source('alpha', { url: 'https://alpha.example/list.m3u' });
|
||||
const BRAVO = source('bravo', {
|
||||
serverUrl: 'http://bravo.example',
|
||||
username: 'u',
|
||||
password: 'secret-pass',
|
||||
});
|
||||
const CHARLIE = source('charlie', {
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://charlie.example/c/',
|
||||
});
|
||||
|
||||
describe('PlaylistSwitcherPanelComponent', () => {
|
||||
let fixture: ComponentFixture<PlaylistSwitcherPanelComponent>;
|
||||
let component: PlaylistSwitcherPanelComponent;
|
||||
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
|
||||
let deleting: ReturnType<typeof signal<ReadonlySet<string>>>;
|
||||
let selectPlaylist: jest.Mock;
|
||||
let remove: jest.Mock;
|
||||
let navigate: jest.Mock;
|
||||
let xtreamRefreshing: ReturnType<typeof signal<ReadonlySet<string>>>;
|
||||
|
||||
const input = () =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.switcher__search input'
|
||||
) as HTMLInputElement;
|
||||
const press = (key: string, init: KeyboardEventInit = {}) => {
|
||||
input().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, ...init })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
};
|
||||
const type = (value: string) => {
|
||||
input().value = value;
|
||||
input().dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
const optionIds = () =>
|
||||
Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll(
|
||||
'[role="option"]'
|
||||
)
|
||||
).map((option) => option.id);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
playlists = signal([ALPHA, BRAVO, CHARLIE]);
|
||||
pending = signal<ReadonlySet<string>>(new Set());
|
||||
deleting = signal<ReadonlySet<string>>(new Set());
|
||||
selectPlaylist = jest.fn();
|
||||
remove = jest.fn();
|
||||
navigate = jest.fn();
|
||||
xtreamRefreshing = signal<ReadonlySet<string>>(new Set());
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
PlaylistSwitcherPanelComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
playlists,
|
||||
resolvedPlaylistId: signal('bravo'),
|
||||
selectPlaylist,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistRecentHistoryService,
|
||||
useValue: { ids: signal(['charlie']) },
|
||||
},
|
||||
{
|
||||
provide: SortService,
|
||||
useValue: {
|
||||
getSortOptions: () =>
|
||||
new BehaviorSubject<SortOptions>({
|
||||
by: SortBy.NAME,
|
||||
order: SortOrder.ASC,
|
||||
}),
|
||||
sortPlaylists: new SortService().sortPlaylists,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SourceStatusService,
|
||||
useValue: {
|
||||
statusOf: () => null,
|
||||
tracks: () => false,
|
||||
ensureChecked: jest.fn(),
|
||||
recheck: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SourceRemovalService,
|
||||
useValue: {
|
||||
pendingIds: pending,
|
||||
remove,
|
||||
progress: {
|
||||
isDeleting: (id: string) => deleting().has(id),
|
||||
},
|
||||
canTakeOver: (id: string) =>
|
||||
!deleting().has(id) &&
|
||||
!TestBed.inject(SourceActivityService).isBusy(id),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistRefreshActionService,
|
||||
useValue: {
|
||||
canRefresh: () => true,
|
||||
refresh: jest.fn(),
|
||||
isSourceBusy: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XtreamRefreshFlowService,
|
||||
useValue: {
|
||||
isRunning: (id: string) => xtreamRefreshing().has(id),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
{ provide: Router, useValue: { navigate } },
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(PlaylistSwitcherPanelComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('lists every source in the Sources order as listbox options', () => {
|
||||
expect(optionIds()).toEqual([
|
||||
'playlist-switcher-all-alpha',
|
||||
'playlist-switcher-all-bravo',
|
||||
'playlist-switcher-all-charlie',
|
||||
]);
|
||||
const current = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'#playlist-switcher-all-bravo'
|
||||
);
|
||||
expect(current?.getAttribute('aria-current')).toBe('true');
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).textContent
|
||||
).not.toContain('secret-pass');
|
||||
});
|
||||
|
||||
it('shows Recent above All once the list is long enough', () => {
|
||||
playlists.set([
|
||||
ALPHA,
|
||||
BRAVO,
|
||||
CHARLIE,
|
||||
source('d'),
|
||||
source('e'),
|
||||
source('f'),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
expect(optionIds()[0]).toBe('playlist-switcher-recent-charlie');
|
||||
expect(optionIds()).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('moves the highlight with the arrow keys, wrapping, and opens it on Enter', () => {
|
||||
press('ArrowDown');
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(
|
||||
'playlist-switcher-all-alpha'
|
||||
);
|
||||
press('ArrowUp');
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(
|
||||
'playlist-switcher-all-charlie'
|
||||
);
|
||||
press('ArrowDown');
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(
|
||||
'playlist-switcher-all-alpha'
|
||||
);
|
||||
|
||||
const closed = jest.fn();
|
||||
component.closeRequested.subscribe(closed);
|
||||
press('Enter');
|
||||
expect(selectPlaylist).toHaveBeenCalledWith(ALPHA);
|
||||
expect(closed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls a new search back to its first match at the top', () => {
|
||||
const list = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.switcher__list'
|
||||
) as HTMLElement;
|
||||
list.scrollTop = 120;
|
||||
type('a');
|
||||
expect(list.scrollTop).toBe(0);
|
||||
|
||||
list.scrollTop = 120;
|
||||
component.setType('xtream');
|
||||
expect(list.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it('moves through options whose IDs a selector would misread', () => {
|
||||
const restored = { ...ALPHA, _id: 'odd:id.one', title: 'Aardvark' };
|
||||
playlists.set([restored, BRAVO]);
|
||||
fixture.detectChanges();
|
||||
expect(() => press('ArrowDown')).not.toThrow();
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(
|
||||
'playlist-switcher-all-odd:id.one'
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by name or host as you type and highlights the first match', () => {
|
||||
type('charlie.example');
|
||||
expect(optionIds()).toEqual(['playlist-switcher-all-charlie']);
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(
|
||||
'playlist-switcher-all-charlie'
|
||||
);
|
||||
|
||||
type('nothing like it');
|
||||
expect(optionIds()).toEqual([]);
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.switcher__empty'
|
||||
)?.textContent
|
||||
).toContain('WORKSPACE.SWITCHER.NO_MATCHES');
|
||||
});
|
||||
|
||||
it('falls back to All when the remembered type has no sources left', () => {
|
||||
component.setType('xtream');
|
||||
fixture.detectChanges();
|
||||
expect(optionIds()).toEqual(['playlist-switcher-all-bravo']);
|
||||
|
||||
// The last Xtream source is removed; two other types remain.
|
||||
playlists.set([ALPHA, CHARLIE]);
|
||||
fixture.detectChanges();
|
||||
expect(component.activeType()).toBe('all');
|
||||
expect(optionIds()).toEqual([
|
||||
'playlist-switcher-all-alpha',
|
||||
'playlist-switcher-all-charlie',
|
||||
]);
|
||||
// The choice is kept for when an Xtream source returns.
|
||||
expect(localStorage.getItem('playlist-switcher:type:v2')).toBe(
|
||||
'xtream'
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by type with one segment and remembers it', () => {
|
||||
component.setType('xtream');
|
||||
fixture.detectChanges();
|
||||
expect(optionIds()).toEqual(['playlist-switcher-all-bravo']);
|
||||
expect(localStorage.getItem('playlist-switcher:type:v2')).toBe(
|
||||
'xtream'
|
||||
);
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const closed = jest.fn();
|
||||
component.closeRequested.subscribe(closed);
|
||||
press('Escape');
|
||||
expect(closed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes before a delete, so Undo can take the focus and give it back to the trigger', () => {
|
||||
const trigger = document.createElement('button');
|
||||
document.body.appendChild(trigger);
|
||||
// The host closes the popover and focuses its trigger.
|
||||
component.closeRequested.subscribe(() => trigger.focus());
|
||||
|
||||
component.remove(BRAVO);
|
||||
expect(remove).toHaveBeenCalledWith(BRAVO, CHARLIE, {
|
||||
returnFocusTo: trigger,
|
||||
});
|
||||
trigger.remove();
|
||||
});
|
||||
|
||||
it('offers no Remove while a refresh clears and refills the source', async () => {
|
||||
// Held by a refresh from any entry point, the Sources page included.
|
||||
const release = TestBed.inject(SourceActivityService).begin(['bravo']);
|
||||
fixture.detectChanges();
|
||||
const menuRemove = async (id: string) => {
|
||||
(fixture.nativeElement as HTMLElement)
|
||||
.querySelector<HTMLElement>(
|
||||
`#playlist-switcher-all-${id} .source-row__more`
|
||||
)
|
||||
?.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const items = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.cdk-overlay-container [mat-menu-item]'
|
||||
)
|
||||
);
|
||||
const item = items[items.length - 1];
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||
);
|
||||
return item;
|
||||
};
|
||||
expect((await menuRemove('bravo')).disabled).toBe(true);
|
||||
expect((await menuRemove('alpha')).disabled).toBe(false);
|
||||
// Still a source you can switch to.
|
||||
(fixture.nativeElement as HTMLElement)
|
||||
.querySelector<HTMLElement>(
|
||||
'#playlist-switcher-all-bravo .source-row'
|
||||
)
|
||||
?.click();
|
||||
expect(selectPlaylist).toHaveBeenCalled();
|
||||
release();
|
||||
});
|
||||
|
||||
it('does not open a source while its refresh clears the catalog', () => {
|
||||
xtreamRefreshing.set(new Set(['alpha']));
|
||||
fixture.detectChanges();
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const alpha = element.querySelector('#playlist-switcher-all-alpha');
|
||||
expect(alpha?.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
alpha?.querySelector<HTMLElement>('.source-row')?.click();
|
||||
// Enter on the highlighted option goes through the same method.
|
||||
component.select(ALPHA);
|
||||
expect(selectPlaylist).not.toHaveBeenCalled();
|
||||
|
||||
xtreamRefreshing.set(new Set());
|
||||
fixture.detectChanges();
|
||||
component.select(ALPHA);
|
||||
expect(selectPlaylist).toHaveBeenCalledWith(ALPHA);
|
||||
});
|
||||
|
||||
it('hands over past a source a refresh holds when removing the current one', () => {
|
||||
// Bravo is current; Charlie would follow it but is refreshing.
|
||||
const release = TestBed.inject(SourceActivityService).begin([
|
||||
'charlie',
|
||||
]);
|
||||
component.remove(BRAVO);
|
||||
expect(remove).toHaveBeenCalledWith(BRAVO, ALPHA, expect.anything());
|
||||
release();
|
||||
});
|
||||
|
||||
it('leaves out a source whose delete is pending or running', () => {
|
||||
pending.set(new Set(['bravo']));
|
||||
fixture.detectChanges();
|
||||
expect(optionIds()).not.toContain('playlist-switcher-all-bravo');
|
||||
|
||||
pending.set(new Set());
|
||||
deleting.set(new Set(['alpha']));
|
||||
fixture.detectChanges();
|
||||
expect(optionIds()).toEqual([
|
||||
'playlist-switcher-all-bravo',
|
||||
'playlist-switcher-all-charlie',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the highlight on its option, or in range, when the list changes', () => {
|
||||
press('ArrowUp');
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(
|
||||
'playlist-switcher-all-charlie'
|
||||
);
|
||||
// An earlier option leaves: the highlight follows charlie.
|
||||
playlists.set([BRAVO, CHARLIE]);
|
||||
fixture.detectChanges();
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(
|
||||
'playlist-switcher-all-charlie'
|
||||
);
|
||||
// The highlighted last option leaves: Enter opens the new last one.
|
||||
playlists.set([BRAVO]);
|
||||
fixture.detectChanges();
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(
|
||||
'playlist-switcher-all-bravo'
|
||||
);
|
||||
press('Enter');
|
||||
expect(selectPlaylist).toHaveBeenCalledWith(BRAVO);
|
||||
});
|
||||
|
||||
it('goes to the Sources page from the footer', () => {
|
||||
(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.switcher__all'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
expect(navigate).toHaveBeenCalledWith(['/workspace', 'sources']);
|
||||
});
|
||||
});
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
import { DOCUMENT, NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
SourceStatusService,
|
||||
sourceStatusBucket,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
countSourcesByType,
|
||||
orderFailingFirst,
|
||||
PlaylistContextFacade,
|
||||
PlaylistRecentHistoryService,
|
||||
replacementAfterRemoval,
|
||||
SourceBucketOf,
|
||||
sourceSortName,
|
||||
SourcesTypeFilter,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
SortBy,
|
||||
SortService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistMeta, PlaylistSourceKind } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
|
||||
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
|
||||
import { SourceRemovalService } from '../source-removal/source-removal.service';
|
||||
import { SourceRowComponent } from '../source-row/source-row.component';
|
||||
import { XtreamRefreshFlowService } from '../xtream-refresh-flow.service';
|
||||
import {
|
||||
buildSwitcherGroups,
|
||||
moveHighlight,
|
||||
readSwitcherType,
|
||||
SwitcherOption,
|
||||
writeSwitcherType,
|
||||
} from './playlist-switcher-list';
|
||||
|
||||
const KINDS: readonly PlaylistSourceKind[] = ['m3u', 'xtream', 'stalker'];
|
||||
|
||||
/**
|
||||
* The switcher popover: search, a type segment, "Recent" and "All" groups
|
||||
* of compact source rows, and a footer. Focus stays in the search field;
|
||||
* ↑/↓ move the highlight (`aria-activedescendant`), Enter opens it,
|
||||
* Shift+F10 opens its menu and Escape closes the popover.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher-panel',
|
||||
imports: [
|
||||
A11yModule,
|
||||
MatButton,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
NgTemplateOutlet,
|
||||
SourceRowComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './playlist-switcher-panel.component.html',
|
||||
styleUrl: './playlist-switcher-panel.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PlaylistSwitcherPanelComponent {
|
||||
private readonly activity = inject(SourceActivityService);
|
||||
private readonly context = inject(PlaylistContextFacade);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly recentHistory = inject(PlaylistRecentHistoryService);
|
||||
private readonly refreshAction = inject(PlaylistRefreshActionService);
|
||||
private readonly removal = inject(SourceRemovalService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly sortService = inject(SortService);
|
||||
private readonly statusService = inject(SourceStatusService);
|
||||
private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService);
|
||||
private readonly list = viewChild.required<ElementRef<HTMLElement>>('list');
|
||||
private readonly searchInput =
|
||||
viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
|
||||
private readonly rows = viewChildren(SourceRowComponent);
|
||||
private readonly rowElements = viewChildren(SourceRowComponent, {
|
||||
read: ElementRef,
|
||||
});
|
||||
|
||||
readonly closeRequested = output<void>();
|
||||
private closing = false;
|
||||
readonly playlistSelected = output<string>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly accountInfoRequested = output<PlaylistMeta>();
|
||||
|
||||
readonly listId = 'playlist-switcher-listbox';
|
||||
readonly kinds = KINDS;
|
||||
readonly kindLabels = { m3u: 'M3U', xtream: 'Xtream', stalker: 'Stalker' };
|
||||
readonly currentId = this.context.resolvedPlaylistId;
|
||||
readonly query = signal('');
|
||||
readonly type = signal<SourcesTypeFilter>(readSwitcherType());
|
||||
|
||||
private readonly sortOptions = toSignal(this.sortService.getSortOptions(), {
|
||||
requireSync: true,
|
||||
});
|
||||
private readonly bucketOf: SourceBucketOf = (playlist) => {
|
||||
const status = this.statusService.statusOf(playlist);
|
||||
return status ? sourceStatusBucket(status.reachability) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every source except one whose delete is pending or running: a running
|
||||
* delete shows its progress and Cancel on the Sources page, and its
|
||||
* source cannot be opened or removed again from here.
|
||||
*/
|
||||
readonly playlists = computed(() => {
|
||||
const pending = this.removal.pendingIds();
|
||||
return this.context
|
||||
.playlists()
|
||||
.filter(
|
||||
(playlist) =>
|
||||
!pending.has(playlist._id) &&
|
||||
!this.removal.progress.isDeleting(playlist._id)
|
||||
);
|
||||
});
|
||||
readonly typeCounts = computed(() =>
|
||||
countSourcesByType(this.playlists(), 'all', this.bucketOf)
|
||||
);
|
||||
readonly showTypeFilter = computed(
|
||||
() => KINDS.filter((kind) => this.typeCounts()[kind] > 0).length > 1
|
||||
);
|
||||
/**
|
||||
* The type the list filters by: the remembered one while it has sources
|
||||
* and its segment shows, else All. Removing the last source of the
|
||||
* remembered type would otherwise leave an empty list with no segment
|
||||
* selected. The remembered choice itself is kept for when it returns.
|
||||
*/
|
||||
readonly activeType = computed<SourcesTypeFilter>(() => {
|
||||
const type = this.type();
|
||||
return this.showTypeFilter() &&
|
||||
(type === 'all' || this.typeCounts()[type] > 0)
|
||||
? type
|
||||
: 'all';
|
||||
});
|
||||
/** The Sources page order, so both lists agree. */
|
||||
private readonly ordered = computed(() => {
|
||||
const options = this.sortOptions();
|
||||
const sorted = this.sortService.sortPlaylists(
|
||||
this.playlists(),
|
||||
options,
|
||||
sourceSortName
|
||||
);
|
||||
return options.failingFirst && options.by !== SortBy.CUSTOM
|
||||
? orderFailingFirst(sorted, this.bucketOf)
|
||||
: sorted;
|
||||
});
|
||||
readonly groups = computed(() =>
|
||||
buildSwitcherGroups(
|
||||
this.ordered(),
|
||||
this.recentHistory.ids(),
|
||||
this.activeType(),
|
||||
this.query()
|
||||
)
|
||||
);
|
||||
readonly options = computed<SwitcherOption[]>(() => [
|
||||
...this.groups().recent,
|
||||
...this.groups().all,
|
||||
]);
|
||||
/**
|
||||
* A search highlights its first match, so Enter opens it. When the list
|
||||
* changes under the same search (a source added or removed), the
|
||||
* highlight stays on its option, or on the same place, clamped.
|
||||
*/
|
||||
readonly highlight = linkedSignal<
|
||||
{
|
||||
query: string;
|
||||
type: SourcesTypeFilter;
|
||||
ids: readonly string[];
|
||||
},
|
||||
number
|
||||
>({
|
||||
source: () => ({
|
||||
query: this.query(),
|
||||
type: this.activeType(),
|
||||
ids: this.options().map((option) => option.id),
|
||||
}),
|
||||
computation: (source, previous) => {
|
||||
const sameSearch =
|
||||
previous &&
|
||||
previous.source.query === source.query &&
|
||||
previous.source.type === source.type;
|
||||
if (!sameSearch) {
|
||||
return source.query.trim() ? 0 : -1;
|
||||
}
|
||||
const id = previous.source.ids[previous.value];
|
||||
const kept = id === undefined ? -1 : source.ids.indexOf(id);
|
||||
return kept !== -1
|
||||
? kept
|
||||
: Math.min(previous.value, source.ids.length - 1);
|
||||
},
|
||||
});
|
||||
readonly activeOptionId = computed(
|
||||
() => this.options()[this.highlight()]?.id ?? null
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Typing goes straight to the search. Not the focus trap's
|
||||
// auto-capture: on close it would move focus back to wherever it was
|
||||
// before ⌘P, and closing returns it to the trigger instead.
|
||||
afterNextRender(() => this.searchInput().nativeElement.focus());
|
||||
}
|
||||
|
||||
setQuery(value: string): void {
|
||||
this.query.set(value);
|
||||
this.scrollToTop();
|
||||
}
|
||||
|
||||
setType(type: SourcesTypeFilter): void {
|
||||
this.type.set(type);
|
||||
writeSwitcherType(type);
|
||||
this.scrollToTop();
|
||||
}
|
||||
|
||||
/**
|
||||
* A new search or type highlights the first match, at the top of the
|
||||
* list: a list scrolled down for the previous one would hide it.
|
||||
*/
|
||||
private scrollToTop(): void {
|
||||
this.list().nativeElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
onSearchKeydown(event: KeyboardEvent): void {
|
||||
const count = this.options().length;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
this.highlight.set(
|
||||
moveHighlight(
|
||||
this.highlight(),
|
||||
event.key === 'ArrowDown' ? 1 : -1,
|
||||
count
|
||||
)
|
||||
);
|
||||
this.scrollHighlightIntoView();
|
||||
break;
|
||||
case 'Enter': {
|
||||
const option = this.options()[this.highlight()];
|
||||
if (option) {
|
||||
event.preventDefault();
|
||||
this.select(option.playlist);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
break;
|
||||
case 'ContextMenu':
|
||||
case 'F10':
|
||||
if (event.key === 'ContextMenu' || event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.openHighlightedMenu();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
select(playlist: PlaylistMeta): void {
|
||||
// Pointer and Enter alike: opening it would race the refresh.
|
||||
if (this.isRefreshing(playlist)) {
|
||||
return;
|
||||
}
|
||||
this.context.selectPlaylist(playlist);
|
||||
this.playlistSelected.emit(playlist._id);
|
||||
this.close();
|
||||
}
|
||||
|
||||
canRefresh(playlist: PlaylistMeta): boolean {
|
||||
return this.refreshAction.canRefresh(playlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refresh is clearing and refilling the source: it cannot be opened
|
||||
* until the refresh has parked the user data its import restores.
|
||||
*/
|
||||
isRefreshing(playlist: PlaylistMeta): boolean {
|
||||
return (
|
||||
this.refreshAction.isSourceBusy(playlist._id) ||
|
||||
this.xtreamRefreshFlow.isRunning(playlist._id)
|
||||
);
|
||||
}
|
||||
|
||||
/** A source cannot be removed while a refresh clears and refills it. */
|
||||
isBusy(playlist: PlaylistMeta): boolean {
|
||||
return this.activity.isBusy(playlist._id);
|
||||
}
|
||||
|
||||
refresh(playlist: PlaylistMeta): void {
|
||||
this.close();
|
||||
this.refreshAction.refresh(playlist);
|
||||
}
|
||||
|
||||
openInfo(playlist: PlaylistMeta): void {
|
||||
this.close();
|
||||
this.dialog.open(PlaylistInfoComponent, { data: playlist });
|
||||
}
|
||||
|
||||
openAccountInfo(playlist: PlaylistMeta): void {
|
||||
this.close();
|
||||
this.accountInfoRequested.emit(playlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes with Undo; the active source hands over to its neighbour. The
|
||||
* popover closes first: its focus trap would keep the keyboard away
|
||||
* from Undo, which then takes the focus and returns it to the trigger.
|
||||
*/
|
||||
remove(playlist: PlaylistMeta): void {
|
||||
const next = replacementAfterRemoval(
|
||||
this.groups().all.map((option) => option.playlist),
|
||||
playlist._id,
|
||||
(row) => !this.removal.canTakeOver(row._id)
|
||||
);
|
||||
this.close();
|
||||
// Closing put the focus back on the trigger.
|
||||
const returnFocusTo = this.document.activeElement;
|
||||
this.removal.remove(playlist, next, {
|
||||
returnFocusTo:
|
||||
returnFocusTo instanceof HTMLElement &&
|
||||
returnFocusTo !== this.document.body
|
||||
? returnFocusTo
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** A row menu gives the focus back to the search, unless closing. */
|
||||
onMenuClosed(): void {
|
||||
if (!this.closing) {
|
||||
this.searchInput().nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every action that leaves the popover closes it through here, so a row
|
||||
* menu closing after its action does not pull the focus back into it.
|
||||
*/
|
||||
private close(): void {
|
||||
this.closing = true;
|
||||
this.closeRequested.emit();
|
||||
}
|
||||
|
||||
addPlaylist(): void {
|
||||
this.close();
|
||||
this.addPlaylistRequested.emit();
|
||||
}
|
||||
|
||||
openAllSources(): void {
|
||||
this.close();
|
||||
void this.router.navigate(['/workspace', 'sources']);
|
||||
}
|
||||
|
||||
/** Shift+F10 / the menu key: the highlighted row's ⋯ menu. */
|
||||
private openHighlightedMenu(): void {
|
||||
const id = this.activeOptionId();
|
||||
const index = this.rowElements().findIndex(
|
||||
(element) => (element.nativeElement as HTMLElement).id === id
|
||||
);
|
||||
this.rows()[index]?.openMenu();
|
||||
}
|
||||
|
||||
/** Keeps the highlighted option in the list's view, not the page's. */
|
||||
private scrollHighlightIntoView(): void {
|
||||
const id = this.activeOptionId();
|
||||
const list = this.list().nativeElement;
|
||||
// By exact ID, not a selector: a restored playlist ID may hold "."
|
||||
// or ":", which a selector would misread.
|
||||
const option = id
|
||||
? this.rowElements()
|
||||
.map((row) => row.nativeElement as HTMLElement)
|
||||
.find((element) => element.id === id)
|
||||
: undefined;
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
const top = option.offsetTop;
|
||||
const bottom = top + option.offsetHeight;
|
||||
if (top < list.scrollTop) {
|
||||
list.scrollTop = top - 8;
|
||||
} else if (bottom > list.scrollTop + list.clientHeight) {
|
||||
list.scrollTop = bottom - list.clientHeight + 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-288
@@ -1,293 +1,55 @@
|
||||
<div
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="playlist-switcher-trigger"
|
||||
[class.is-open]="isMenuOpen()"
|
||||
matRipple
|
||||
[matRippleDisabled]="false"
|
||||
[matMenuTriggerFor]="playlistMenu"
|
||||
#menuTrigger="matMenuTrigger"
|
||||
#triggerElement
|
||||
(menuOpened)="onMenuOpened()"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
data-test-id="playlist-switcher-trigger"
|
||||
cdkOverlayOrigin
|
||||
#origin="cdkOverlayOrigin"
|
||||
aria-haspopup="dialog"
|
||||
aria-keyshortcuts="Meta+P Control+P"
|
||||
[attr.aria-expanded]="isOpen()"
|
||||
[class.is-open]="isOpen()"
|
||||
(click)="toggle()"
|
||||
>
|
||||
<div class="trigger-icon-container">
|
||||
<mat-icon class="trigger-playlist-icon">playlist_play</mat-icon>
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<div class="name">{{ displayTitle() }}</div>
|
||||
<div class="channels-count">{{ subtitle() }}</div>
|
||||
</div>
|
||||
@if (canRefreshActivePlaylist()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="trigger-refresh-button"
|
||||
[disabled]="isRefreshingActivePlaylist()"
|
||||
(click)="onRefreshClick($event)"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
[matTooltip]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
|
||||
>
|
||||
<mat-icon
|
||||
class="trigger-refresh-icon"
|
||||
[class.is-spinning]="isRefreshingActivePlaylist()"
|
||||
>sync</mat-icon
|
||||
>
|
||||
</button>
|
||||
@if (activePlaylist(); as active) {
|
||||
<app-source-tile
|
||||
size="sm"
|
||||
[playlist]="active"
|
||||
[status]="activeStatus()"
|
||||
/>
|
||||
} @else {
|
||||
<span class="trigger-placeholder-tile" aria-hidden="true">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
</span>
|
||||
}
|
||||
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()">
|
||||
expand_more
|
||||
</mat-icon>
|
||||
</div>
|
||||
<span class="playlist-info">
|
||||
<span class="name">{{ displayTitle() }}</span>
|
||||
<span class="channels-count">{{ triggerSubtitle() }}</span>
|
||||
</span>
|
||||
<mat-icon class="dropdown-icon" [class.rotated]="isOpen()"
|
||||
>expand_more</mat-icon
|
||||
>
|
||||
</button>
|
||||
|
||||
<mat-menu
|
||||
#playlistMenu="matMenu"
|
||||
panelClass="playlist-switcher-menu"
|
||||
hasBackdrop
|
||||
backdropClass="playlist-switcher-menu-backdrop"
|
||||
overlapTrigger="false"
|
||||
xPosition="after"
|
||||
yPosition="below"
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="origin"
|
||||
[cdkConnectedOverlayOpen]="isOpen()"
|
||||
[cdkConnectedOverlayPositions]="positions"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
[cdkConnectedOverlayPush]="true"
|
||||
[cdkConnectedOverlayViewportMargin]="8"
|
||||
cdkConnectedOverlayBackdropClass="playlist-switcher-backdrop"
|
||||
cdkConnectedOverlayPanelClass="playlist-switcher-pane"
|
||||
(backdropClick)="close()"
|
||||
(detach)="close()"
|
||||
(overlayKeydown)="onOverlayKeydown($event)"
|
||||
>
|
||||
<div class="menu-header">
|
||||
<mat-icon class="menu-header-icon">playlist_play</mat-icon>
|
||||
<span class="menu-header-title">{{
|
||||
'HOME.PLAYLISTS.MY_PLAYLISTS' | translate
|
||||
}}</span>
|
||||
@if (hasSearchToggle()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="search-toggle-btn"
|
||||
[class.is-active]="searchExpanded()"
|
||||
(click)="toggleSearchField($event)"
|
||||
[attr.aria-label]="
|
||||
'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</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
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
[attr.aria-label]="'CLEAR_SEARCH' | translate"
|
||||
(click)="setSearchQuery('')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showTypeFilters()) {
|
||||
<div class="type-filters" (click)="$event.stopPropagation()">
|
||||
@if (hasType('m3u')) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-filter-chip"
|
||||
[class.selected]="isTypeFilterSelected('m3u')"
|
||||
(click)="togglePlaylistTypeFilter('m3u', $event)"
|
||||
>
|
||||
M3U
|
||||
</button>
|
||||
}
|
||||
@if (hasType('stalker')) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-filter-chip"
|
||||
[class.selected]="isTypeFilterSelected('stalker')"
|
||||
(click)="togglePlaylistTypeFilter('stalker', $event)"
|
||||
>
|
||||
Stalker
|
||||
</button>
|
||||
}
|
||||
@if (hasType('xtream')) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-filter-chip"
|
||||
[class.selected]="isTypeFilterSelected('xtream')"
|
||||
(click)="togglePlaylistTypeFilter('xtream', $event)"
|
||||
>
|
||||
Xtream
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="menu-content">
|
||||
@for (playlist of filteredPlaylists(); track playlist._id) {
|
||||
<div
|
||||
class="playlist-item"
|
||||
[class.selected]="isSelected(playlist)"
|
||||
(click)="selectPlaylist(playlist)"
|
||||
>
|
||||
<!-- @if (isSelected(playlist)) {
|
||||
<mat-icon class="selected-indicator">check</mat-icon>
|
||||
} @else {
|
||||
<div class="selected-indicator-placeholder"></div>
|
||||
} -->
|
||||
|
||||
<div class="playlist-icon-container">
|
||||
<mat-icon class="playlist-type-icon">
|
||||
{{ getPlaylistIcon(playlist) }}
|
||||
</mat-icon>
|
||||
@if (runtime.supportsSourceHealth) {
|
||||
<app-source-health-indicator
|
||||
[playlist]="playlist"
|
||||
[enabled]="isMenuOpen()"
|
||||
/>
|
||||
} @else if (playlist.serverUrl) {
|
||||
<span
|
||||
class="status-dot"
|
||||
[class]="getStatusClass(playlist._id)"
|
||||
></span>
|
||||
}
|
||||
@if (
|
||||
playlist.autoRefresh &&
|
||||
!playlist.serverUrl &&
|
||||
!playlist.macAddress
|
||||
) {
|
||||
<mat-icon class="auto-refresh-indicator"
|
||||
>autorenew</mat-icon
|
||||
>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="playlist-details">
|
||||
<span class="playlist-title">
|
||||
{{ playlist.title || playlist.filename }}
|
||||
</span>
|
||||
<span class="playlist-meta">
|
||||
{{ getPlaylistMetaLabel(playlist) }}
|
||||
@if (playlist.updateDate) {
|
||||
<span class="separator">·</span>
|
||||
{{
|
||||
playlist.updateDate
|
||||
| date
|
||||
: 'MMM d, HH:mm'
|
||||
: undefined
|
||||
: currentLocale()
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="item-action-trigger"
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="itemMenu"
|
||||
[matMenuTriggerData]="{ playlist: playlist }"
|
||||
(click)="$event.stopPropagation()"
|
||||
[attr.aria-label]="
|
||||
'HOME.PLAYLISTS.PLAYLIST_ACTIONS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-state">
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showAddPlaylist() || showAccountInfo() || showPlaylistInfo()) {
|
||||
<div class="context-actions-section">
|
||||
@if (showPlaylistInfo()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestPlaylistInfo()"
|
||||
>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (showAccountInfo()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestAccountInfo()"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (showAddPlaylist()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestAddPlaylist()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #itemMenu="matMenu" class="playlist-item-action-menu">
|
||||
<ng-template matMenuContent let-playlist="playlist">
|
||||
<button mat-menu-item (click)="openPlaylistInfoFor(playlist, $event)">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
|
||||
</button>
|
||||
@if (hasAccountInfo(playlist)) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="requestAccountInfoFor(playlist, $event)"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
<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)"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
class="danger"
|
||||
(click)="removePlaylistFor(playlist, $event)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE' | translate }}</span>
|
||||
</button>
|
||||
</ng-template>
|
||||
</mat-menu>
|
||||
<app-playlist-switcher-panel
|
||||
[style.--playlist-switcher-max-height]="maxHeight()"
|
||||
(closeRequested)="close()"
|
||||
(playlistSelected)="playlistSelected.emit($event)"
|
||||
(addPlaylistRequested)="addPlaylistRequested.emit()"
|
||||
(accountInfoRequested)="accountInfoForPlaylistRequested.emit($event)"
|
||||
/>
|
||||
</ng-template>
|
||||
+82
-596
@@ -5,648 +5,134 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// The trigger is a secondary button (UI kit): 40px, 10px radius, a 10%
|
||||
// fill, the source's tile, two lines and a chevron that turns when open.
|
||||
.playlist-switcher-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 10px 0 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
||||
color: var(--app-heading-color);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: color-mix(in srgb, var(--app-on-surface) 5%, transparent);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 36%, transparent);
|
||||
app-region: no-drag;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 12%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 64%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--app-on-surface) 16%, transparent);
|
||||
}
|
||||
|
||||
// Open: one step up, with a 2px inset ring in the selection colour.
|
||||
&.is-open {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 12%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 72%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
box-shadow: inset 0 0 0 2px var(--app-selection-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
&:focus-visible {
|
||||
@include focus-ring.focus-ring-declarations;
|
||||
}
|
||||
|
||||
app-source-tile {
|
||||
--source-tile-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
var(--app-header-bg)
|
||||
);
|
||||
--source-tile-ring: var(--app-header-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-icon-container {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 8%,
|
||||
transparent
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.trigger-placeholder-tile {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
||||
color: var(--app-body-color);
|
||||
|
||||
.trigger-playlist-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
.mat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
|
||||
.name {
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
.name,
|
||||
.channels-count {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.channels-count {
|
||||
font-size: 0.74em;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
// A wrapping subtitle pushes the trigger past the 56px header row.
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// On a phone the header row has no width to spare, and the shell allows this
|
||||
// trigger to shrink to 88px there. The decorative type icon goes so that the
|
||||
// trigger's fixed chrome (refresh, chevron, gaps, padding) actually fits that
|
||||
// floor with the name ellipsizing; the subtitle goes because the name is the
|
||||
// only part worth the room.
|
||||
@media (max-width: 640px) {
|
||||
.trigger-icon-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-info .channels-count {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-refresh-button {
|
||||
margin-left: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
line-height: 32px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--mat-sys-on-surface);
|
||||
background: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-button-persistent-ripple,
|
||||
::ng-deep .mat-mdc-focus-indicator {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-refresh-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.is-spinning {
|
||||
animation: switcher-refresh-spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes switcher-refresh-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-left: 4px;
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
color: var(--app-body-color);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel {
|
||||
--mat-menu-container-color: var(--mat-sys-surface-container-high);
|
||||
width: min(
|
||||
var(--playlist-switcher-overlay-width, 400px),
|
||||
calc(100vw - 24px)
|
||||
) !important;
|
||||
min-width: min(
|
||||
var(--playlist-switcher-overlay-width, 400px),
|
||||
calc(100vw - 24px)
|
||||
) !important;
|
||||
max-width: min(
|
||||
var(--playlist-switcher-overlay-width, 400px),
|
||||
calc(100vw - 24px)
|
||||
) !important;
|
||||
margin-top: -1px !important;
|
||||
border-radius: 0 0 12px 12px !important;
|
||||
border: 1px solid var(--mat-sys-outline) !important;
|
||||
border-top: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: var(--mat-menu-container-color) !important;
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 0 !important;
|
||||
background: var(--mat-menu-container-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 6px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--mat-menu-container-color);
|
||||
|
||||
.menu-header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu-header-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.search-toggle-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.8;
|
||||
padding: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 0 8px 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 6%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-container:focus-within {
|
||||
border-color: var(--app-focus-ring);
|
||||
background: var(--mat-sys-surface-container-highest);
|
||||
}
|
||||
|
||||
.search-container .search-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.search-container .search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 0.86em;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-container .search-input::placeholder {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.search-container .clear-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-container .clear-btn mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 8px 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.type-filter-chip {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 78%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--app-selection-border, var(--app-selection-color));
|
||||
background: var(
|
||||
--app-selection-surface-strong,
|
||||
var(--app-selection-surface)
|
||||
);
|
||||
color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
/* box-shadow:
|
||||
inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--app-selection-color) 18%, transparent),
|
||||
0 0 0 1px
|
||||
color-mix(in srgb, var(--app-selection-color) 12%, transparent),
|
||||
0 0 10px var(--app-selection-glow, transparent); */
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring.focus-ring-declarations($offset: 1px);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
background: color-mix(in srgb, #000 22%, transparent);
|
||||
border-top: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 12px;
|
||||
min-height: 44px;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-item.selected {
|
||||
background: var(--app-selection-surface, rgba(139, 92, 246, 0.1));
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--app-selection-color, #a78bfa);
|
||||
}
|
||||
|
||||
.playlist-title,
|
||||
.playlist-meta {
|
||||
color: var(--app-selection-color, #a78bfa);
|
||||
}
|
||||
}
|
||||
|
||||
.item-action-trigger {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
flex-shrink: 0;
|
||||
color: var(--mat-sys-on-surface-variant);padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-item:hover .item-action-trigger,
|
||||
.playlist-item.selected .item-action-trigger,
|
||||
.item-action-trigger:focus-visible,
|
||||
.item-action-trigger[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
::ng-deep .playlist-item-action-menu {
|
||||
.danger,
|
||||
.danger mat-icon {
|
||||
color: var(--mat-sys-error, #f44336);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-item:hover:not(.selected) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.playlist-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 6%,
|
||||
transparent
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-type-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
border: 2px solid var(--mat-menu-container-color, #2a2a2e);
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #4caf50;
|
||||
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background-color: #f44336;
|
||||
box-shadow: 0 0 6px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #ff9800;
|
||||
box-shadow: 0 0 6px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
background-color: #666666;
|
||||
}
|
||||
|
||||
.status-checking {
|
||||
background-color: #9e9e9e;
|
||||
animation: switcher-status-checking 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes switcher-status-checking {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
box-shadow: 0 0 0 0 rgba(158, 158, 158, 0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 4px 1px rgba(158, 158, 158, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-checking {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
.playlist-switcher-trigger,
|
||||
.dropdown-icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-refresh-indicator {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
font-size: 12px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
color: var(--mat-sys-primary);
|
||||
background-color: var(--mat-menu-container-color, var(--mat-sys-surface));
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playlist-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 0.92em;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 0.73em;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.separator {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
gap: 12px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
.context-actions-section {
|
||||
padding: 6px 8px 8px;
|
||||
background: var(--mat-menu-container-color);
|
||||
|
||||
.context-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font: inherit;
|
||||
font-size: 0.86em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.playlist-info .name {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.playlist-icon-container {
|
||||
// On a phone the header row has no width to spare and the trigger may
|
||||
// shrink to 88px: the tile and the subtitle go, the name ellipsizes.
|
||||
@media (max-width: 640px) {
|
||||
.playlist-switcher-trigger app-source-tile,
|
||||
.trigger-placeholder-tile,
|
||||
.playlist-info .channels-count {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// The popover's backdrop dims and slightly blurs the window behind it. The
|
||||
// overlay container is outside this component, so these are global.
|
||||
::ng-deep .playlist-switcher-backdrop {
|
||||
background: var(--app-popover-backdrop);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
::ng-deep .playlist-switcher-backdrop {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
+103
-400
@@ -1,111 +1,53 @@
|
||||
import { signal } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
flushMicrotasks,
|
||||
tick,
|
||||
} from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import {
|
||||
PlaylistDeleteActionService,
|
||||
PortalStatusService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
|
||||
import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service';
|
||||
import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component';
|
||||
import { PlaylistSwitcherComponent } from './playlist-switcher.component';
|
||||
|
||||
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
|
||||
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
|
||||
|
||||
function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
filename: overrides.filename,
|
||||
count: overrides.count ?? 0,
|
||||
importDate:
|
||||
overrides.importDate ??
|
||||
new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
autoRefresh: overrides.autoRefresh ?? false,
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher-panel',
|
||||
template: '<input class="stub-search" />',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubPanelComponent {
|
||||
readonly closeRequested = output<void>();
|
||||
readonly playlistSelected = output<string>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly accountInfoRequested = output<PlaylistMeta>();
|
||||
}
|
||||
|
||||
const URL_NAMED = {
|
||||
_id: 'url-named',
|
||||
title: 'get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
url: 'http://biggie.test:8080/get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
count: 3220,
|
||||
importDate: '',
|
||||
autoRefresh: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
describe('PlaylistSwitcherComponent', () => {
|
||||
const testWindow = window as unknown as { electron?: unknown };
|
||||
const originalElectron = testWindow.electron;
|
||||
let fixture: ComponentFixture<PlaylistSwitcherComponent>;
|
||||
let component: PlaylistSwitcherComponent;
|
||||
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let resolvedPlaylistIdSignal: ReturnType<typeof signal<string | null>>;
|
||||
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
|
||||
let playlistContext: {
|
||||
playlists: typeof playlistsSignal;
|
||||
allPlaylistsLoaded: ReturnType<typeof signal<boolean>>;
|
||||
resolvedPlaylistId: typeof resolvedPlaylistIdSignal;
|
||||
activePlaylist: typeof activePlaylistSignal;
|
||||
selectPlaylist: jest.Mock;
|
||||
};
|
||||
let portalStatusService: {
|
||||
checkPortalStatus: jest.Mock;
|
||||
getCachedStatus: jest.Mock;
|
||||
getStatusClass: jest.Mock;
|
||||
};
|
||||
let refreshActionService: {
|
||||
canRefresh: jest.Mock;
|
||||
refresh: jest.Mock;
|
||||
};
|
||||
let dialog: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let dialogService: {
|
||||
openConfirmDialog: jest.Mock;
|
||||
};
|
||||
let playlistDeleteAction: {
|
||||
deletePlaylist: jest.Mock;
|
||||
};
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let store: {
|
||||
dispatch: jest.Mock;
|
||||
};
|
||||
let active: ReturnType<typeof signal<PlaylistMeta | null>>;
|
||||
|
||||
const m3uPlaylist = createPlaylist({
|
||||
_id: 'm3u-1',
|
||||
count: 24,
|
||||
importDate: '2026-04-05T09:00:00.000Z',
|
||||
title: 'Morning M3U',
|
||||
url: 'http://example.test/m3u.m3u',
|
||||
});
|
||||
const stalkerPlaylist = createPlaylist({
|
||||
_id: 'stalker-1',
|
||||
importDate: '2026-04-05T11:00:00.000Z',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Living Room Stalker',
|
||||
});
|
||||
const xtreamPlaylist = createPlaylist({
|
||||
_id: 'xtream-1',
|
||||
importDate: '2026-04-05T12:00:00.000Z',
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Cinema Xtream',
|
||||
updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(),
|
||||
username: 'user1',
|
||||
});
|
||||
const trigger = () =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="playlist-switcher-trigger"]'
|
||||
) as HTMLButtonElement;
|
||||
const panel = () => document.querySelector('app-playlist-switcher-panel');
|
||||
|
||||
async function createComponent(): Promise<void> {
|
||||
await TestBed.configureTestingModule({
|
||||
beforeEach(() => {
|
||||
active = signal<PlaylistMeta | null>(URL_NAMED);
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
PlaylistSwitcherComponent,
|
||||
NoopAnimationsModule,
|
||||
@@ -114,337 +56,98 @@ describe('PlaylistSwitcherComponent', () => {
|
||||
providers: [
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: playlistContext,
|
||||
useValue: {
|
||||
resolvedPlaylistId: signal('url-named'),
|
||||
activePlaylist: active,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PortalStatusService,
|
||||
useValue: portalStatusService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistRefreshActionService,
|
||||
useValue: refreshActionService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistDeleteActionService,
|
||||
useValue: playlistDeleteAction,
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: dialog,
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: dialogService,
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store,
|
||||
provide: SourceStatusService,
|
||||
useValue: { statusOf: () => ({ reachability: 'ok' }) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
});
|
||||
TestBed.overrideComponent(PlaylistSwitcherComponent, {
|
||||
remove: { imports: [PlaylistSwitcherPanelComponent] },
|
||||
add: { imports: [StubPanelComponent] },
|
||||
});
|
||||
fixture = TestBed.createComponent(PlaylistSwitcherComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('currentTitle', '');
|
||||
fixture.componentRef.setInput('subtitle', '3 playlists');
|
||||
fixture.componentRef.setInput('subtitle', '3 sources');
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
playlistsSignal = signal([
|
||||
m3uPlaylist,
|
||||
stalkerPlaylist,
|
||||
xtreamPlaylist,
|
||||
]);
|
||||
resolvedPlaylistIdSignal = signal<string | null>(xtreamPlaylist._id);
|
||||
activePlaylistSignal = signal<PlaylistMeta | null>(xtreamPlaylist);
|
||||
|
||||
playlistContext = {
|
||||
playlists: playlistsSignal,
|
||||
allPlaylistsLoaded: signal(true),
|
||||
resolvedPlaylistId: resolvedPlaylistIdSignal,
|
||||
activePlaylist: activePlaylistSignal,
|
||||
selectPlaylist: jest.fn((playlist: PlaylistMeta) => {
|
||||
resolvedPlaylistIdSignal.set(playlist._id);
|
||||
activePlaylistSignal.set(playlist);
|
||||
}),
|
||||
};
|
||||
portalStatusService = {
|
||||
checkPortalStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('active')
|
||||
.mockResolvedValueOnce('inactive'),
|
||||
getCachedStatus: jest.fn().mockReturnValue(null),
|
||||
getStatusClass: jest.fn((status: string) => `status-${status}`),
|
||||
};
|
||||
refreshActionService = {
|
||||
canRefresh: jest.fn((playlist: PlaylistMeta) =>
|
||||
Boolean(playlist.serverUrl || playlist.url || playlist.filePath)
|
||||
),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
dialog = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
playlistDeleteAction = {
|
||||
deletePlaylist: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalElectron,
|
||||
});
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('labels each playlist row with translated text', async () => {
|
||||
await createComponent();
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
HOME: { PLAYLISTS: { CHANNELS_COUNT: '{{count}} channels' } },
|
||||
WORKSPACE: {
|
||||
SHELL: {
|
||||
STALKER_PORTAL: 'Stalker Portal',
|
||||
XTREAM_CODE: 'Xtream Code',
|
||||
},
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
|
||||
expect(component.getPlaylistMetaLabel(m3uPlaylist)).toBe('24 channels');
|
||||
expect(component.getPlaylistMetaLabel(stalkerPlaylist)).toBe(
|
||||
'Stalker Portal'
|
||||
it('names the active source by its host, never by a credential URL', () => {
|
||||
expect(trigger().querySelector('.name')?.textContent?.trim()).toBe(
|
||||
'biggie.test:8080'
|
||||
);
|
||||
expect(component.getPlaylistMetaLabel(xtreamPlaylist)).toBe(
|
||||
'Xtream Code'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads persisted search and type filters on startup and filters playlists accordingly', async () => {
|
||||
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room');
|
||||
localStorage.setItem(
|
||||
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
m3u: false,
|
||||
stalker: true,
|
||||
xtream: true,
|
||||
})
|
||||
);
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.searchQuery()).toBe('room');
|
||||
expect(component.isTypeFilterSelected('m3u')).toBe(false);
|
||||
expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]);
|
||||
});
|
||||
|
||||
it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(
|
||||
component.filteredPlaylists().map((playlist) => playlist._id)
|
||||
).toEqual([xtreamPlaylist._id, stalkerPlaylist._id, m3uPlaylist._id]);
|
||||
|
||||
component.togglePlaylistTypeFilter('m3u');
|
||||
component.togglePlaylistTypeFilter('stalker');
|
||||
expect(component.playlistTypeFilters()).toEqual({
|
||||
m3u: false,
|
||||
stalker: false,
|
||||
xtream: true,
|
||||
});
|
||||
|
||||
component.togglePlaylistTypeFilter('xtream');
|
||||
expect(component.playlistTypeFilters()).toEqual({
|
||||
m3u: false,
|
||||
stalker: false,
|
||||
xtream: true,
|
||||
});
|
||||
trigger().querySelector('.channels-count')?.textContent
|
||||
).toContain('M3U · HOME.PLAYLISTS.CHANNELS_COUNT');
|
||||
expect(trigger().textContent).not.toMatch(/hunter2|password=/);
|
||||
expect(trigger().querySelector('.source-tile__dot')).not.toBeNull();
|
||||
expect(trigger().getAttribute('aria-keyshortcuts')).toBe(
|
||||
'Meta+P Control+P'
|
||||
);
|
||||
});
|
||||
|
||||
it('selects a playlist, closes the menu, and emits the selected id', async () => {
|
||||
await createComponent();
|
||||
|
||||
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
|
||||
const selectedIds: string[] = [];
|
||||
component.playlistSelected.subscribe((playlistId) => {
|
||||
selectedIds.push(playlistId);
|
||||
});
|
||||
|
||||
component.selectPlaylist(stalkerPlaylist);
|
||||
it('shows the source count when no source is active', () => {
|
||||
active.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(closeMenuSpy).toHaveBeenCalledTimes(1);
|
||||
expect(playlistContext.selectPlaylist).toHaveBeenCalledWith(
|
||||
stalkerPlaylist
|
||||
expect(trigger().querySelector('.channels-count')?.textContent).toBe(
|
||||
'3 sources'
|
||||
);
|
||||
expect(selectedIds).toEqual([stalkerPlaylist._id]);
|
||||
expect(component.isSelected(stalkerPlaylist)).toBe(true);
|
||||
});
|
||||
|
||||
it('emits playlist and account info requests through the context actions', async () => {
|
||||
await createComponent();
|
||||
|
||||
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
|
||||
const playlistInfoSpy = jest.fn();
|
||||
const accountInfoSpy = jest.fn();
|
||||
component.playlistInfoRequested.subscribe(playlistInfoSpy);
|
||||
component.accountInfoRequested.subscribe(accountInfoSpy);
|
||||
|
||||
component.requestPlaylistInfo();
|
||||
component.requestAccountInfo();
|
||||
|
||||
expect(closeMenuSpy).toHaveBeenCalledTimes(2);
|
||||
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
|
||||
expect(accountInfoSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders playlist info, account info, and add playlist context actions in the menu', async () => {
|
||||
await createComponent();
|
||||
fixture.componentRef.setInput('showPlaylistInfo', true);
|
||||
fixture.componentRef.setInput('showAccountInfo', true);
|
||||
fixture.componentRef.setInput('showAddPlaylist', true);
|
||||
it('opens the popover in an overlay and closes it on Escape, back on the trigger', () => {
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
expect(panel()).not.toBeNull();
|
||||
expect(trigger().getAttribute('aria-expanded')).toBe('true');
|
||||
expect(trigger().classList).toContain('is-open');
|
||||
|
||||
const playlistInfoSpy = jest.fn();
|
||||
component.playlistInfoRequested.subscribe(playlistInfoSpy);
|
||||
|
||||
component.menuTrigger().openMenu();
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const actionButtons = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.context-actions-section .context-action-item'
|
||||
)
|
||||
);
|
||||
const labels = actionButtons.map((button) =>
|
||||
button.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(labels).toEqual([
|
||||
expect.stringContaining('PLAYLIST_INFO'),
|
||||
expect.stringContaining('ACCOUNT_INFO'),
|
||||
expect.stringContaining('ADD_PLAYLIST'),
|
||||
]);
|
||||
|
||||
actionButtons[0].click();
|
||||
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
|
||||
expect(panel()).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger());
|
||||
});
|
||||
|
||||
it('offers per-playlist account info for portal playlists only', async () => {
|
||||
await createComponent();
|
||||
it('refits the open popover when the window shrinks', () => {
|
||||
const height = Object.getOwnPropertyDescriptor(window, 'innerHeight');
|
||||
const setHeight = (value: number) =>
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
try {
|
||||
setHeight(900);
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
const opened = fixture.componentInstance.maxHeight();
|
||||
|
||||
const requestedPlaylists: PlaylistMeta[] = [];
|
||||
component.accountInfoForPlaylistRequested.subscribe((playlist) =>
|
||||
requestedPlaylists.push(playlist)
|
||||
);
|
||||
|
||||
expect(component.hasAccountInfo(m3uPlaylist)).toBe(false);
|
||||
expect(component.hasAccountInfo(stalkerPlaylist)).toBe(true);
|
||||
expect(component.hasAccountInfo(xtreamPlaylist)).toBe(true);
|
||||
|
||||
component.requestAccountInfoFor(stalkerPlaylist);
|
||||
expect(requestedPlaylists).toEqual([stalkerPlaylist]);
|
||||
setHeight(600);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
expect(fixture.componentInstance.maxHeight()).not.toBe(opened);
|
||||
expect(
|
||||
parseInt(fixture.componentInstance.maxHeight() ?? '', 10)
|
||||
).toBeLessThanOrEqual(600);
|
||||
} finally {
|
||||
if (height) Object.defineProperty(window, 'innerHeight', height);
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => {
|
||||
await createComponent();
|
||||
|
||||
const triggerElement = component.triggerElement().nativeElement;
|
||||
jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 320,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: 320,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
component.onMenuOpened();
|
||||
flushMicrotasks();
|
||||
tick();
|
||||
|
||||
expect(component.isMenuOpen()).toBe(true);
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--playlist-switcher-overlay-width'
|
||||
)
|
||||
).toBe('400px');
|
||||
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1);
|
||||
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
|
||||
xtreamPlaylist.serverUrl,
|
||||
xtreamPlaylist.username,
|
||||
xtreamPlaylist.password
|
||||
);
|
||||
expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe(
|
||||
'active'
|
||||
);
|
||||
expect(component.getStatusClass(xtreamPlaylist._id)).toBe(
|
||||
'status-active'
|
||||
);
|
||||
|
||||
component.onMenuClosed();
|
||||
expect(component.isMenuOpen()).toBe(false);
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--playlist-switcher-overlay-width'
|
||||
)
|
||||
).toBe('');
|
||||
}));
|
||||
|
||||
it('falls back to the active playlist title when no explicit current title is provided', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(component.displayTitle()).toBe('Cinema Xtream');
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
HOME: { PLAYLISTS: { SELECT_PLAYLIST: 'Pick a source' } },
|
||||
});
|
||||
translate.use('en');
|
||||
resolvedPlaylistIdSignal.set(null);
|
||||
activePlaylistSignal.set(null);
|
||||
it('opens on a ⌘P / Ctrl+P request while it is on screen', () => {
|
||||
const switcherOpen = TestBed.inject(PlaylistSwitcherOpenService);
|
||||
expect(switcherOpen.available).toBe(true);
|
||||
switcherOpen.request();
|
||||
fixture.detectChanges();
|
||||
expect(panel()).not.toBeNull();
|
||||
|
||||
expect(component.displayTitle()).toBe('Pick a source');
|
||||
});
|
||||
|
||||
it('delegates playlist deletion and removes the source after confirmation', async () => {
|
||||
await createComponent();
|
||||
|
||||
component.removePlaylistFor(xtreamPlaylist);
|
||||
const confirm = dialogService.openConfirmDialog.mock.calls[0][0]
|
||||
.onConfirm as () => Promise<void>;
|
||||
await confirm();
|
||||
|
||||
expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(
|
||||
xtreamPlaylist
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.playlistRemovalCommitted({
|
||||
playlistId: xtreamPlaylist._id,
|
||||
})
|
||||
);
|
||||
fixture.destroy();
|
||||
expect(switcherOpen.available).toBe(false);
|
||||
});
|
||||
});
|
||||
+118
-511
@@ -1,12 +1,8 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
injectTranslationTick,
|
||||
} from '@iptvnator/services';
|
||||
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
|
||||
import { foldSearchText, sourceHealthType } from '@iptvnator/shared/interfaces';
|
||||
import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component';
|
||||
import { DatePipe, DOCUMENT } from '@angular/common';
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
ConnectedPosition,
|
||||
} from '@angular/cdk/overlay';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -19,562 +15,173 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
PlaylistDeleteActionService,
|
||||
PortalStatus,
|
||||
PortalStatusService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
getPlaylistSourceIcon,
|
||||
isPortalAccountPlaylist,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
|
||||
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
|
||||
PlaylistContextFacade,
|
||||
sourceDisplayName,
|
||||
sourceMetaParts,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import { injectTranslationTick } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { SourceTileComponent } from '../source-row/source-tile.component';
|
||||
import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service';
|
||||
import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component';
|
||||
|
||||
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
|
||||
|
||||
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
|
||||
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
|
||||
const DEFAULT_PLAYLIST_TYPE_FILTERS: Record<PlaylistFilterType, boolean> = {
|
||||
m3u: true,
|
||||
stalker: true,
|
||||
xtream: true,
|
||||
};
|
||||
/** Below the trigger's left edge, 8px down; above it when there is no room. */
|
||||
const POSITIONS: ConnectedPosition[] = [
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetY: 8,
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -8,
|
||||
},
|
||||
];
|
||||
/** The popover keeps this far from the window's bottom edge. */
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
|
||||
/**
|
||||
* The toolbar playlist switcher: a trigger showing the active source and a
|
||||
* popover (`app-playlist-switcher-panel`) in a CDK overlay, so row menus
|
||||
* open above it rather than inside its scrolling list. Opens on click and
|
||||
* on ⌘P / Ctrl+P; Escape closes it and returns focus to the trigger.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher',
|
||||
imports: [
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
MatIcon,
|
||||
PlaylistSwitcherPanelComponent,
|
||||
SourceTileComponent,
|
||||
],
|
||||
templateUrl: './playlist-switcher.component.html',
|
||||
styleUrls: ['./playlist-switcher.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
SourceHealthIndicatorComponent,
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatRippleModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
host: { '(window:resize)': 'onWindowResize()' },
|
||||
})
|
||||
export class PlaylistSwitcherComponent {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
private readonly statusService = inject(SourceStatusService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly refreshAction = inject(PlaylistRefreshActionService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly healthInjector = inject(Injector);
|
||||
readonly sourceHealthType = sourceHealthType;
|
||||
recheckSource(playlist: PlaylistMeta): void {
|
||||
void this.healthInjector.get(SourceHealthService).recheck(playlist);
|
||||
}
|
||||
private readonly trigger =
|
||||
viewChild.required<ElementRef<HTMLButtonElement>>('trigger');
|
||||
|
||||
readonly currentTitle = input.required<string>();
|
||||
/** Shown under the name while no source is active ("3 sources"). */
|
||||
readonly subtitle = input<string>('');
|
||||
readonly showPlaylistInfo = input(false);
|
||||
readonly showAccountInfo = input(false);
|
||||
readonly showAddPlaylist = input(false);
|
||||
readonly canRefreshActivePlaylist = input(false);
|
||||
readonly isRefreshingActivePlaylist = input(false);
|
||||
readonly playlistSelected = output<string>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
|
||||
onRefreshClick(event: Event): void {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.refreshPlaylistRequested.emit();
|
||||
}
|
||||
|
||||
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
|
||||
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
|
||||
readonly triggerElement =
|
||||
viewChild.required<ElementRef<HTMLElement>>('triggerElement');
|
||||
readonly searchInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
|
||||
readonly isMenuOpen = signal(false);
|
||||
readonly searchQuery = signal(this.readPersistedSearchQuery());
|
||||
readonly playlistTypeFilters = signal(this.readPersistedTypeFilters());
|
||||
readonly searchExpanded = signal(false);
|
||||
private static readonly SEARCH_AUTO_VISIBLE_THRESHOLD = 5;
|
||||
|
||||
readonly playlists = this.playlistContext.playlists;
|
||||
readonly allPlaylistsLoaded = this.playlistContext.allPlaylistsLoaded;
|
||||
readonly positions = POSITIONS;
|
||||
readonly isOpen = signal(false);
|
||||
readonly maxHeight = signal<string | null>(null);
|
||||
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
|
||||
readonly activePlaylist = this.playlistContext.activePlaylist;
|
||||
readonly filteredPlaylists = computed(() => {
|
||||
const query = foldSearchText(this.searchQuery()).trim();
|
||||
const filters = this.playlistTypeFilters();
|
||||
const allPlaylists = this.playlists();
|
||||
const filteredByType = allPlaylists.filter((playlist) => {
|
||||
const playlistType = this.getPlaylistFilterType(playlist);
|
||||
return filters[playlistType];
|
||||
});
|
||||
const filtered = query
|
||||
? filteredByType.filter(
|
||||
(playlist) =>
|
||||
foldSearchText(playlist.title ?? '').includes(query) ||
|
||||
foldSearchText(playlist.filename ?? '').includes(query)
|
||||
)
|
||||
: filteredByType;
|
||||
|
||||
return [...filtered].sort(
|
||||
(a, b) =>
|
||||
new Date(b.importDate ?? 0).getTime() -
|
||||
new Date(a.importDate ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
/** The active source's name; never its URL or a credential. */
|
||||
readonly displayTitle = computed(() => {
|
||||
this.languageTick();
|
||||
if (!this.activePlaylistId()) {
|
||||
return this.translate.instant('HOME.PLAYLISTS.SELECT_PLAYLIST');
|
||||
}
|
||||
|
||||
const active = this.activePlaylist();
|
||||
return (
|
||||
this.currentTitle() ||
|
||||
this.activePlaylist()?.title ||
|
||||
this.activePlaylist()?.filename ||
|
||||
this.activePlaylist()?.url ||
|
||||
this.activePlaylist()?.portalUrl ||
|
||||
(active ? sourceDisplayName(active) : this.currentTitle()) ||
|
||||
this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
|
||||
);
|
||||
});
|
||||
|
||||
readonly availablePlaylistTypes = computed(() => {
|
||||
const types = new Set<PlaylistFilterType>();
|
||||
for (const playlist of this.playlists()) {
|
||||
types.add(this.getPlaylistFilterType(playlist));
|
||||
}
|
||||
return types;
|
||||
});
|
||||
readonly showTypeFilters = computed(
|
||||
() => this.availablePlaylistTypes().size > 1
|
||||
);
|
||||
readonly hasSearchToggle = computed(
|
||||
() =>
|
||||
this.playlists().length <=
|
||||
PlaylistSwitcherComponent.SEARCH_AUTO_VISIBLE_THRESHOLD
|
||||
);
|
||||
readonly showSearchField = computed(
|
||||
() => !this.hasSearchToggle() || this.searchExpanded()
|
||||
);
|
||||
|
||||
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
|
||||
|
||||
/**
|
||||
* Tracks the in-flight check round so we can cancel pending writes when
|
||||
* the menu closes (avoiding zombie writes from a slow portal landing
|
||||
* after the user has moved on). Status caching itself lives in
|
||||
* PortalStatusService so it's shared across components.
|
||||
*/
|
||||
private portalStatusAbortController: AbortController | null = null;
|
||||
readonly currentLocale = computed(() => {
|
||||
/** "Stalker", "M3U · 3,220 channels", or the header's source count. */
|
||||
readonly triggerSubtitle = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
const active = this.activePlaylist();
|
||||
if (!active) {
|
||||
return this.subtitle();
|
||||
}
|
||||
return sourceMetaParts(
|
||||
active,
|
||||
{
|
||||
translate: (key, params) => this.translate.instant(key, params),
|
||||
locale: normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
),
|
||||
now: Date.now(),
|
||||
},
|
||||
true
|
||||
)
|
||||
.filter((part) => part.kind === 'type' || part.kind === 'channels')
|
||||
.map((part) => part.text)
|
||||
.join(' · ');
|
||||
});
|
||||
readonly activeStatus = computed(() => {
|
||||
const active = this.activePlaylist();
|
||||
return active ? this.statusService.statusOf(active) : null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.clearMenuOverlayWidth();
|
||||
this.clearSearchFocusTimeout();
|
||||
this.cancelPortalStatusChecks();
|
||||
const openService = inject(PlaylistSwitcherOpenService);
|
||||
const unregister = openService.register();
|
||||
const requests = openService.openRequests.subscribe(() => this.open());
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
unregister();
|
||||
requests.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
onMenuOpened(): void {
|
||||
this.isMenuOpen.set(true);
|
||||
this.syncMenuOverlayWidthToTrigger();
|
||||
if (this.showSearchField()) {
|
||||
this.focusSearchField();
|
||||
}
|
||||
if (!this.runtime.supportsSourceHealth)
|
||||
void this.checkPortalStatuses(this.playlists());
|
||||
}
|
||||
|
||||
onMenuClosed(): void {
|
||||
this.isMenuOpen.set(false);
|
||||
this.clearMenuOverlayWidth();
|
||||
this.clearSearchFocusTimeout();
|
||||
this.cancelPortalStatusChecks();
|
||||
if (this.hasSearchToggle()) {
|
||||
this.searchExpanded.set(false);
|
||||
toggle(): void {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
toggleSearchField(event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
const next = !this.searchExpanded();
|
||||
this.searchExpanded.set(next);
|
||||
if (next) {
|
||||
this.focusSearchField();
|
||||
open(): void {
|
||||
this.fitToViewport();
|
||||
this.isOpen.set(true);
|
||||
}
|
||||
|
||||
/** A window resized while the popover is open refits it. */
|
||||
onWindowResize(): void {
|
||||
if (this.isOpen()) {
|
||||
this.fitToViewport();
|
||||
}
|
||||
}
|
||||
|
||||
setSearchQuery(value: string): void {
|
||||
this.searchQuery.set(value);
|
||||
this.writePersistedSearchQuery(value);
|
||||
/** Caps the popover to the room below the trigger; only its list scrolls. */
|
||||
private fitToViewport(): void {
|
||||
const bottom =
|
||||
this.trigger().nativeElement.getBoundingClientRect().bottom;
|
||||
this.maxHeight.set(
|
||||
`${Math.max(240, window.innerHeight - bottom - 8 - VIEWPORT_MARGIN)}px`
|
||||
);
|
||||
}
|
||||
|
||||
togglePlaylistTypeFilter(type: PlaylistFilterType, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
const current = this.playlistTypeFilters();
|
||||
const next = {
|
||||
...current,
|
||||
[type]: !current[type],
|
||||
};
|
||||
|
||||
if (!next.m3u && !next.stalker && !next.xtream) {
|
||||
/** Closes the popover; focus goes back to the trigger. */
|
||||
close(): void {
|
||||
if (!this.isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistTypeFilters.set(next);
|
||||
this.writePersistedTypeFilters(next);
|
||||
this.isOpen.set(false);
|
||||
this.trigger().nativeElement.focus();
|
||||
}
|
||||
|
||||
isTypeFilterSelected(type: PlaylistFilterType): boolean {
|
||||
return this.playlistTypeFilters()[type];
|
||||
}
|
||||
|
||||
selectPlaylist(playlist: PlaylistMeta): void {
|
||||
this.menuTrigger().closeMenu();
|
||||
this.playlistContext.selectPlaylist(playlist);
|
||||
this.playlistSelected.emit(playlist._id);
|
||||
}
|
||||
|
||||
requestPlaylistInfo(): void {
|
||||
this.menuTrigger().closeMenu();
|
||||
this.playlistInfoRequested.emit();
|
||||
}
|
||||
|
||||
requestAccountInfo(): void {
|
||||
this.menuTrigger().closeMenu();
|
||||
this.accountInfoRequested.emit();
|
||||
}
|
||||
|
||||
requestAddPlaylist(): void {
|
||||
this.menuTrigger().closeMenu();
|
||||
this.addPlaylistRequested.emit();
|
||||
}
|
||||
|
||||
hasType(type: PlaylistFilterType): boolean {
|
||||
return this.availablePlaylistTypes().has(type);
|
||||
}
|
||||
|
||||
canRefreshPlaylist(playlist: PlaylistMeta): boolean {
|
||||
return this.refreshAction.canRefresh(playlist);
|
||||
}
|
||||
|
||||
openPlaylistInfoFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
this.dialog.open(PlaylistInfoComponent, { data: playlist });
|
||||
}
|
||||
|
||||
hasAccountInfo(playlist: PlaylistMeta): boolean {
|
||||
return isPortalAccountPlaylist(playlist);
|
||||
}
|
||||
|
||||
requestAccountInfoFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
this.accountInfoForPlaylistRequested.emit(playlist);
|
||||
}
|
||||
|
||||
refreshPlaylistFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
this.refreshAction.refresh(playlist);
|
||||
}
|
||||
|
||||
removePlaylistFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
|
||||
tone: 'destructive',
|
||||
onConfirm: () => this.removePlaylistConfirmed(playlist),
|
||||
});
|
||||
}
|
||||
|
||||
private async removePlaylistConfirmed(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<void> {
|
||||
const deleted =
|
||||
await this.playlistDeleteAction.deletePlaylist(playlist);
|
||||
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.playlistRemovalCommitted({
|
||||
playlistId: playlist._id,
|
||||
})
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
}
|
||||
|
||||
getPlaylistIcon(playlist: PlaylistMeta): string {
|
||||
return getPlaylistSourceIcon(playlist);
|
||||
}
|
||||
|
||||
getPlaylistMetaLabel(playlist: PlaylistMeta): string {
|
||||
if (playlist.macAddress) {
|
||||
return this.translate.instant('WORKSPACE.SHELL.STALKER_PORTAL');
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return this.translate.instant('WORKSPACE.SHELL.XTREAM_CODE');
|
||||
}
|
||||
return this.translate.instant('HOME.PLAYLISTS.CHANNELS_COUNT', {
|
||||
count: playlist.count ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
getStatusClass(playlistId: string): string {
|
||||
const status = this.portalStatuses().get(playlistId);
|
||||
// No entry yet means we haven't checked (or are mid-flight without a
|
||||
// 'checking' write). Render no status class instead of misleading the
|
||||
// user with the red 'unavailable' dot.
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
return this.portalStatusService.getStatusClass(status);
|
||||
}
|
||||
|
||||
isSelected(playlist: PlaylistMeta): boolean {
|
||||
return this.activePlaylistId() === playlist._id;
|
||||
}
|
||||
|
||||
private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType {
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return 'xtream';
|
||||
}
|
||||
return 'm3u';
|
||||
}
|
||||
|
||||
private async checkPortalStatuses(playlists: PlaylistMeta[]) {
|
||||
// Cancel any prior in-flight round so its results can't overwrite the
|
||||
// new round (e.g. user closes + reopens the menu rapidly).
|
||||
this.cancelPortalStatusChecks();
|
||||
const controller = new AbortController();
|
||||
this.portalStatusAbortController = controller;
|
||||
|
||||
const xtreamPlaylists = playlists.filter(
|
||||
(
|
||||
playlist
|
||||
): playlist is PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} =>
|
||||
Boolean(
|
||||
playlist.serverUrl && playlist.username && playlist.password
|
||||
)
|
||||
);
|
||||
if (xtreamPlaylists.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hydrate from the shared service cache + mark uncached portals as
|
||||
// 'checking' in a single signal write so the UI flips from blank →
|
||||
// cached/checking dots in one render, not one per playlist.
|
||||
const next = new Map(this.portalStatuses());
|
||||
const toFetch: (PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
})[] = [];
|
||||
for (const playlist of xtreamPlaylists) {
|
||||
const cached = this.portalStatusService.getCachedStatus(
|
||||
playlist.serverUrl,
|
||||
playlist.username,
|
||||
playlist.password
|
||||
);
|
||||
if (cached !== null) {
|
||||
next.set(playlist._id, cached);
|
||||
} else {
|
||||
next.set(playlist._id, 'checking');
|
||||
toFetch.push(playlist);
|
||||
}
|
||||
}
|
||||
this.portalStatuses.set(next);
|
||||
|
||||
if (toFetch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream results: each portal's dot updates as soon as ITS request
|
||||
// resolves, independent of the slowest one. Replaces the prior
|
||||
// Promise.all that blocked all dots on the tail latency.
|
||||
await Promise.all(
|
||||
toFetch.map(async (playlist) => {
|
||||
let status: PortalStatus;
|
||||
try {
|
||||
status = await this.portalStatusService.checkPortalStatus(
|
||||
playlist.serverUrl,
|
||||
playlist.username,
|
||||
playlist.password
|
||||
);
|
||||
} catch {
|
||||
status = 'unavailable';
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.portalStatuses.update((current) => {
|
||||
const updated = new Map(current);
|
||||
updated.set(playlist._id, status);
|
||||
return updated;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private cancelPortalStatusChecks(): void {
|
||||
if (this.portalStatusAbortController) {
|
||||
this.portalStatusAbortController.abort();
|
||||
this.portalStatusAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
private syncMenuOverlayWidthToTrigger(): void {
|
||||
const triggerWidth = Math.round(
|
||||
this.triggerElement().nativeElement.getBoundingClientRect().width
|
||||
);
|
||||
const overlayWidth = Math.max(triggerWidth, 400);
|
||||
this.document.documentElement.style.setProperty(
|
||||
'--playlist-switcher-overlay-width',
|
||||
`${overlayWidth}px`
|
||||
);
|
||||
}
|
||||
|
||||
private clearMenuOverlayWidth(): void {
|
||||
this.document.documentElement.style.removeProperty(
|
||||
'--playlist-switcher-overlay-width'
|
||||
);
|
||||
}
|
||||
|
||||
private focusSearchField(): void {
|
||||
this.clearSearchFocusTimeout();
|
||||
this.focusSearchTimeoutId = setTimeout(() => {
|
||||
const input = this.searchInput()?.nativeElement;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.focus();
|
||||
input.select();
|
||||
this.focusSearchTimeoutId = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private clearSearchFocusTimeout(): void {
|
||||
if (this.focusSearchTimeoutId !== null) {
|
||||
clearTimeout(this.focusSearchTimeoutId);
|
||||
this.focusSearchTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private readPersistedSearchQuery(): string {
|
||||
try {
|
||||
return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private writePersistedSearchQuery(value: string): void {
|
||||
try {
|
||||
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, value);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private readPersistedTypeFilters(): Record<PlaylistFilterType, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(PLAYLIST_TYPE_FILTER_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<
|
||||
Record<PlaylistFilterType, boolean>
|
||||
>;
|
||||
const next = {
|
||||
m3u: parsed.m3u !== false,
|
||||
stalker: parsed.stalker !== false,
|
||||
xtream: parsed.xtream !== false,
|
||||
};
|
||||
|
||||
if (!next.m3u && !next.stalker && !next.xtream) {
|
||||
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
|
||||
}
|
||||
|
||||
return next;
|
||||
} catch {
|
||||
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
|
||||
}
|
||||
}
|
||||
|
||||
private writePersistedTypeFilters(
|
||||
filters: Record<PlaylistFilterType, boolean>
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
|
||||
JSON.stringify(filters)
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
onOverlayKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
-17
@@ -82,23 +82,6 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('no-results') {
|
||||
<div class="empty-state no-results-state">
|
||||
<div class="illustration-container small">
|
||||
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="65" cy="65" r="35" class="search-circle" stroke-width="4"/>
|
||||
<line x1="90" y1="90" x2="115" y2="115" class="search-handle" stroke-width="4" stroke-linecap="round"/>
|
||||
<rect x="45" y="50" width="40" height="30" rx="3" class="empty-box" stroke-width="2" opacity="0.4"/>
|
||||
<line x1="55" y1="65" x2="75" y2="65" class="empty-line" stroke-width="2" opacity="0.3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h3 class="title small">{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}</h3>
|
||||
<p class="description">{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('no-data') {
|
||||
<div class="empty-state no-data-state">
|
||||
<div class="empty-icon-container">
|
||||
|
||||
-34
@@ -66,40 +66,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.no-results-state {
|
||||
padding: 40px 24px;
|
||||
|
||||
.illustration-container {
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.5;
|
||||
|
||||
&.small {
|
||||
animation: gentleFloat 1s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
color: var(--app-body-color);
|
||||
|
||||
.search-circle,
|
||||
.search-handle {
|
||||
stroke: currentColor;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-box,
|
||||
.empty-line {
|
||||
stroke: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data-state {
|
||||
padding: 56px 24px;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import type { PlaylistType } from '../../add-playlist-menu/playlist-type';
|
||||
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export type EmptyStateType =
|
||||
'welcome-dashboard' | 'welcome-sources' | 'no-results' | 'no-data';
|
||||
'welcome-dashboard' | 'welcome-sources' | 'no-data';
|
||||
|
||||
interface FeatureCard {
|
||||
icon: string;
|
||||
|
||||
-21
@@ -1,12 +1,4 @@
|
||||
@media (max-width: 480px) {
|
||||
.no-results-state {
|
||||
padding: 30px 20px;
|
||||
|
||||
.illustration {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data-state {
|
||||
padding: 40px 20px;
|
||||
@@ -19,10 +11,6 @@
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.no-results-state {
|
||||
padding: 24px 20px;
|
||||
.illustration-container { margin-bottom: 16px; }
|
||||
}
|
||||
|
||||
.no-data-state {
|
||||
padding: 32px 24px;
|
||||
@@ -30,12 +18,3 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 550px) {
|
||||
.no-results-state {
|
||||
padding: 16px 20px;
|
||||
.illustration-container { margin-bottom: 12px; }
|
||||
.illustration { width: 80px; height: 80px; }
|
||||
.title { font-size: 1.125rem; }
|
||||
.description { font-size: 0.8125rem; }
|
||||
}
|
||||
}
|
||||
-290
@@ -1,290 +0,0 @@
|
||||
<div
|
||||
class="playlist-item"
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="!isDraggable() || isBusy()"
|
||||
cdkDragPreviewContainer="parent"
|
||||
[class.selected]="isSelected()"
|
||||
[class.is-busy]="isBusy()"
|
||||
(click)="onPlaylistClick()"
|
||||
>
|
||||
@if (isDraggable()) {
|
||||
<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()"
|
||||
/>
|
||||
}
|
||||
<!-- One provider icon per row, by the shared precedence: a restored
|
||||
Stalker or Xtream playlist can also carry a URL. -->
|
||||
<div class="icon-container">
|
||||
@switch (sourceIconKey) {
|
||||
@case ('stalker') {
|
||||
<mat-icon
|
||||
class="upload-type-icon"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.STALKER_PORTAL' | translate
|
||||
"
|
||||
>{{ sourceIcons.stalker }}</mat-icon
|
||||
>
|
||||
}
|
||||
@case ('xtream') {
|
||||
<mat-icon
|
||||
class="upload-type-icon"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate
|
||||
"
|
||||
>{{ sourceIcons.xtream }}</mat-icon
|
||||
>
|
||||
}
|
||||
@case ('m3u-url') {
|
||||
<mat-icon
|
||||
class="upload-type-icon"
|
||||
[matTooltip]="
|
||||
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) +
|
||||
' ' +
|
||||
item.url
|
||||
"
|
||||
>{{ sourceIcons['m3u-url'] }}</mat-icon
|
||||
>
|
||||
}
|
||||
@default {
|
||||
<mat-icon
|
||||
class="upload-type-icon"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate
|
||||
"
|
||||
>{{ sourceIcons['m3u-local'] }}</mat-icon
|
||||
>
|
||||
}
|
||||
}
|
||||
@if (showsPortalStatusDot) {
|
||||
@let statusLabel =
|
||||
'HOME.PLAYLISTS.PORTAL_STATUS'
|
||||
| translate
|
||||
: { status: (portalStatusLabelKey() | translate) };
|
||||
<span
|
||||
class="status-dot"
|
||||
[class]="getStatusClass()"
|
||||
[matTooltip]="statusLabel"
|
||||
[attr.aria-label]="statusLabel"
|
||||
></span>
|
||||
} @else if (showsAutoRefresh) {
|
||||
<mat-icon
|
||||
class="auto-refresh-indicator"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
|
||||
"
|
||||
>autorenew</mat-icon
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- The keyboard activation element is its own element so the drag
|
||||
handle and the actions stay siblings: an interactive control nested
|
||||
inside a role="button" is an invalid accessibility structure. A
|
||||
pointer click anywhere on the row still opens it via the row. -->
|
||||
<div
|
||||
class="playlist-content"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="item.title || item.filename"
|
||||
[attr.aria-current]="isSelected() || null"
|
||||
[attr.aria-disabled]="isBusy() || null"
|
||||
(keydown.enter)="onActivationKey($event)"
|
||||
(keydown.space)="onActivationKey($event)"
|
||||
>
|
||||
<div class="playlist-title">
|
||||
{{ item.title || item.filename }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
@if (!item.serverUrl && !item.portalUrl) {
|
||||
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
|
||||
@if (item.importDate.length > 3) {
|
||||
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
|
||||
{{
|
||||
item.importDate
|
||||
| date: undefined : undefined : currentLocale()
|
||||
}}
|
||||
}
|
||||
@if (item.updateDate) {
|
||||
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
|
||||
{{
|
||||
item.updateDate
|
||||
| date
|
||||
: 'MMMM d, yyyy, HH:mm'
|
||||
: undefined
|
||||
: currentLocale()
|
||||
}}
|
||||
}
|
||||
@if (item.updateState === 2) {
|
||||
<mat-icon
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
|
||||
| translate
|
||||
"
|
||||
[inline]="true"
|
||||
>warning</mat-icon
|
||||
>
|
||||
}
|
||||
} @else {
|
||||
@if (item.macAddress) {
|
||||
Stalker
|
||||
} @else {
|
||||
Xtream
|
||||
}
|
||||
@if (item.importDate.length > 3) {
|
||||
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
|
||||
{{
|
||||
item.importDate
|
||||
| date: undefined : undefined : currentLocale()
|
||||
}}
|
||||
}
|
||||
@if (item.updateDate) {
|
||||
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
|
||||
{{
|
||||
item.updateDate
|
||||
| date
|
||||
: 'MMMM d, yyyy, HH:mm'
|
||||
: undefined
|
||||
: currentLocale()
|
||||
}}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (isBusy() && busyMessage()) {
|
||||
<div class="busy-state">
|
||||
<span class="busy-state__message">{{ busyMessage() }}</span>
|
||||
@if (busyProgress() !== null) {
|
||||
<span class="busy-state__value">{{ busyProgress() }}%</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (isBusy() && busyMessage() && busyProgress() !== null) {
|
||||
<div class="busy-state__bar">
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="busyProgress() ?? 0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@if (isBusy() && busyMessage() && busyProgress() === null) {
|
||||
<div class="busy-state__bar">
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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
|
||||
class="cancel-btn app-destructive-button"
|
||||
(click)="
|
||||
$event.stopPropagation(); cancelBusyActionClicked.emit()
|
||||
"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (item.url || (item.filePath && supportsPlaylistRefresh)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
(click)="
|
||||
$event.stopPropagation(); refreshClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
>
|
||||
@if (isRefreshing()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>sync</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (item.serverUrl && supportsXtreamSqliteDataSource) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
(click)="
|
||||
$event.stopPropagation(); refreshClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
|
||||
[attr.aria-label]="
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM' | translate
|
||||
"
|
||||
>
|
||||
@if (isRefreshing()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>sync</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="edit-btn"
|
||||
(click)="
|
||||
$event.stopPropagation(); editPlaylistClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="delete-btn"
|
||||
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.REMOVE' | translate"
|
||||
>
|
||||
@if (isDeleting()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>delete</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
-290
@@ -1,290 +0,0 @@
|
||||
@use '../../../../../../../ui/styles/focus-ring';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px 8px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--app-selection-surface-strong),
|
||||
var(--app-selection-surface)
|
||||
);
|
||||
border-color: var(--app-selection-border);
|
||||
color: var(--app-selection-color);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border),
|
||||
0 12px 20px -18px var(--app-selection-glow);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.meta,
|
||||
.upload-type-icon,
|
||||
.drag-icon,
|
||||
.action-buttons button,
|
||||
.action-buttons mat-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface-strong) 85%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-busy {
|
||||
cursor: progress;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playlist-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
// The row draws this element's focus ring.
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// The keyboard ring belongs to the whole row, the unit that opens. It sits
|
||||
// inside the row: the list's scroller would cut one drawn outside it.
|
||||
.playlist-item:has(> .playlist-content:focus-visible) {
|
||||
@include focus-ring.focus-ring-declarations($offset: -2px);
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.84;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.busy-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--app-selection-border, #8aa4c8);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.busy-state__message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.busy-state__value {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.busy-state__bar {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.25),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15),
|
||||
0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
opacity: 0.46;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-inline-start: auto;
|
||||
|
||||
button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
opacity: 0.76;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
transform: none;
|
||||
opacity: 0.38;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The action buttons inherit the row color, including the selected row's,
|
||||
// which masks the .app-destructive-button token on the cancel action.
|
||||
.playlist-item .action-buttons .cancel-btn,
|
||||
.playlist-item.selected .action-buttons .cancel-btn {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
.action-spinner {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
.upload-type-icon {
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.playlist-item:hover & .upload-type-icon {
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 2px solid var(--mat-sys-surface);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #4caf50;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background-color: #f44336;
|
||||
box-shadow: 0 0 8px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #ff9800;
|
||||
box-shadow: 0 0 8px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
background-color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.auto-refresh-indicator {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
font-size: 14px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
color: #4caf50;
|
||||
background-color: var(--mat-sys-surface);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
.playlist-item:hover & {
|
||||
color: #66bb6a;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.upload-type-icon,
|
||||
.icon-container,
|
||||
.action-buttons .refresh-btn,
|
||||
.action-buttons .edit-btn,
|
||||
.action-buttons .delete-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
-408
@@ -1,408 +0,0 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { MockModule } from 'ng-mocks';
|
||||
import {
|
||||
PortalStatusService,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistItemComponent } from './playlist-item.component';
|
||||
|
||||
describe('PlaylistItemComponent', () => {
|
||||
let component: PlaylistItemComponent;
|
||||
let fixture: ComponentFixture<PlaylistItemComponent>;
|
||||
let runtime: {
|
||||
supportsPlaylistRefresh: boolean;
|
||||
supportsXtreamSqliteDataSource: boolean;
|
||||
};
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
runtime = {
|
||||
supportsPlaylistRefresh: true,
|
||||
supportsXtreamSqliteDataSource: true,
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
PlaylistItemComponent,
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatTooltipModule),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PortalStatusService,
|
||||
useValue: {
|
||||
checkPortalStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue('active'),
|
||||
getStatusClass: jest.fn(() => 'status-active'),
|
||||
getStatusIcon: jest.fn(() => 'check_circle'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: runtime,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Playlist',
|
||||
_id: '1',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
};
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows busy UI and suppresses playlist clicks while deleting', () => {
|
||||
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
|
||||
|
||||
fixture.componentRef.setInput('isDeleting', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
const deleteButton = nativeElement.querySelector(
|
||||
'.delete-btn'
|
||||
) as HTMLButtonElement;
|
||||
const editButton = nativeElement.querySelector(
|
||||
'.edit-btn'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(component.isBusy()).toBe(true);
|
||||
expect(deleteButton.disabled).toBe(true);
|
||||
expect(editButton.disabled).toBe(true);
|
||||
expect(nativeElement.querySelector('.action-spinner')).not.toBeNull();
|
||||
|
||||
component.onPlaylistClick();
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('keyboard activation', () => {
|
||||
const row = () => fixture.nativeElement as HTMLElement;
|
||||
const activation = () =>
|
||||
row().querySelector('[role="button"]') as HTMLElement;
|
||||
const keydown = (target: HTMLElement, key: string) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
it('is a Tab stop named after the source that Enter and Space open', () => {
|
||||
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
|
||||
const surface = activation();
|
||||
|
||||
expect(surface).not.toBeNull();
|
||||
expect(surface.getAttribute('tabindex')).toBe('0');
|
||||
expect(surface.getAttribute('aria-label')).toBe('Playlist');
|
||||
|
||||
const enter = keydown(surface, 'Enter');
|
||||
const space = keydown(surface, ' ');
|
||||
|
||||
expect(emitSpy).toHaveBeenNthCalledWith(1, '1');
|
||||
expect(emitSpy).toHaveBeenNthCalledWith(2, '1');
|
||||
expect(enter.defaultPrevented).toBe(true);
|
||||
// Space must not scroll the page.
|
||||
expect(space.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the drag handle and actions outside the activation element so their keys never open the source', () => {
|
||||
fixture.componentRef.setInput('isDraggable', true);
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
|
||||
const editSpy = jest.spyOn(component.editPlaylistClicked, 'emit');
|
||||
const controls = Array.from(
|
||||
row().querySelectorAll<HTMLElement>(
|
||||
'.action-buttons button, .drag-icon'
|
||||
)
|
||||
);
|
||||
|
||||
expect(activation()).not.toBeNull();
|
||||
expect(controls.length).toBeGreaterThan(1);
|
||||
// No interactive control nested inside a role="button".
|
||||
for (const control of controls) {
|
||||
expect(control.closest('[role="button"]')).toBeNull();
|
||||
}
|
||||
|
||||
const editButton = row().querySelector(
|
||||
'.edit-btn'
|
||||
) as HTMLButtonElement;
|
||||
keydown(editButton, 'Enter');
|
||||
const space = keydown(editButton, ' ');
|
||||
editButton.click();
|
||||
|
||||
// The button's own activation edits; the row must neither open
|
||||
// the source nor swallow the Space the button relies on.
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
expect(space.defaultPrevented).toBe(false);
|
||||
expect(editSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens once for a pointer click anywhere on the row', () => {
|
||||
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
|
||||
|
||||
activation().click();
|
||||
(row().querySelector('.playlist-item') as HTMLElement).click();
|
||||
(row().querySelector('.playlist-icon') as HTMLElement).click();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('announces the selected source and ignores keys while busy', () => {
|
||||
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
|
||||
expect(activation().hasAttribute('aria-current')).toBe(false);
|
||||
expect(activation().hasAttribute('aria-disabled')).toBe(false);
|
||||
|
||||
fixture.componentRef.setInput('isSelected', true);
|
||||
fixture.componentRef.setInput('isRefreshing', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(activation().getAttribute('aria-current')).toBe('true');
|
||||
expect(activation().getAttribute('aria-disabled')).toBe('true');
|
||||
// Still a Tab stop, so focus is not lost when a refresh starts.
|
||||
expect(activation().getAttribute('tabindex')).toBe('0');
|
||||
|
||||
const space = keydown(activation(), ' ');
|
||||
keydown(activation(), 'Enter');
|
||||
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
expect(space.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the file name for the accessible name', () => {
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
fixture.componentInstance.item = {
|
||||
title: '',
|
||||
filename: 'channels.m3u',
|
||||
_id: 'file-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(activation().getAttribute('aria-label')).toBe(
|
||||
'channels.m3u'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a refresh action for file-backed M3U playlists', () => {
|
||||
fixture.destroy();
|
||||
runtime.supportsPlaylistRefresh = true;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Local Source',
|
||||
_id: 'local-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
filePath: '/tmp/local-source.m3u',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(nativeElement.querySelector('.refresh-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides file-backed M3U refresh without the refresh bridge', () => {
|
||||
fixture.destroy();
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Local Source',
|
||||
_id: 'local-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
filePath: '/tmp/local-source.m3u',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps URL-backed M3U refresh visible without the refresh bridge', () => {
|
||||
fixture.destroy();
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Remote Source',
|
||||
_id: 'remote-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Xtream refresh action only when the SQLite data source is available', () => {
|
||||
fixture.destroy();
|
||||
runtime.supportsXtreamSqliteDataSource = true;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Xtream Source',
|
||||
_id: 'xtream-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
|
||||
).not.toBeNull();
|
||||
|
||||
fixture.destroy();
|
||||
runtime.supportsXtreamSqliteDataSource = false;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Xtream Source',
|
||||
_id: 'xtream-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// OnPush: the status arrives after an await outside any template event,
|
||||
// so it must reach the view without a zone-triggered tick.
|
||||
it('renders the portal status that resolves after the first render', async () => {
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Xtream Source',
|
||||
_id: 'xtream-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
};
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
// Not English text, so the label must come from the keys.
|
||||
translate.setTranslation('en', {
|
||||
HOME: {
|
||||
PLAYLISTS: {
|
||||
PORTAL_STATUS: 'Portalstatus: {{status}}',
|
||||
PORTAL_STATUS_ACTIVE: 'aktiv',
|
||||
},
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
// The fixture renders on its own: a forced detectChanges() after the
|
||||
// await would hide a status that does not schedule a render.
|
||||
fixture.autoDetectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
const statusDot = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.status-dot'
|
||||
);
|
||||
expect(statusDot?.getAttribute('aria-label')).toBe(
|
||||
'Portalstatus: aktiv'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cancel and progress UI for long-running playlist actions', () => {
|
||||
fixture.componentRef.setInput('isDeleting', true);
|
||||
fixture.componentRef.setInput(
|
||||
'busyMessage',
|
||||
'Removing cached content...'
|
||||
);
|
||||
fixture.componentRef.setInput('busyProgress', 42);
|
||||
fixture.componentRef.setInput('canCancelBusyAction', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(
|
||||
nativeElement.querySelector('.busy-state__message')?.textContent
|
||||
).toContain('Removing cached content...');
|
||||
expect(
|
||||
nativeElement.querySelector('.busy-state__value')?.textContent
|
||||
).toContain('42%');
|
||||
expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ macAddress: '00:1A:79:00:00:01', url: 'http://portal.test' },
|
||||
'cast',
|
||||
true,
|
||||
],
|
||||
[{ macAddress: '00:1A:79:00:00:01' }, 'cast', false],
|
||||
[{ serverUrl: 'http://xtream.test' }, 'cloud', false],
|
||||
[{ url: 'http://list.test/playlist.m3u' }, 'link', true],
|
||||
[{}, 'description', true],
|
||||
])(
|
||||
'shows one provider icon for %o (%s) and keeps the auto-refresh badge: %s',
|
||||
(source, icon, autoRefreshBadge) => {
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
fixture.componentInstance.item = {
|
||||
title: 'Source',
|
||||
_id: 'source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: true,
|
||||
...source,
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
const row = fixture.nativeElement as HTMLElement;
|
||||
const icons = Array.from(
|
||||
row.querySelectorAll('.upload-type-icon'),
|
||||
(element) => element.textContent?.trim()
|
||||
);
|
||||
expect(icons).toEqual([icon]);
|
||||
expect(row.querySelector('.auto-refresh-indicator') !== null).toBe(
|
||||
autoRefreshBadge
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
PlaylistSourceIconKey,
|
||||
resolvePlaylistSourceIconKey,
|
||||
SOURCE_TYPE_ICONS,
|
||||
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 {
|
||||
Component,
|
||||
Input,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
PortalStatus,
|
||||
PortalStatusService,
|
||||
RuntimeCapabilitiesService,
|
||||
injectTranslationTick,
|
||||
} from '@iptvnator/services';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Translation keys for the status word in the portal status tooltip. */
|
||||
const PORTAL_STATUS_LABEL_KEYS: Record<PortalStatus, string> = {
|
||||
active: 'HOME.PLAYLISTS.PORTAL_STATUS_ACTIVE',
|
||||
inactive: 'HOME.PLAYLISTS.PORTAL_STATUS_INACTIVE',
|
||||
expired: 'HOME.PLAYLISTS.PORTAL_STATUS_EXPIRED',
|
||||
unavailable: 'HOME.PLAYLISTS.PORTAL_STATUS_UNAVAILABLE',
|
||||
checking: 'HOME.PLAYLISTS.PORTAL_STATUS_CHECKING',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-item',
|
||||
templateUrl: './playlist-item.component.html',
|
||||
styleUrls: ['./playlist-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
SourceHealthIndicatorComponent,
|
||||
DatePipe,
|
||||
DragDropModule,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatProgressBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class PlaylistItemComponent implements OnInit {
|
||||
@Input() item!: PlaylistMeta;
|
||||
readonly showActions = input(true);
|
||||
readonly isDraggable = input(false);
|
||||
readonly isSelected = input(false);
|
||||
readonly isRefreshing = input(false);
|
||||
readonly isDeleting = input(false);
|
||||
readonly busyMessage = input('');
|
||||
readonly busyProgress = input<number | null>(null);
|
||||
readonly canCancelBusyAction = input(false);
|
||||
readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting());
|
||||
|
||||
readonly editPlaylistClicked = output<PlaylistMeta>();
|
||||
readonly playlistClicked = output<string>();
|
||||
readonly refreshClicked = output<PlaylistMeta>();
|
||||
readonly removeClicked = output<string>();
|
||||
readonly cancelBusyActionClicked = output<void>();
|
||||
|
||||
readonly portalStatus = signal<PortalStatus>('unavailable');
|
||||
readonly portalStatusLabelKey = computed(
|
||||
() => PORTAL_STATUS_LABEL_KEYS[this.portalStatus()]
|
||||
);
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
readonly sourceIcons = SOURCE_TYPE_ICONS;
|
||||
|
||||
get sourceIconKey(): PlaylistSourceIconKey {
|
||||
return resolvePlaylistSourceIconKey(this.item);
|
||||
}
|
||||
|
||||
/** Without source health, Xtream rows badge the portal status instead. */
|
||||
get showsPortalStatusDot(): boolean {
|
||||
return (
|
||||
this.sourceIconKey === 'xtream' &&
|
||||
!this.runtime.supportsSourceHealth
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-refresh re-fetches a URL or a local file, so any row with a URL
|
||||
* keeps the badge, whichever provider icon it shows.
|
||||
*/
|
||||
get showsAutoRefresh(): boolean {
|
||||
return (
|
||||
!!this.item.autoRefresh &&
|
||||
(!!this.item.url || this.sourceIconKey === 'm3u-local')
|
||||
);
|
||||
}
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly supportsPlaylistRefresh = this.runtime.supportsPlaylistRefresh;
|
||||
readonly supportsXtreamSqliteDataSource =
|
||||
this.runtime.supportsXtreamSqliteDataSource;
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
private readonly healthInjector = inject(Injector);
|
||||
readonly sourceHealthType = sourceHealthType;
|
||||
recheckSource(): void {
|
||||
void this.healthInjector.get(SourceHealthService).recheck(this.item);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
if (!this.runtime.supportsSourceHealth) await this.checkPortalStatus();
|
||||
}
|
||||
|
||||
private async checkPortalStatus() {
|
||||
if (this.item.serverUrl && this.item.username && this.item.password) {
|
||||
this.portalStatus.set(
|
||||
await this.portalStatusService.checkPortalStatus(
|
||||
this.item.serverUrl,
|
||||
this.item.username,
|
||||
this.item.password
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getStatusClass(): string {
|
||||
return this.portalStatusService.getStatusClass(this.portalStatus());
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
return this.portalStatusService.getStatusIcon(this.portalStatus());
|
||||
}
|
||||
|
||||
onPlaylistClick(): void {
|
||||
if (this.isBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistClicked.emit(this.item._id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter/Space open the source like a click on the row; Space also
|
||||
* prevents the page scroll. The drag handle and the actions are siblings
|
||||
* of the activation element, never descendants, so their keys cannot
|
||||
* reach this handler.
|
||||
*/
|
||||
onActivationKey(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.onPlaylistClick();
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,53 @@
|
||||
@if (playlistsData(); as data) {
|
||||
@if (data.playlists.length === 0) {
|
||||
@if (data.totalCount === 0) {
|
||||
<!-- Welcome state: no playlists at all -->
|
||||
<app-empty-state
|
||||
[type]="'welcome-sources'"
|
||||
[showElectronOnlyValueProps]="isElectron"
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
} @else {
|
||||
<!-- No results state: search/filter returned nothing -->
|
||||
<app-empty-state [type]="'no-results'" />
|
||||
}
|
||||
} @else {
|
||||
<div
|
||||
class="playlists-list"
|
||||
cdkDropList
|
||||
[cdkDropListDisabled]="!isCustomSortActive()"
|
||||
(cdkDropListDropped)="drop($event, data.playlists)"
|
||||
>
|
||||
@for (
|
||||
item of data.playlists;
|
||||
track item._id;
|
||||
let last = $last
|
||||
) {
|
||||
<app-playlist-item
|
||||
[item]="item"
|
||||
[isSelected]="activePlaylistId() === item._id"
|
||||
[showActions]="!sidebarMode()"
|
||||
[isDraggable]="isCustomSortActive()"
|
||||
[isRefreshing]="isRefreshPending(item._id)"
|
||||
[isDeleting]="isDeletePending(item._id)"
|
||||
[busyMessage]="getBusyMessage(item)"
|
||||
[busyProgress]="getBusyProgress(item._id)"
|
||||
[canCancelBusyAction]="canCancelBusyOperation(item)"
|
||||
(editPlaylistClicked)="openInfoDialog($event)"
|
||||
(playlistClicked)="getPlaylist(item)"
|
||||
(refreshClicked)="refreshPlaylist($event)"
|
||||
(removeClicked)="removeClicked(item)"
|
||||
(cancelBusyActionClicked)="cancelBusyOperation(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (playlists().length === 0) {
|
||||
@if (allPlaylistsLoaded()) {
|
||||
<app-empty-state
|
||||
[type]="'welcome-sources'"
|
||||
[showElectronOnlyValueProps]="isElectron"
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
}
|
||||
} @else if (visiblePlaylists().length === 0) {
|
||||
<div class="sources-empty no-results-state" role="status">
|
||||
<p class="sources-empty__message">
|
||||
{{ 'WORKSPACE.SOURCES.NO_MATCHES' | translate }}
|
||||
</p>
|
||||
@if (hasFilters()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="sources-empty__reset"
|
||||
(click)="resetFilters()"
|
||||
>
|
||||
<mat-icon>filter_alt_off</mat-icon>
|
||||
{{ 'WORKSPACE.SOURCES.RESET_FILTERS' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="sources-list"
|
||||
cdkDropList
|
||||
[cdkDropListDisabled]="!isCustomSort()"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
>
|
||||
@for (item of visiblePlaylists(); track item._id) {
|
||||
<app-source-row
|
||||
[playlist]="item"
|
||||
[current]="activePlaylistId() === item._id"
|
||||
[draggable]="isCustomSort()"
|
||||
[canRefresh]="canRefresh(item)"
|
||||
[refreshing]="isRefreshing(item)"
|
||||
[removable]="isRemovable(item)"
|
||||
[deleting]="removal.progress.isDeleting(item._id)"
|
||||
[busyMessage]="busyMessage(item)"
|
||||
[busyProgress]="busyProgress(item)"
|
||||
[canCancelBusy]="canCancelBusy(item)"
|
||||
(activated)="open(item)"
|
||||
(refreshRequested)="refresh.refresh(item)"
|
||||
(editRequested)="edit(item)"
|
||||
(removeRequested)="remove(item)"
|
||||
(cancelBusyRequested)="cancelBusy(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
@use '../../../../../../ui/styles/app-controls' as controls;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -15,35 +16,40 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.playlists-list {
|
||||
padding: 0 6px;
|
||||
.sources-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#skeleton-container {
|
||||
padding-top: 10px;
|
||||
width: 100%;
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
padding: 0 20px;
|
||||
height: 72px;
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
padding-left: 6px;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
// A 1px line between rows, hidden around the hovered row so its fill
|
||||
// reads as one shape.
|
||||
app-source-row + app-source-row {
|
||||
border-top: 1px solid var(--app-widget-header-border);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.favorites-icon {
|
||||
padding-left: 0 !important;
|
||||
display: none;
|
||||
}
|
||||
app-source-row:hover,
|
||||
app-source-row:hover + app-source-row,
|
||||
app-source-row:focus-within,
|
||||
app-source-row:focus-within + app-source-row {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.sources-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 64px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sources-empty__message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.sources-empty__reset {
|
||||
@include controls.ghost-button(sm);
|
||||
}
|
||||
+291
-668
File diff suppressed because it is too large.
Load diff
@@ -6,97 +6,86 @@ import {
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { BehaviorSubject, combineLatest, map } from 'rxjs';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import {
|
||||
DatabaseService,
|
||||
DataService,
|
||||
DbOperationEvent,
|
||||
isDbAbortError,
|
||||
PlaylistDeleteActionService,
|
||||
PlaylistRefreshService,
|
||||
keepSourcesChecked,
|
||||
SourceStatusService,
|
||||
sourceStatusBucket,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
filterSources,
|
||||
orderFailingFirst,
|
||||
PlaylistContextFacade,
|
||||
replacementAfterRemoval,
|
||||
SourceBucketOf,
|
||||
sourceSortName,
|
||||
SourcesViewStateService,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
SortBy,
|
||||
SortService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
foldSearchText,
|
||||
PLAYLIST_UPDATE,
|
||||
PlaylistMeta,
|
||||
PlaylistRefreshEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
|
||||
import {
|
||||
XtreamRefreshFlowService,
|
||||
type XtreamRefreshProgressReporter,
|
||||
} from '../xtream-refresh-flow.service';
|
||||
import { EmptyStateComponent } from './empty-state/empty-state.component';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import type { PlaylistType } from '../add-playlist-menu/playlist-type';
|
||||
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
|
||||
import { SourceRemovalService } from '../source-removal/source-removal.service';
|
||||
import { SourceRowComponent } from '../source-row/source-row.component';
|
||||
import { EmptyStateComponent } from './empty-state/empty-state.component';
|
||||
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
|
||||
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
|
||||
|
||||
type PlaylistBusyOperation = {
|
||||
current?: number;
|
||||
operation: string;
|
||||
operationId?: string;
|
||||
phase?: string;
|
||||
status: DbOperationEvent['status'];
|
||||
total?: number;
|
||||
};
|
||||
import { SourceListRefreshService } from './source-list-refresh.service';
|
||||
|
||||
/**
|
||||
* The Sources page list: every source that passes the side list's type and
|
||||
* status filters and the header search, in the chosen sort order, as
|
||||
* `app-source-row`s. Sources being deleted (Undo pending) are left out.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-recent-playlists',
|
||||
templateUrl: './recent-playlists.component.html',
|
||||
styleUrls: ['./recent-playlists.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [SourceListRefreshService],
|
||||
imports: [
|
||||
DragDropModule,
|
||||
EmptyStateComponent,
|
||||
MatInputModule,
|
||||
PlaylistItemComponent,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
SourceRowComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class RecentPlaylistsComponent {
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly playlistRefreshService = inject(PlaylistRefreshService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly sortService = inject(SortService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
|
||||
private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService);
|
||||
private readonly statusService = inject(SourceStatusService);
|
||||
private readonly viewState = inject(SourcesViewStateService);
|
||||
readonly removal = inject(SourceRemovalService);
|
||||
readonly refresh = inject(SourceListRefreshService);
|
||||
private readonly refreshAction = inject(PlaylistRefreshActionService);
|
||||
private readonly activity = inject(SourceActivityService);
|
||||
|
||||
readonly sidebarMode = input(false);
|
||||
readonly searchQueryInput = input<string>('');
|
||||
readonly playlistClicked = output<string>();
|
||||
readonly addPlaylistClicked = output<PlaylistType | undefined>();
|
||||
@@ -105,115 +94,96 @@ export class RecentPlaylistsComponent {
|
||||
return this.runtime.isElectron;
|
||||
}
|
||||
|
||||
get supportsPlaylistRefresh(): boolean {
|
||||
return this.runtime.supportsPlaylistRefresh;
|
||||
}
|
||||
|
||||
get supportsXtreamSqliteDataSource(): boolean {
|
||||
return this.runtime.supportsXtreamSqliteDataSource;
|
||||
}
|
||||
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
private readonly allPlaylists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
|
||||
|
||||
private readonly currentSortOptions = toSignal(
|
||||
this.sortService.getSortOptions(),
|
||||
{ requireSync: true }
|
||||
private readonly sortOptions = toSignal(this.sortService.getSortOptions(), {
|
||||
requireSync: true,
|
||||
});
|
||||
readonly isCustomSort = computed(
|
||||
() => this.sortOptions().by === SortBy.CUSTOM
|
||||
);
|
||||
|
||||
readonly isCustomSortActive = () =>
|
||||
this.currentSortOptions().by === SortBy.CUSTOM;
|
||||
|
||||
readonly searchQuery = new BehaviorSubject<string>('');
|
||||
readonly pendingDeletionIds = signal<Set<string>>(new Set());
|
||||
readonly pendingRefreshIds = signal<Set<string>>(new Set());
|
||||
readonly busyOperations = signal<Map<string, PlaylistBusyOperation>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Update searchQuery when input changes
|
||||
effect(() => {
|
||||
this.searchQuery.next(this.searchQueryInput());
|
||||
});
|
||||
}
|
||||
|
||||
readonly playlistsData$ = combineLatest([
|
||||
this.store.select(selectAllPlaylistsMeta),
|
||||
this.searchQuery,
|
||||
this.store.select(selectActiveTypeFilters),
|
||||
this.sortService.getSortOptions(),
|
||||
]).pipe(
|
||||
map(([playlists, searchQuery, filters, sortOptions]) => {
|
||||
const filteredPlaylists = playlists
|
||||
.filter((item) => {
|
||||
const isStalkerFilter =
|
||||
item.macAddress && filters.includes('stalker');
|
||||
const isXtreamFilter =
|
||||
item.username &&
|
||||
item.password &&
|
||||
item.serverUrl &&
|
||||
filters.includes('xtream');
|
||||
const isM3uFilter =
|
||||
!item.username &&
|
||||
!item.password &&
|
||||
!item.serverUrl &&
|
||||
!item.macAddress &&
|
||||
filters.includes('m3u');
|
||||
|
||||
return (
|
||||
(isStalkerFilter && filters.includes('stalker')) ||
|
||||
(isXtreamFilter && filters.includes('xtream')) ||
|
||||
(isM3uFilter && filters.includes('m3u'))
|
||||
);
|
||||
})
|
||||
.filter((item) =>
|
||||
foldSearchText(item.title || '').includes(
|
||||
foldSearchText(searchQuery)
|
||||
)
|
||||
);
|
||||
|
||||
// Apply sorting using the SortService
|
||||
const sortedPlaylists = this.sortService.sortPlaylists(
|
||||
filteredPlaylists,
|
||||
sortOptions
|
||||
);
|
||||
|
||||
return {
|
||||
playlists: sortedPlaylists,
|
||||
totalCount: playlists.length,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
readonly playlistsData = toSignal(this.playlistsData$, {
|
||||
initialValue: {
|
||||
playlists: [] as PlaylistMeta[],
|
||||
totalCount: 0,
|
||||
},
|
||||
readonly hasFilters = computed(() => {
|
||||
const filter = this.viewState.filter();
|
||||
return filter.type !== 'all' || filter.status !== 'all';
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens the details dialog with the information about the provided playlist
|
||||
* @param data selected playlist
|
||||
*/
|
||||
openInfoDialog(data: PlaylistMeta): void {
|
||||
this.dialog.open(PlaylistInfoComponent, {
|
||||
data,
|
||||
});
|
||||
/** Every source except those whose delete is pending. */
|
||||
readonly playlists = computed(() => {
|
||||
const pending = this.removal.pendingIds();
|
||||
return this.allPlaylists().filter(
|
||||
(playlist) => !pending.has(playlist._id)
|
||||
);
|
||||
});
|
||||
|
||||
readonly visiblePlaylists = computed(() => {
|
||||
const bucketOf: SourceBucketOf = (playlist) => {
|
||||
const status = this.statusService.statusOf(playlist);
|
||||
return status ? sourceStatusBucket(status.reachability) : null;
|
||||
};
|
||||
const options = this.sortOptions();
|
||||
const filter = this.viewState.filter();
|
||||
const matching = filterSources(
|
||||
this.playlists(),
|
||||
this.statusService.supportsStatusFilter
|
||||
? filter
|
||||
: { ...filter, status: 'all' },
|
||||
this.searchQueryInput(),
|
||||
bucketOf
|
||||
);
|
||||
// A running delete keeps its row, whatever the filters: its
|
||||
// progress and Cancel live there (one started from the switcher
|
||||
// has nowhere else to show).
|
||||
const filtered = this.playlists().filter(
|
||||
(playlist) =>
|
||||
matching.includes(playlist) ||
|
||||
this.removal.progress.isDeleting(playlist._id)
|
||||
);
|
||||
const sorted = this.sortService.sortPlaylists(
|
||||
filtered,
|
||||
options,
|
||||
sourceSortName
|
||||
);
|
||||
return options.failingFirst && options.by !== SortBy.CUSTOM
|
||||
? orderFailingFirst(sorted, bucketOf)
|
||||
: sorted;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// A status filter hides the sources it has no result for, so the
|
||||
// checks follow the library rather than the rows on screen.
|
||||
keepSourcesChecked(() => this.playlists());
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.viewState.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop event handler - applies the new sort order to the playlists array
|
||||
* @param event drop event
|
||||
* Applies a drag in custom order to the whole library: the shown rows
|
||||
* trade places among the slots they occupy, and sources a filter hides
|
||||
* keep theirs, so every position stays unique.
|
||||
*/
|
||||
drop(event: CdkDragDrop<PlaylistMeta[]>, playlists: PlaylistMeta[]): void {
|
||||
moveItemInArray(playlists, event.previousIndex, event.currentIndex);
|
||||
drop(event: CdkDragDrop<PlaylistMeta[]>): void {
|
||||
const visible = [...this.visiblePlaylists()];
|
||||
moveItemInArray(visible, event.previousIndex, event.currentIndex);
|
||||
const visibleIds = new Set(visible.map((item) => item._id));
|
||||
const ordered = this.sortService.sortPlaylists(
|
||||
this.allPlaylists(),
|
||||
this.sortOptions(),
|
||||
sourceSortName
|
||||
);
|
||||
let shown = 0;
|
||||
const merged = ordered.map((item) =>
|
||||
visibleIds.has(item._id) ? visible[shown++] : item
|
||||
);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistPositions({
|
||||
positionUpdates: playlists.map((item, index) => ({
|
||||
positionUpdates: merged.map((item, index) => ({
|
||||
id: item._id,
|
||||
changes: { position: index },
|
||||
})),
|
||||
@@ -221,475 +191,100 @@ export class RecentPlaylistsComponent {
|
||||
);
|
||||
}
|
||||
|
||||
onAddPlaylist(type?: PlaylistType) {
|
||||
open(playlist: PlaylistMeta): void {
|
||||
if (playlist.serverUrl) {
|
||||
void this.router.navigate(['/workspace', 'xtreams', playlist._id]);
|
||||
} else if (playlist.macAddress) {
|
||||
void this.router.navigate(['/workspace', 'stalker', playlist._id]);
|
||||
} else {
|
||||
void this.router.navigate([
|
||||
'/workspace',
|
||||
'playlists',
|
||||
playlist._id,
|
||||
]);
|
||||
this.playlistClicked.emit(playlist._id);
|
||||
}
|
||||
}
|
||||
|
||||
edit(playlist: PlaylistMeta): void {
|
||||
this.dialog.open(PlaylistInfoComponent, { data: playlist });
|
||||
}
|
||||
|
||||
/** Deletes with Undo; the active source hands over to its neighbour. */
|
||||
remove(playlist: PlaylistMeta): void {
|
||||
if (this.refresh.isRefreshing(playlist._id)) {
|
||||
return;
|
||||
}
|
||||
const next = replacementAfterRemoval(
|
||||
this.visiblePlaylists(),
|
||||
playlist._id,
|
||||
(row) => !this.removal.canTakeOver(row._id)
|
||||
);
|
||||
this.removal.remove(playlist, next);
|
||||
}
|
||||
|
||||
/** Refreshing from this page, or from the playlist switcher. */
|
||||
isRefreshing(item: PlaylistMeta): boolean {
|
||||
return (
|
||||
this.refresh.isRefreshing(item._id) ||
|
||||
this.refreshAction.isSourceBusy(item._id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh is offered while it can run, and kept (with its spinner)
|
||||
* while this source refreshes. Held by anything else (the startup
|
||||
* auto-refresh, an import, a delete), refresh() would refuse it.
|
||||
*/
|
||||
canRefresh(item: PlaylistMeta): boolean {
|
||||
return (
|
||||
this.refresh.canRefresh(item) &&
|
||||
(this.isRefreshing(item) || !this.activity.isBusy(item._id))
|
||||
);
|
||||
}
|
||||
|
||||
/** Nothing else (a refresh, the startup auto-refresh) holds it. */
|
||||
isRemovable(item: PlaylistMeta): boolean {
|
||||
return !this.activity.isBusy(item._id);
|
||||
}
|
||||
|
||||
busyMessage(item: PlaylistMeta): string {
|
||||
return (
|
||||
this.removal.progress.message(item._id) ||
|
||||
this.refresh.message(item)
|
||||
);
|
||||
}
|
||||
|
||||
busyProgress(item: PlaylistMeta): number | null {
|
||||
return this.removal.progress.isDeleting(item._id)
|
||||
? this.removal.progress.progress(item._id)
|
||||
: this.refresh.progress(item._id);
|
||||
}
|
||||
|
||||
canCancelBusy(item: PlaylistMeta): boolean {
|
||||
return (
|
||||
this.removal.progress.canCancel(item._id) ||
|
||||
this.refresh.canCancel(item)
|
||||
);
|
||||
}
|
||||
|
||||
cancelBusy(item: PlaylistMeta): void {
|
||||
if (this.removal.progress.isDeleting(item._id)) {
|
||||
void this.removal.progress.cancel(item._id);
|
||||
} else {
|
||||
void this.refresh.cancel(item);
|
||||
}
|
||||
}
|
||||
|
||||
onAddPlaylist(type?: PlaylistType): void {
|
||||
this.addPlaylistClicked.emit(type);
|
||||
}
|
||||
|
||||
getPlaylist(playlistMeta: PlaylistMeta): void {
|
||||
if (playlistMeta.serverUrl) {
|
||||
this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]);
|
||||
} else if (playlistMeta.macAddress) {
|
||||
this.router.navigate(['/workspace', 'stalker', playlistMeta._id]);
|
||||
} else {
|
||||
this.router.navigate(['/workspace', 'playlists', playlistMeta._id]);
|
||||
this.playlistClicked.emit(playlistMeta._id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers on remove click
|
||||
* @param playlistId playlist id to remove
|
||||
*/
|
||||
removeClicked(item: PlaylistMeta): void {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
|
||||
tone: 'destructive',
|
||||
onConfirm: () => {
|
||||
this.removePlaylist(item);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the provided playlist from the database
|
||||
* @param playlistId playlist id to remove
|
||||
*/
|
||||
async removePlaylist(item: PlaylistMeta) {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPendingDeletion(item._id, true);
|
||||
try {
|
||||
const deleted = await this.playlistDeleteAction.deletePlaylist(
|
||||
item,
|
||||
{
|
||||
onEvent: (event) =>
|
||||
this.updateBusyOperation(item._id, event),
|
||||
}
|
||||
);
|
||||
if (deleted) {
|
||||
if (item.serverUrl) {
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.playlistRemovalCommitted({
|
||||
playlistId: item._id,
|
||||
})
|
||||
),
|
||||
() => ({ items: 1 })
|
||||
);
|
||||
} else {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.playlistRemovalCommitted({
|
||||
playlistId: item._id,
|
||||
})
|
||||
);
|
||||
}
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
|
||||
),
|
||||
undefined,
|
||||
{
|
||||
duration: 2000,
|
||||
}
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.clearBusyOperation(item._id);
|
||||
this.setPendingDeletion(item._id, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an IPC event with the playlist details to the main process to trigger the refresh operation
|
||||
* @param item playlist to update
|
||||
*/
|
||||
refreshPlaylist(item: PlaylistMeta) {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.serverUrl && this.supportsXtreamSqliteDataSource) {
|
||||
// For Xtream playlists, delete and re-import
|
||||
this.refreshXtreamPlaylist(item);
|
||||
} else if (
|
||||
this.supportsPlaylistRefresh &&
|
||||
(item.url || item.filePath)
|
||||
) {
|
||||
void this.refreshM3uPlaylist(item);
|
||||
} else if (item.url) {
|
||||
// Browser/PWA URL refresh uses the PWA data service path.
|
||||
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
...(item.userAgent ? { userAgent: item.userAgent } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Xtream playlist by deleting all data and re-importing from remote
|
||||
* @param item Xtream playlist to refresh
|
||||
*/
|
||||
refreshXtreamPlaylist(item: PlaylistMeta): void {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.xtreamRefreshFlow.confirmAndRefresh(
|
||||
item,
|
||||
this.xtreamRefreshReporter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the shared destructive refresh into this page's per-row busy
|
||||
* indicators, which are keyed by playlist and shared with deletion — hence
|
||||
* the same `updateBusyOperation`/`clearBusyOperation` pair used there.
|
||||
*/
|
||||
private readonly xtreamRefreshReporter: XtreamRefreshProgressReporter = {
|
||||
isBusy: (playlistId) =>
|
||||
this.isDeletePending(playlistId) ||
|
||||
this.isRefreshPending(playlistId),
|
||||
begin: ({ playlistId }) => this.setPendingRefresh(playlistId, true),
|
||||
report: ({ playlistId }, event) =>
|
||||
this.updateBusyOperation(playlistId, event),
|
||||
end: ({ playlistId }) => {
|
||||
this.clearBusyOperation(playlistId);
|
||||
this.setPendingRefresh(playlistId, false);
|
||||
},
|
||||
};
|
||||
|
||||
private async refreshM3uPlaylist(item: PlaylistMeta): Promise<void> {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPendingRefresh(item._id, true);
|
||||
const operationId =
|
||||
this.databaseService.createOperationId('playlist-refresh');
|
||||
|
||||
try {
|
||||
const refreshedPlaylist =
|
||||
await this.playlistRefreshService.refreshPlaylist(
|
||||
{
|
||||
operationId,
|
||||
playlistId: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
...(item.userAgent
|
||||
? { userAgent: item.userAgent }
|
||||
: {}),
|
||||
filePath: item.filePath,
|
||||
},
|
||||
{
|
||||
onEvent: (event) =>
|
||||
this.updateBusyOperation(
|
||||
item._id,
|
||||
this.toPlaylistRefreshBusyEvent(event)
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
this.updateBusyOperation(item._id, {
|
||||
operationId,
|
||||
operation: 'playlist-refresh',
|
||||
playlistId: item._id,
|
||||
phase: 'saving',
|
||||
status: 'progress',
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: {
|
||||
...refreshedPlaylist,
|
||||
_id: item._id,
|
||||
},
|
||||
playlistId: item._id,
|
||||
refreshEpg: true,
|
||||
operationId,
|
||||
})
|
||||
);
|
||||
|
||||
this.clearBusyOperation(item._id);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
console.error('Error refreshing playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.getPlaylistRefreshErrorMessage(error, item),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.clearBusyOperation(item._id);
|
||||
this.setPendingRefresh(item._id, false);
|
||||
}
|
||||
}
|
||||
|
||||
isDeletePending(playlistId: string): boolean {
|
||||
return this.pendingDeletionIds().has(playlistId);
|
||||
return this.removal.isPending(playlistId);
|
||||
}
|
||||
|
||||
isRefreshPending(playlistId: string): boolean {
|
||||
return this.pendingRefreshIds().has(playlistId);
|
||||
}
|
||||
|
||||
getBusyMessage(item: PlaylistMeta): string {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
if (!operation) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (operation.operation) {
|
||||
case 'delete-playlist':
|
||||
return this.translateDeletePhase(operation.phase);
|
||||
case 'delete-xtream-content':
|
||||
return this.translateRefreshPhase(operation.phase);
|
||||
case 'playlist-refresh':
|
||||
return this.translatePlaylistRefreshPhase(operation.phase);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getBusyProgress(playlistId: string): number | null {
|
||||
const operation = this.busyOperations().get(playlistId);
|
||||
if (
|
||||
!operation ||
|
||||
operation.current == null ||
|
||||
operation.total == null ||
|
||||
operation.total <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((operation.current / operation.total) * 100)
|
||||
);
|
||||
}
|
||||
|
||||
canCancelBusyOperation(item: PlaylistMeta): boolean {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
if (!operation?.operationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (operation.operation === 'playlist-refresh') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(item.serverUrl);
|
||||
}
|
||||
|
||||
async cancelBusyOperation(item: PlaylistMeta): Promise<void> {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
if (!operation?.operationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.operation === 'playlist-refresh') {
|
||||
await this.playlistRefreshService.cancelRefresh(
|
||||
operation.operationId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.databaseService.cancelOperation(operation.operationId);
|
||||
}
|
||||
|
||||
private setPendingDeletion(playlistId: string, isPending: boolean): void {
|
||||
this.pendingDeletionIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (isPending) {
|
||||
next.add(playlistId);
|
||||
} else {
|
||||
next.delete(playlistId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private setPendingRefresh(playlistId: string, isPending: boolean): void {
|
||||
this.pendingRefreshIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (isPending) {
|
||||
next.add(playlistId);
|
||||
} else {
|
||||
next.delete(playlistId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private updateBusyOperation(
|
||||
playlistId: string,
|
||||
event: DbOperationEvent
|
||||
): void {
|
||||
this.busyOperations.update((current) => {
|
||||
const next = new Map(current);
|
||||
|
||||
if (
|
||||
event.status === 'completed' ||
|
||||
event.status === 'cancelled' ||
|
||||
event.status === 'error'
|
||||
) {
|
||||
next.delete(playlistId);
|
||||
return next;
|
||||
}
|
||||
|
||||
next.set(playlistId, {
|
||||
operation: event.operation,
|
||||
operationId: event.operationId,
|
||||
phase: event.phase,
|
||||
current: event.current,
|
||||
total: event.total,
|
||||
status: event.status,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private clearBusyOperation(playlistId: string): void {
|
||||
this.busyOperations.update((current) => {
|
||||
const next = new Map(current);
|
||||
next.delete(playlistId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private toPlaylistRefreshBusyEvent(
|
||||
event: PlaylistRefreshEvent
|
||||
): DbOperationEvent {
|
||||
return {
|
||||
operationId: event.operationId,
|
||||
operation: 'playlist-refresh',
|
||||
playlistId: event.playlistId,
|
||||
phase: event.phase,
|
||||
status: event.status,
|
||||
error: event.error,
|
||||
};
|
||||
}
|
||||
|
||||
private translateDeletePhase(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'deleting-favorites':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES'
|
||||
);
|
||||
case 'deleting-recently-viewed':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT'
|
||||
);
|
||||
case 'deleting-playback-positions':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS'
|
||||
);
|
||||
case 'deleting-downloads':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS'
|
||||
);
|
||||
case 'deleting-content':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
case 'deleting-categories':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES'
|
||||
);
|
||||
case 'deleting-playlist':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST'
|
||||
);
|
||||
default:
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private translateRefreshPhase(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'collecting-user-data':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
|
||||
);
|
||||
case 'deleting-content':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
case 'deleting-categories':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES'
|
||||
);
|
||||
default:
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private translatePlaylistRefreshPhase(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'fetching':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_FETCHING'
|
||||
);
|
||||
case 'reading-file':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_READING_FILE'
|
||||
);
|
||||
case 'parsing':
|
||||
return this.translate.instant('HOME.PLAYLISTS.REFRESH_PARSING');
|
||||
case 'saving':
|
||||
return this.translate.instant('HOME.PLAYLISTS.REFRESH_SAVING');
|
||||
default:
|
||||
return this.translate.instant('HOME.PLAYLISTS.REFRESH');
|
||||
}
|
||||
}
|
||||
|
||||
private getPlaylistRefreshErrorMessage(
|
||||
error: unknown,
|
||||
item: PlaylistMeta
|
||||
): string {
|
||||
if (item.filePath) {
|
||||
const message = String(
|
||||
(error as { message?: string })?.message ?? error
|
||||
);
|
||||
|
||||
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
|
||||
return this.refresh.isRefreshing(playlistId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import {
|
||||
DatabaseService,
|
||||
DataService,
|
||||
DbOperationEvent,
|
||||
PlaybackPositionService,
|
||||
PlaylistDeleteActionService,
|
||||
PlaylistRefreshService,
|
||||
RuntimeCapabilitiesService,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
SortService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
PLAYLIST_UPDATE,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { SourceListRefreshService } from './source-list-refresh.service';
|
||||
|
||||
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
|
||||
function setPerformanceHook(
|
||||
hook: ((event: RendererPerformancePhaseEvent) => void) | null
|
||||
): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[performanceHookSymbol];
|
||||
} else {
|
||||
target[performanceHookSymbol] = hook;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error('Cancelled');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function createPlaylistMeta(
|
||||
overrides: Partial<PlaylistMeta> = {}
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: 'playlist-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 0,
|
||||
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
serverUrl: 'http://localhost:8080',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('SourceListRefreshService', () => {
|
||||
let component: SourceListRefreshService;
|
||||
let databaseService: {
|
||||
cancelOperation: jest.Mock;
|
||||
createOperationId: jest.Mock;
|
||||
deletePlaylist: jest.Mock;
|
||||
deleteXtreamPlaylistContent: jest.Mock;
|
||||
updateXtreamPlaylistDetails: jest.Mock;
|
||||
};
|
||||
let dialogService: {
|
||||
openConfirmDialog: jest.Mock;
|
||||
};
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock;
|
||||
};
|
||||
let playbackPositionService: {
|
||||
getAllPlaybackPositions: jest.Mock;
|
||||
};
|
||||
let playlistDeleteAction: {
|
||||
deletePlaylist: jest.Mock;
|
||||
};
|
||||
let playlistRefreshService: {
|
||||
cancelRefresh: jest.Mock;
|
||||
refreshPlaylist: jest.Mock;
|
||||
};
|
||||
let runtime: {
|
||||
isElectron: boolean;
|
||||
supportsPlaylistRefresh: boolean;
|
||||
supportsXtreamSqliteDataSource: boolean;
|
||||
};
|
||||
let router: {
|
||||
navigate: jest.Mock;
|
||||
};
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let store: MockStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
databaseService = {
|
||||
cancelOperation: jest.fn().mockResolvedValue(true),
|
||||
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
|
||||
deletePlaylist: jest.fn(),
|
||||
deleteXtreamPlaylistContent: jest.fn(),
|
||||
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
playbackPositionService = {
|
||||
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
playlistDeleteAction = {
|
||||
deletePlaylist: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
playlistRefreshService = {
|
||||
cancelRefresh: jest.fn().mockResolvedValue(undefined),
|
||||
refreshPlaylist: jest.fn().mockResolvedValue({
|
||||
id: 'playlist-1',
|
||||
items: [],
|
||||
}),
|
||||
};
|
||||
runtime = {
|
||||
isElectron: true,
|
||||
supportsPlaylistRefresh: true,
|
||||
supportsXtreamSqliteDataSource: true,
|
||||
};
|
||||
router = {
|
||||
navigate: jest.fn(),
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SourceListRefreshService,
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectPlaylistsLoadingFlag, value: true },
|
||||
{
|
||||
selector: selectAllPlaylistsMeta,
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: dialogService,
|
||||
},
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: dataService,
|
||||
},
|
||||
{
|
||||
provide: PlaybackPositionService,
|
||||
useValue: playbackPositionService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistDeleteActionService,
|
||||
useValue: playlistDeleteAction,
|
||||
},
|
||||
{
|
||||
provide: PlaylistRefreshService,
|
||||
useValue: playlistRefreshService,
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: runtime,
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
resolvedPlaylistId: signal<string | null>(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: SortService,
|
||||
useValue: {
|
||||
getSortOptions: jest.fn(() =>
|
||||
of({
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.DESC,
|
||||
})
|
||||
),
|
||||
sortPlaylists: jest.fn(
|
||||
(playlists: PlaylistMeta[]) => playlists
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
component = TestBed.inject(SourceListRefreshService);
|
||||
store = TestBed.inject(MockStore);
|
||||
jest.spyOn(store, 'dispatch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPerformanceHook(null);
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-refresh-1' });
|
||||
const refresh = createDeferred<{
|
||||
success: boolean;
|
||||
favorites: Array<{ xtreamId: number; contentType: string }>;
|
||||
recentlyViewed: Array<{
|
||||
xtreamId: number;
|
||||
contentType: string;
|
||||
viewedAt: string;
|
||||
}>;
|
||||
hiddenCategories: Array<{
|
||||
xtreamId: number;
|
||||
categoryType: string;
|
||||
}>;
|
||||
}>();
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
// The shared flow awaits the connectivity-guard reset before it starts
|
||||
// deleting, so the delete is several ticks away and counting them is
|
||||
// fragile. The mock fires onEvent synchronously and the component
|
||||
// applies it synchronously, so resolving this deferred inside the mock
|
||||
// is a deterministic "the busy row has been updated" signal.
|
||||
const workerEventDelivered = createDeferred<void>();
|
||||
|
||||
dialogService.openConfirmDialog.mockImplementation(
|
||||
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
|
||||
confirmPromise = onConfirm?.();
|
||||
}
|
||||
);
|
||||
|
||||
databaseService.deleteXtreamPlaylistContent.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
options?: {
|
||||
onEvent?: (event: DbOperationEvent) => void;
|
||||
operationId?: string;
|
||||
}
|
||||
) => {
|
||||
options?.onEvent?.({
|
||||
operation: 'delete-xtream-content',
|
||||
operationId: 'xtream-refresh-op',
|
||||
status: 'progress',
|
||||
phase: 'collecting-user-data',
|
||||
current: 1,
|
||||
total: 4,
|
||||
});
|
||||
workerEventDelivered.resolve();
|
||||
|
||||
return refresh.promise;
|
||||
}
|
||||
);
|
||||
|
||||
component.refresh(item);
|
||||
await workerEventDelivered.promise;
|
||||
|
||||
expect(component.isRefreshing(item._id)).toBe(true);
|
||||
expect(component.message(item)).toBe(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
|
||||
);
|
||||
expect(component.progress(item._id)).toBe(25);
|
||||
expect(component.canCancel(item)).toBe(true);
|
||||
|
||||
await component.cancel(item);
|
||||
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
|
||||
'xtream-refresh-op'
|
||||
);
|
||||
|
||||
refresh.reject(createAbortError());
|
||||
await confirmPromise;
|
||||
|
||||
expect(component.isRefreshing(item._id)).toBe(false);
|
||||
expect(component.progress(item._id)).toBeNull();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
const executionOrder: string[] = [];
|
||||
const performanceEvents: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => {
|
||||
performanceEvents.push(event);
|
||||
executionOrder.push(event.boundary);
|
||||
});
|
||||
const dateNowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(1712145600000);
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
const setItemSpy = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(function (
|
||||
this: Storage,
|
||||
key: string,
|
||||
value: string
|
||||
) {
|
||||
executionOrder.push('setItem');
|
||||
return originalSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
dialogService.openConfirmDialog.mockImplementation(
|
||||
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
|
||||
confirmPromise = onConfirm?.();
|
||||
}
|
||||
);
|
||||
(store.dispatch as jest.Mock).mockImplementation((action: unknown) => {
|
||||
executionOrder.push('dispatch');
|
||||
return action;
|
||||
});
|
||||
router.navigate.mockImplementation((commands: unknown[]) => {
|
||||
executionOrder.push('navigate');
|
||||
return Promise.resolve(Boolean(commands));
|
||||
});
|
||||
databaseService.deleteXtreamPlaylistContent.mockResolvedValue({
|
||||
success: true,
|
||||
favorites: [
|
||||
{ xtreamId: 101, contentType: 'live' },
|
||||
{ xtreamId: 202, contentType: 'movie' },
|
||||
],
|
||||
recentlyViewed: [
|
||||
{
|
||||
xtreamId: 303,
|
||||
contentType: 'series',
|
||||
viewedAt: '2026-04-03T11:15:00.000Z',
|
||||
},
|
||||
],
|
||||
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
|
||||
});
|
||||
|
||||
component.refresh(item);
|
||||
await confirmPromise;
|
||||
|
||||
expect(dialogService.openConfirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
width: '400px',
|
||||
})
|
||||
);
|
||||
expect(
|
||||
databaseService.updateXtreamPlaylistDetails
|
||||
).toHaveBeenCalledWith({
|
||||
id: item._id,
|
||||
updateDate: 1712145600000,
|
||||
});
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate: 1712145600000 },
|
||||
})
|
||||
);
|
||||
expect(setItemSpy).toHaveBeenCalledWith(
|
||||
`xtream-restore-${item._id}`,
|
||||
expect.any(String)
|
||||
);
|
||||
const persistedRestoreState = JSON.parse(
|
||||
setItemSpy.mock.calls.find(
|
||||
([key]) => key === `xtream-restore-${item._id}`
|
||||
)?.[1] ?? 'null'
|
||||
);
|
||||
expect(persistedRestoreState).toEqual({
|
||||
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
|
||||
favorites: [
|
||||
{ xtreamId: 101, contentType: 'live' },
|
||||
{ xtreamId: 202, contentType: 'movie' },
|
||||
],
|
||||
recentlyViewed: [
|
||||
{
|
||||
xtreamId: 303,
|
||||
contentType: 'series',
|
||||
viewedAt: '2026-04-03T11:15:00.000Z',
|
||||
},
|
||||
],
|
||||
playbackPositions: [],
|
||||
});
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
expect(executionOrder).toEqual([
|
||||
'setItem',
|
||||
'start',
|
||||
'dispatch',
|
||||
'end',
|
||||
'navigate',
|
||||
]);
|
||||
expect(performanceEvents).toHaveLength(2);
|
||||
expect(performanceEvents[0]?.phaseId).toBe(
|
||||
performanceEvents[1]?.phaseId
|
||||
);
|
||||
expect(
|
||||
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
metadata,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
metadata: { items: 1 },
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
metadata: { items: 1 },
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
]);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
dateNowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses the legacy IPC refresh flow for non-Xtream playlists', async () => {
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
let answer!: () => void;
|
||||
dataService.sendIpcEvent.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
);
|
||||
const activity = TestBed.inject(SourceActivityService);
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u-1',
|
||||
serverUrl: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
filePath: undefined,
|
||||
url: 'https://example.com/test.m3u',
|
||||
userAgent: 'IPTVnator-Test/1.0',
|
||||
});
|
||||
|
||||
component.refresh(item);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
userAgent: item.userAgent,
|
||||
});
|
||||
// Busy, and held for removal and other refreshes, until it answers.
|
||||
expect(component.isRefreshing(item._id)).toBe(true);
|
||||
expect(activity.isBusy(item._id)).toBe(true);
|
||||
component.refresh(item);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
answer();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(component.isRefreshing(item._id)).toBe(false);
|
||||
expect(activity.isBusy(item._id)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => {
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u-1',
|
||||
serverUrl: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
filePath: '/tmp/test.m3u',
|
||||
});
|
||||
|
||||
component.refresh(item);
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('holds a row refresh in the shared registry, and leaves a held source alone', async () => {
|
||||
const activity = TestBed.inject(SourceActivityService);
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u',
|
||||
serverUrl: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
url: 'https://source.test/list.m3u',
|
||||
});
|
||||
let resolve!: (value: unknown) => void;
|
||||
playlistRefreshService.refreshPlaylist.mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
component.refresh(item);
|
||||
// The switcher and the removal guard see the row's refresh.
|
||||
expect(activity.isBusy(item._id)).toBe(true);
|
||||
resolve({ id: item._id, items: [] });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(activity.isBusy(item._id)).toBe(false);
|
||||
|
||||
// A refresh or delete started elsewhere holds it: no second run.
|
||||
const release = activity.begin([item._id]);
|
||||
component.refresh(item);
|
||||
expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
});
|
||||
|
||||
it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => {
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
const lateComponent = TestBed.inject(SourceListRefreshService);
|
||||
runtime.supportsPlaylistRefresh = true;
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u-2',
|
||||
serverUrl: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
filePath: '/tmp/test.m3u',
|
||||
userAgent: 'IPTVnator-Test/1.0',
|
||||
});
|
||||
|
||||
lateComponent.refresh(item);
|
||||
|
||||
expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith(
|
||||
{
|
||||
operationId: 'playlist-refresh-op',
|
||||
playlistId: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
userAgent: item.userAgent,
|
||||
filePath: item.filePath,
|
||||
},
|
||||
{
|
||||
onEvent: expect.any(Function),
|
||||
}
|
||||
);
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
|
||||
await Promise.resolve();
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: expect.objectContaining({
|
||||
_id: item._id,
|
||||
}),
|
||||
playlistId: item._id,
|
||||
refreshEpg: true,
|
||||
operationId: 'playlist-refresh-op',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the connectivity guard before deleting the cached Xtream catalog', async () => {
|
||||
// Second, independent implementation of the destructive refresh (the
|
||||
// Workspace sources page). Same consequence as the shared action: the
|
||||
// catalog is already gone by the time an open guard fast-fails the
|
||||
// re-import bootstrap.
|
||||
const item = {
|
||||
_id: 'xtream-guard',
|
||||
title: 'Guarded Xtream',
|
||||
serverUrl: 'http://panel.example:8080',
|
||||
} as PlaylistMeta;
|
||||
const order: string[] = [];
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
|
||||
dataService.sendIpcEvent.mockImplementation((event: string) => {
|
||||
order.push(`ipc:${event}`);
|
||||
return Promise.resolve({ success: true });
|
||||
});
|
||||
databaseService.deleteXtreamPlaylistContent.mockImplementation(() => {
|
||||
order.push('deleteXtreamPlaylistContent');
|
||||
return Promise.resolve({
|
||||
hiddenCategories: [],
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
sourcePins: [],
|
||||
});
|
||||
});
|
||||
dialogService.openConfirmDialog.mockImplementation(
|
||||
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
|
||||
confirmPromise = onConfirm?.();
|
||||
}
|
||||
);
|
||||
|
||||
component.refresh(item);
|
||||
await confirmPromise;
|
||||
|
||||
expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`);
|
||||
expect(order).toContain('deleteXtreamPlaylistContent');
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
{ url: item.serverUrl }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DatabaseService,
|
||||
DataService,
|
||||
DbOperationEvent,
|
||||
isDbAbortError,
|
||||
PlaylistRefreshService,
|
||||
RuntimeCapabilitiesService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
PLAYLIST_UPDATE,
|
||||
PlaylistMeta,
|
||||
PlaylistRefreshEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
XtreamRefreshFlowService,
|
||||
type XtreamRefreshProgressReporter,
|
||||
} from '../xtream-refresh-flow.service';
|
||||
|
||||
interface BusyOperation {
|
||||
current?: number;
|
||||
operation: string;
|
||||
operationId?: string;
|
||||
phase?: string;
|
||||
status: DbOperationEvent['status'];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
const REFRESH_PHASE_KEYS: Record<string, string> = {
|
||||
fetching: 'HOME.PLAYLISTS.REFRESH_FETCHING',
|
||||
'reading-file': 'HOME.PLAYLISTS.REFRESH_READING_FILE',
|
||||
parsing: 'HOME.PLAYLISTS.REFRESH_PARSING',
|
||||
saving: 'HOME.PLAYLISTS.REFRESH_SAVING',
|
||||
};
|
||||
const XTREAM_PHASE_KEYS: Record<string, string> = {
|
||||
'collecting-user-data':
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA',
|
||||
'deleting-content': 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT',
|
||||
'deleting-categories':
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES',
|
||||
};
|
||||
|
||||
/**
|
||||
* The Sources page's per-row refresh: which rows refresh, their phase and
|
||||
* progress, and cancelling. M3U refreshes in place, Xtream re-imports
|
||||
* through the shared confirmation flow, and the browser build sends the
|
||||
* URL refresh event. Provided by the list component.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SourceListRefreshService {
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly playlistRefreshService = inject(PlaylistRefreshService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
// Root registry: the switcher, the removal guard and cleanup consult it,
|
||||
// so a row refresh must not be visible only to this page.
|
||||
private readonly activity = inject(SourceActivityService);
|
||||
private readonly releases = new Map<string, () => void>();
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService);
|
||||
private readonly logger = createLogger('SourceListRefresh');
|
||||
|
||||
private readonly refreshingIds = signal<ReadonlySet<string>>(new Set());
|
||||
private readonly operations = signal<ReadonlyMap<string, BusyOperation>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
isRefreshing(id: string): boolean {
|
||||
return this.refreshingIds().has(id);
|
||||
}
|
||||
|
||||
canRefresh(item: PlaylistMeta): boolean {
|
||||
if (item.serverUrl) {
|
||||
return this.runtime.supportsXtreamSqliteDataSource;
|
||||
}
|
||||
return (
|
||||
!!item.url ||
|
||||
(!!item.filePath && this.runtime.supportsPlaylistRefresh)
|
||||
);
|
||||
}
|
||||
|
||||
refresh(item: PlaylistMeta): void {
|
||||
if (this.isRefreshing(item._id) || this.activity.isBusy(item._id)) {
|
||||
return;
|
||||
}
|
||||
if (item.serverUrl && this.runtime.supportsXtreamSqliteDataSource) {
|
||||
this.xtreamRefreshFlow.confirmAndRefresh(item, this.xtreamReporter);
|
||||
} else if (
|
||||
this.runtime.supportsPlaylistRefresh &&
|
||||
(item.url || item.filePath)
|
||||
) {
|
||||
void this.refreshM3u(item);
|
||||
} else if (item.url) {
|
||||
void this.refreshViaBackend(item, item.url);
|
||||
}
|
||||
}
|
||||
|
||||
message(item: PlaylistMeta): string {
|
||||
const operation = this.operations().get(item._id);
|
||||
if (!operation) {
|
||||
return '';
|
||||
}
|
||||
if (operation.operation === 'playlist-refresh') {
|
||||
return this.translate.instant(
|
||||
REFRESH_PHASE_KEYS[operation.phase ?? ''] ??
|
||||
'HOME.PLAYLISTS.REFRESH'
|
||||
);
|
||||
}
|
||||
if (operation.operation === 'delete-xtream-content') {
|
||||
return this.translate.instant(
|
||||
XTREAM_PHASE_KEYS[operation.phase ?? ''] ??
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
progress(id: string): number | null {
|
||||
const operation = this.operations().get(id);
|
||||
if (
|
||||
!operation ||
|
||||
operation.current == null ||
|
||||
operation.total == null ||
|
||||
operation.total <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((operation.current / operation.total) * 100)
|
||||
);
|
||||
}
|
||||
|
||||
canCancel(item: PlaylistMeta): boolean {
|
||||
const operation = this.operations().get(item._id);
|
||||
if (!operation?.operationId) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
operation.operation === 'playlist-refresh' ||
|
||||
Boolean(item.serverUrl)
|
||||
);
|
||||
}
|
||||
|
||||
async cancel(item: PlaylistMeta): Promise<void> {
|
||||
const operation = this.operations().get(item._id);
|
||||
if (!operation?.operationId) {
|
||||
return;
|
||||
}
|
||||
if (operation.operation === 'playlist-refresh') {
|
||||
await this.playlistRefreshService.cancelRefresh(
|
||||
operation.operationId
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.databaseService.cancelOperation(operation.operationId);
|
||||
}
|
||||
|
||||
/** The shared Xtream re-import reports into this list's row state. */
|
||||
private readonly xtreamReporter: XtreamRefreshProgressReporter = {
|
||||
isBusy: (playlistId) =>
|
||||
this.isRefreshing(playlistId) || this.activity.isBusy(playlistId),
|
||||
begin: ({ playlistId }) => this.setRefreshing(playlistId, true),
|
||||
report: ({ playlistId }, event) => this.track(playlistId, event),
|
||||
end: ({ playlistId }) => {
|
||||
this.clear(playlistId);
|
||||
this.setRefreshing(playlistId, false);
|
||||
},
|
||||
};
|
||||
|
||||
private async refreshM3u(item: PlaylistMeta): Promise<void> {
|
||||
this.setRefreshing(item._id, true);
|
||||
const operationId =
|
||||
this.databaseService.createOperationId('playlist-refresh');
|
||||
try {
|
||||
const refreshed = await this.playlistRefreshService.refreshPlaylist(
|
||||
{
|
||||
operationId,
|
||||
playlistId: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
...(item.userAgent ? { userAgent: item.userAgent } : {}),
|
||||
filePath: item.filePath,
|
||||
},
|
||||
{
|
||||
onEvent: (event) =>
|
||||
this.track(item._id, this.toBusyEvent(event)),
|
||||
}
|
||||
);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: { ...refreshed, _id: item._id },
|
||||
playlistId: item._id,
|
||||
refreshEpg: true,
|
||||
operationId,
|
||||
})
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
// Redacted: the error can carry the playlist URL.
|
||||
this.logger.error('Error refreshing playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.refreshErrorMessage(error, item),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.clear(item._id);
|
||||
this.setRefreshing(item._id, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser build refreshes through the backend; the row stays busy,
|
||||
* and held in the shared registry, until the backend answers.
|
||||
*/
|
||||
private async refreshViaBackend(
|
||||
item: PlaylistMeta,
|
||||
url: string
|
||||
): Promise<void> {
|
||||
this.setRefreshing(item._id, true);
|
||||
try {
|
||||
await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
url,
|
||||
...(item.userAgent ? { userAgent: item.userAgent } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Error refreshing playlist:', error);
|
||||
} finally {
|
||||
this.setRefreshing(item._id, false);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshErrorMessage(error: unknown, item: PlaylistMeta): string {
|
||||
if (item.filePath) {
|
||||
const message = String(
|
||||
(error as { message?: string })?.message ?? error
|
||||
);
|
||||
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
|
||||
}
|
||||
|
||||
private toBusyEvent(event: PlaylistRefreshEvent): DbOperationEvent {
|
||||
return {
|
||||
operationId: event.operationId,
|
||||
operation: 'playlist-refresh',
|
||||
playlistId: event.playlistId,
|
||||
phase: event.phase,
|
||||
status: event.status,
|
||||
error: event.error,
|
||||
};
|
||||
}
|
||||
|
||||
private track(id: string, event: DbOperationEvent): void {
|
||||
this.operations.update((current) => {
|
||||
const next = new Map(current);
|
||||
if (['completed', 'cancelled', 'error'].includes(event.status)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.set(id, {
|
||||
operation: event.operation,
|
||||
operationId: event.operationId,
|
||||
phase: event.phase,
|
||||
current: event.current,
|
||||
total: event.total,
|
||||
status: event.status,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private clear(id: string): void {
|
||||
this.operations.update((current) => {
|
||||
const next = new Map(current);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private setRefreshing(id: string, refreshing: boolean): void {
|
||||
if (refreshing && !this.releases.has(id)) {
|
||||
this.releases.set(id, this.activity.begin([id]));
|
||||
} else if (!refreshing) {
|
||||
this.releases.get(id)?.();
|
||||
this.releases.delete(id);
|
||||
}
|
||||
this.refreshingIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (refreshing) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
Injector,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
untracked,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import {
|
||||
RuntimeCapabilitiesService,
|
||||
injectTranslationTick,
|
||||
} 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>
|
||||
}`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
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 = injectTranslationTick();
|
||||
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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DatabaseService, DbOperationEvent } from '@iptvnator/services';
|
||||
|
||||
interface DeleteOperation {
|
||||
operationId?: string;
|
||||
phase?: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
const PHASE_KEYS: Record<string, string> = {
|
||||
'deleting-favorites': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES',
|
||||
'deleting-recently-viewed': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT',
|
||||
'deleting-playback-positions':
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS',
|
||||
'deleting-downloads': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS',
|
||||
'deleting-content': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT',
|
||||
'deleting-categories': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES',
|
||||
'deleting-playlist': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST',
|
||||
};
|
||||
|
||||
/**
|
||||
* Progress of a committed delete that reports it: an Xtream catalog in the
|
||||
* desktop database can take a while, so its row comes back as "Removing…"
|
||||
* with progress and a Cancel action that keeps the source.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceDeleteProgressService {
|
||||
private readonly database = inject(DatabaseService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly operations = signal<ReadonlyMap<string, DeleteOperation>>(
|
||||
new Map()
|
||||
);
|
||||
private readonly cancelled = new Set<string>();
|
||||
|
||||
isDeleting(id: string): boolean {
|
||||
return this.operations().has(id);
|
||||
}
|
||||
|
||||
message(id: string): string {
|
||||
const operation = this.operations().get(id);
|
||||
if (!operation) {
|
||||
return '';
|
||||
}
|
||||
return this.translate.instant(
|
||||
PHASE_KEYS[operation.phase ?? ''] ??
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
}
|
||||
|
||||
progress(id: string): number | null {
|
||||
const operation = this.operations().get(id);
|
||||
if (!operation?.total || operation.current == null) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((operation.current / operation.total) * 100)
|
||||
);
|
||||
}
|
||||
|
||||
canCancel(id: string): boolean {
|
||||
return !!this.operations().get(id)?.operationId;
|
||||
}
|
||||
|
||||
async cancel(id: string): Promise<void> {
|
||||
const operationId = this.operations().get(id)?.operationId;
|
||||
if (operationId) {
|
||||
this.cancelled.add(id);
|
||||
await this.database.cancelOperation(operationId);
|
||||
}
|
||||
}
|
||||
|
||||
/** True once, after a cancelled delete: the caller skips its error. */
|
||||
takeCancelled(id: string): boolean {
|
||||
return this.cancelled.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a running delete. A terminal event ends what Cancel can stop,
|
||||
* but the row stays busy: the delete still cleans up (parental lock,
|
||||
* stored state) before its source leaves the store, so the caller clears
|
||||
* it once the whole delete has settled.
|
||||
*/
|
||||
track(id: string, event: DbOperationEvent): void {
|
||||
const terminal = ['completed', 'cancelled', 'error'].includes(
|
||||
event.status
|
||||
);
|
||||
this.operations.update((current) =>
|
||||
new Map(current).set(
|
||||
id,
|
||||
terminal
|
||||
? { ...current.get(id), operationId: undefined }
|
||||
: {
|
||||
operationId: event.operationId,
|
||||
phase: event.phase,
|
||||
current: event.current,
|
||||
total: event.total,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
clear(id: string): void {
|
||||
if (!this.operations().has(id)) {
|
||||
return;
|
||||
}
|
||||
this.operations.update((current) => {
|
||||
const next = new Map(current);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
.source-removal-toast__undo {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-removal-toast__close {
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
// The toast frame (`panelClass: 'app-toast'`): bottom centre, one step
|
||||
// above the page. The container renders in the overlay, so the rule is
|
||||
// global; it ships with this component instead of the initial stylesheet.
|
||||
::ng-deep .mat-mdc-snack-bar-container.app-toast {
|
||||
@include mat.snack-bar-overrides(
|
||||
(
|
||||
container-color: var(--app-widget-bg),
|
||||
container-shape: 10px,
|
||||
supporting-text-color: var(--app-heading-color),
|
||||
supporting-text-size: 13px,
|
||||
button-color: var(--app-selection-color),
|
||||
)
|
||||
);
|
||||
|
||||
.mat-mdc-snackbar-surface {
|
||||
border: 1px solid var(--app-line-strong);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
MAT_SNACK_BAR_DATA,
|
||||
MatSnackBarRef,
|
||||
} from '@angular/material/snack-bar';
|
||||
import { Subject } from 'rxjs';
|
||||
import { SourceRemovalToastComponent } from './source-removal-toast.component';
|
||||
|
||||
describe('SourceRemovalToastComponent', () => {
|
||||
let opened: Subject<void>;
|
||||
let dismissed: Subject<void>;
|
||||
let ref: {
|
||||
afterOpened: () => Subject<void>;
|
||||
afterDismissed: () => Subject<void>;
|
||||
dismiss: jest.Mock;
|
||||
dismissWithAction: jest.Mock;
|
||||
};
|
||||
let data: Record<string, unknown>;
|
||||
|
||||
const render = () => {
|
||||
const fixture = TestBed.createComponent(SourceRemovalToastComponent);
|
||||
fixture.detectChanges();
|
||||
document.body.appendChild(fixture.nativeElement);
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
opened = new Subject<void>();
|
||||
dismissed = new Subject<void>();
|
||||
ref = {
|
||||
afterOpened: () => opened,
|
||||
afterDismissed: () => dismissed,
|
||||
dismiss: jest.fn(),
|
||||
dismissWithAction: jest.fn(),
|
||||
};
|
||||
data = {
|
||||
message: 'Removed “Alpha”',
|
||||
undoLabel: 'Undo',
|
||||
closeLabel: 'Close',
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SourceRemovalToastComponent],
|
||||
providers: [
|
||||
{ provide: MAT_SNACK_BAR_DATA, useFactory: () => data },
|
||||
{ provide: MatSnackBarRef, useValue: ref },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('undoes, or commits at once from the close button', () => {
|
||||
const element = render();
|
||||
expect(element.textContent).toContain('Removed “Alpha”');
|
||||
element
|
||||
.querySelector<HTMLButtonElement>('.source-removal-toast__undo')
|
||||
?.click();
|
||||
expect(ref.dismissWithAction).toHaveBeenCalled();
|
||||
element
|
||||
.querySelector<HTMLButtonElement>('.source-removal-toast__close')
|
||||
?.click();
|
||||
expect(ref.dismiss).toHaveBeenCalled();
|
||||
expect(
|
||||
element
|
||||
.querySelector('.source-removal-toast__close')
|
||||
?.getAttribute('aria-label')
|
||||
).toBe('Close');
|
||||
});
|
||||
|
||||
it('commits through the service from the close button when it can', () => {
|
||||
const commitNow = jest.fn();
|
||||
data['commitNow'] = commitNow;
|
||||
render()
|
||||
.querySelector<HTMLButtonElement>('.source-removal-toast__close')
|
||||
?.click();
|
||||
expect(commitNow).toHaveBeenCalled();
|
||||
expect(ref.dismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes the focus when the delete left it nowhere', () => {
|
||||
const element = render();
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
opened.next();
|
||||
expect(document.activeElement).toBe(
|
||||
element.querySelector('.source-removal-toast__undo')
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the focus from a closing surface and gives it back after', () => {
|
||||
const trigger = document.createElement('button');
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
data['returnFocusTo'] = trigger;
|
||||
const element = render();
|
||||
const undo = element.querySelector<HTMLButtonElement>(
|
||||
'.source-removal-toast__undo'
|
||||
);
|
||||
|
||||
opened.next();
|
||||
expect(document.activeElement).toBe(undo);
|
||||
|
||||
// The toast leaves the page and its focus with it.
|
||||
element.remove();
|
||||
dismissed.next();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
trigger.remove();
|
||||
});
|
||||
|
||||
it('leaves the focus where the user put it', () => {
|
||||
render();
|
||||
const elsewhere = document.createElement('button');
|
||||
document.body.appendChild(elsewhere);
|
||||
elsewhere.focus();
|
||||
opened.next();
|
||||
expect(document.activeElement).toBe(elsewhere);
|
||||
elsewhere.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import {
|
||||
MAT_SNACK_BAR_DATA,
|
||||
MatSnackBarAction,
|
||||
MatSnackBarActions,
|
||||
MatSnackBarLabel,
|
||||
MatSnackBarRef,
|
||||
} from '@angular/material/snack-bar';
|
||||
|
||||
export interface SourceRemovalToastData {
|
||||
message: string;
|
||||
undoLabel: string;
|
||||
closeLabel: string;
|
||||
/** Undo takes the focus, which returns here when the toast closes. */
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
/** The close button: commit the delete now rather than at the end. */
|
||||
commitNow?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Removed …" with Undo, and a close button that commits the delete at
|
||||
* once instead of waiting out the toast. A delete from a row's menu takes
|
||||
* the row, and with it the focus, off the page: Undo then takes the focus,
|
||||
* so the keyboard can reach it. A caller that closes on delete (the
|
||||
* switcher) passes `returnFocusTo`: Undo takes the focus regardless, and
|
||||
* the focus goes back there when the toast closes.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-source-removal-toast',
|
||||
imports: [
|
||||
MatButton,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatSnackBarAction,
|
||||
MatSnackBarActions,
|
||||
MatSnackBarLabel,
|
||||
],
|
||||
template: `
|
||||
<span matSnackBarLabel>{{ data.message }}</span>
|
||||
<span matSnackBarActions>
|
||||
<button
|
||||
#undo
|
||||
mat-button
|
||||
matSnackBarAction
|
||||
type="button"
|
||||
class="source-removal-toast__undo"
|
||||
(click)="ref.dismissWithAction()"
|
||||
>
|
||||
{{ data.undoLabel }}
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-removal-toast__close"
|
||||
(click)="close()"
|
||||
[attr.aria-label]="data.closeLabel"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</span>
|
||||
`,
|
||||
styleUrl: './source-removal-toast.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SourceRemovalToastComponent {
|
||||
readonly data = inject<SourceRemovalToastData>(MAT_SNACK_BAR_DATA);
|
||||
readonly ref =
|
||||
inject<MatSnackBarRef<SourceRemovalToastComponent>>(MatSnackBarRef);
|
||||
private readonly undo = viewChild.required('undo', { read: ElementRef });
|
||||
|
||||
constructor() {
|
||||
const document = inject(DOCUMENT);
|
||||
// After the opening animation: by then a closing menu has tried, and
|
||||
// failed, to give focus back to the removed row's ⋯ button.
|
||||
const returnFocusTo = this.data.returnFocusTo ?? null;
|
||||
this.ref.afterOpened().subscribe(() => {
|
||||
const active = document.activeElement;
|
||||
if (returnFocusTo || !active || active === document.body) {
|
||||
(this.undo().nativeElement as HTMLElement).focus();
|
||||
}
|
||||
});
|
||||
// The toast is gone by now; focus that left with it lands on body.
|
||||
this.ref.afterDismissed().subscribe(() => {
|
||||
const active = document.activeElement;
|
||||
if (
|
||||
returnFocusTo?.isConnected &&
|
||||
(!active || active === document.body)
|
||||
) {
|
||||
returnFocusTo.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Commits the delete at once; the toast closes with it. */
|
||||
close(): void {
|
||||
if (this.data.commitNow) {
|
||||
this.data.commitNow();
|
||||
} else {
|
||||
this.ref.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
DatabaseService,
|
||||
PlaylistDeleteActionService,
|
||||
RuntimeCapabilitiesService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import {
|
||||
SOURCE_REMOVAL_UNDO_MS,
|
||||
SourceRemovalService,
|
||||
} from './source-removal.service';
|
||||
|
||||
const playlist = (id: string): PlaylistMeta => ({
|
||||
_id: id,
|
||||
title: id,
|
||||
count: 0,
|
||||
importDate: '',
|
||||
autoRefresh: false,
|
||||
});
|
||||
|
||||
describe('SourceRemovalService', () => {
|
||||
let toasts: Array<{
|
||||
message: string;
|
||||
data: { commitNow?: () => void };
|
||||
action: Subject<void>;
|
||||
dismissed: Subject<void>;
|
||||
dismiss: jest.Mock;
|
||||
}>;
|
||||
let deletePlaylist: jest.Mock;
|
||||
let cancelOperation: jest.Mock;
|
||||
let dispatch: jest.Mock;
|
||||
let resolvedPlaylistId: ReturnType<typeof signal<string | null>>;
|
||||
let routePlaylistId: ReturnType<typeof signal<string | null>>;
|
||||
let context: {
|
||||
resolvedPlaylistId: typeof resolvedPlaylistId;
|
||||
routePlaylistId: typeof routePlaylistId;
|
||||
selectPlaylist: jest.Mock;
|
||||
activatePlaylist: jest.Mock;
|
||||
};
|
||||
let router: { url: string; navigate: jest.Mock; navigateByUrl: jest.Mock };
|
||||
let service: SourceRemovalService;
|
||||
|
||||
const toast = (message: string, data: { commitNow?: () => void } = {}) => {
|
||||
const dismissed = new Subject<void>();
|
||||
const entry: (typeof toasts)[number] = {
|
||||
message,
|
||||
data,
|
||||
action: new Subject<void>(),
|
||||
dismissed,
|
||||
dismiss: jest.fn(() => dismissed.next()),
|
||||
};
|
||||
toasts.push(entry);
|
||||
return {
|
||||
onAction: () => entry.action,
|
||||
afterDismissed: () => entry.dismissed,
|
||||
dismiss: entry.dismiss,
|
||||
};
|
||||
};
|
||||
|
||||
/** The Undo window ends: the service's own timer commits. */
|
||||
const expireUndo = () => jest.advanceTimersByTime(SOURCE_REMOVAL_UNDO_MS);
|
||||
|
||||
const flush = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
toasts = [];
|
||||
deletePlaylist = jest.fn().mockResolvedValue(true);
|
||||
cancelOperation = jest.fn().mockResolvedValue(true);
|
||||
dispatch = jest.fn();
|
||||
resolvedPlaylistId = signal<string | null>(null);
|
||||
routePlaylistId = signal<string | null>(null);
|
||||
context = {
|
||||
resolvedPlaylistId,
|
||||
routePlaylistId,
|
||||
selectPlaylist: jest.fn((p: PlaylistMeta) =>
|
||||
resolvedPlaylistId.set(p._id)
|
||||
),
|
||||
activatePlaylist: jest.fn(),
|
||||
};
|
||||
router = {
|
||||
url: '/workspace/sources',
|
||||
navigate: jest.fn().mockResolvedValue(true),
|
||||
navigateByUrl: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
providers: [
|
||||
SourceRemovalService,
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn((message: string) => toast(message)),
|
||||
openFromComponent: jest.fn(
|
||||
(
|
||||
_component: unknown,
|
||||
config: {
|
||||
data: {
|
||||
message: string;
|
||||
commitNow?: () => void;
|
||||
};
|
||||
}
|
||||
) => toast(config.data.message, config.data)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistDeleteActionService,
|
||||
useValue: { deletePlaylist },
|
||||
},
|
||||
{ provide: PlaylistContextFacade, useValue: context },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: Store, useValue: { dispatch } },
|
||||
{ provide: DatabaseService, useValue: { cancelOperation } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsXtreamSqliteDataSource: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(SourceRemovalService);
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('hides the source and deletes it only when the Undo window ends', async () => {
|
||||
const a = playlist('a');
|
||||
service.remove(a);
|
||||
expect(service.isPending('a')).toBe(true);
|
||||
expect(deletePlaylist).not.toHaveBeenCalled();
|
||||
expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED');
|
||||
|
||||
expireUndo();
|
||||
await flush();
|
||||
expect(deletePlaylist).toHaveBeenCalledWith(a, {
|
||||
onEvent: expect.any(Function),
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.playlistRemovalCommitted({ playlistId: 'a' })
|
||||
);
|
||||
expect(service.isPending('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not remove a source while a refresh clears and refills it', async () => {
|
||||
// Any entry point's refresh, or the startup auto-refresh.
|
||||
const release = TestBed.inject(SourceActivityService).begin(['a']);
|
||||
service.remove(playlist('a'));
|
||||
expireUndo();
|
||||
await flush();
|
||||
expect(service.isPending('a')).toBe(false);
|
||||
expect(toasts).toHaveLength(0);
|
||||
expect(deletePlaylist).not.toHaveBeenCalled();
|
||||
release();
|
||||
});
|
||||
|
||||
it('restores the source on Undo without deleting anything', async () => {
|
||||
service.remove(playlist('a'));
|
||||
toasts[0].action.next();
|
||||
toasts[0].dismissed.next();
|
||||
expireUndo();
|
||||
await flush();
|
||||
expect(service.isPending('a')).toBe(false);
|
||||
expect(deletePlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the full Undo window when another message replaces the toast', async () => {
|
||||
service.remove(playlist('a'));
|
||||
// A refresh finishing elsewhere opens its own snack bar.
|
||||
toasts[0].dismissed.next();
|
||||
jest.advanceTimersByTime(SOURCE_REMOVAL_UNDO_MS - 1);
|
||||
await flush();
|
||||
expect(deletePlaylist).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
await flush();
|
||||
expect(deletePlaylist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits at once from the close button, and only once', async () => {
|
||||
service.remove(playlist('a'));
|
||||
toasts[0].data.commitNow?.();
|
||||
await flush();
|
||||
expect(deletePlaylist).toHaveBeenCalledTimes(1);
|
||||
expect(toasts[0].dismiss).toHaveBeenCalled();
|
||||
|
||||
expireUndo();
|
||||
await flush();
|
||||
expect(deletePlaylist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits an earlier removal when a second one starts', async () => {
|
||||
service.remove(playlist('a'));
|
||||
service.remove(playlist('b'));
|
||||
await flush();
|
||||
expect(toasts[0].dismiss).toHaveBeenCalled();
|
||||
expect(deletePlaylist).toHaveBeenCalledTimes(1);
|
||||
expect(service.isPending('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('switches away from the active source and back on Undo', () => {
|
||||
resolvedPlaylistId.set('a');
|
||||
service.remove(playlist('a'), playlist('b'));
|
||||
expect(context.selectPlaylist).toHaveBeenCalledWith(playlist('b'));
|
||||
expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED_SWITCHED');
|
||||
|
||||
toasts[0].action.next();
|
||||
expect(context.activatePlaylist).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('does not hand over to a source that is being deleted', () => {
|
||||
resolvedPlaylistId.set('a');
|
||||
service.progress.track('b', {
|
||||
operation: 'delete-playlist',
|
||||
status: 'started',
|
||||
});
|
||||
service.remove(playlist('a'), playlist('b'));
|
||||
expect(context.selectPlaylist).not.toHaveBeenCalled();
|
||||
expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED');
|
||||
});
|
||||
|
||||
it('does not hand over to a source a refresh or import holds', () => {
|
||||
resolvedPlaylistId.set('a');
|
||||
const release = TestBed.inject(SourceActivityService).begin(['b']);
|
||||
service.remove(playlist('a'), playlist('b'));
|
||||
expect(context.selectPlaylist).not.toHaveBeenCalled();
|
||||
expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED');
|
||||
release();
|
||||
});
|
||||
|
||||
it('leaves a switch the user made after the delete alone on Undo', () => {
|
||||
resolvedPlaylistId.set('a');
|
||||
service.remove(playlist('a'), playlist('b'));
|
||||
resolvedPlaylistId.set('c');
|
||||
toasts[0].action.next();
|
||||
expect(context.activatePlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves a later switch alone on Undo when nothing replaced the source', () => {
|
||||
// The source was the only row a filter showed: no replacement.
|
||||
resolvedPlaylistId.set('a');
|
||||
routePlaylistId.set('a');
|
||||
router.url = '/workspace/xtreams/a/vod';
|
||||
service.remove(playlist('a'));
|
||||
resolvedPlaylistId.set('c');
|
||||
|
||||
toasts[0].action.next();
|
||||
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||
expect(context.activatePlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the focus target of a closing surface to the toast', () => {
|
||||
const trigger = document.createElement('button');
|
||||
service.remove(playlist('a'), null, { returnFocusTo: trigger });
|
||||
const openFromComponent = TestBed.inject(MatSnackBar)
|
||||
.openFromComponent as jest.Mock;
|
||||
expect(openFromComponent.mock.calls[0][1].data.returnFocusTo).toBe(
|
||||
trigger
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the deleted source’s own page and returns on Undo', () => {
|
||||
resolvedPlaylistId.set('a');
|
||||
routePlaylistId.set('a');
|
||||
router.url = '/workspace/xtreams/a/vod';
|
||||
service.remove(playlist('a'));
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/workspace', 'sources']);
|
||||
|
||||
toasts[0].action.next();
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(
|
||||
'/workspace/xtreams/a/vod'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a desktop Xtream row as removing as soon as the delete starts', async () => {
|
||||
deletePlaylist.mockReturnValue(new Promise(() => undefined));
|
||||
service.remove({ ...playlist('x'), serverUrl: 'http://x.test' });
|
||||
expect(service.isPending('x')).toBe(true);
|
||||
expireUndo();
|
||||
await flush();
|
||||
expect(service.isPending('x')).toBe(false);
|
||||
expect(service.progress.isDeleting('x')).toBe(true);
|
||||
expect(service.progress.message('x')).toBe(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
});
|
||||
|
||||
it('times an Xtream row leaving the store for the performance journeys', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
const hook = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
target[hook] = (event: RendererPerformancePhaseEvent) =>
|
||||
events.push(event);
|
||||
try {
|
||||
service.remove({ ...playlist('x'), serverUrl: 'http://x.test' });
|
||||
expireUndo();
|
||||
await flush();
|
||||
} finally {
|
||||
delete target[hook];
|
||||
}
|
||||
expect(events.map(({ boundary, phase }) => [boundary, phase])).toEqual([
|
||||
['start', 'store.xtream-delete-row'],
|
||||
['end', 'store.xtream-delete-row'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a long delete on its row and keeps the source on Cancel', async () => {
|
||||
let finish!: (deleted: boolean) => void;
|
||||
deletePlaylist.mockImplementation(
|
||||
(
|
||||
_playlist: PlaylistMeta,
|
||||
options: { onEvent: (event: object) => void }
|
||||
) => {
|
||||
options.onEvent({
|
||||
operation: 'delete-playlist',
|
||||
operationId: 'op-1',
|
||||
status: 'progress',
|
||||
phase: 'deleting-content',
|
||||
current: 1,
|
||||
total: 4,
|
||||
});
|
||||
return new Promise<boolean>((resolve) => (finish = resolve));
|
||||
}
|
||||
);
|
||||
service.remove(playlist('x'));
|
||||
expireUndo();
|
||||
await flush();
|
||||
|
||||
// The row is back, in its busy state.
|
||||
expect(service.isPending('x')).toBe(false);
|
||||
expect(service.progress.isDeleting('x')).toBe(true);
|
||||
expect(service.progress.message('x')).toBe(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
expect(service.progress.progress('x')).toBe(25);
|
||||
|
||||
await service.progress.cancel('x');
|
||||
expect(cancelOperation).toHaveBeenCalledWith('op-1');
|
||||
finish(false);
|
||||
await flush();
|
||||
expect(service.progress.isDeleting('x')).toBe(false);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
// A cancel is not a failure.
|
||||
expect(toasts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps a deleted row busy until the whole delete has settled', async () => {
|
||||
let finish!: (deleted: boolean) => void;
|
||||
deletePlaylist.mockImplementation(
|
||||
(
|
||||
_playlist: PlaylistMeta,
|
||||
options: { onEvent: (event: object) => void }
|
||||
) => {
|
||||
options.onEvent({
|
||||
operation: 'delete-playlist',
|
||||
operationId: 'op-2',
|
||||
status: 'completed',
|
||||
});
|
||||
return new Promise<boolean>((resolve) => (finish = resolve));
|
||||
}
|
||||
);
|
||||
service.remove({ ...playlist('x'), serverUrl: 'http://x.test' });
|
||||
expireUndo();
|
||||
await flush();
|
||||
|
||||
// The database is done, its cleanup is not: no Cancel, still busy.
|
||||
expect(service.progress.isDeleting('x')).toBe(true);
|
||||
expect(service.progress.canCancel('x')).toBe(false);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
|
||||
finish(true);
|
||||
await flush();
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.playlistRemovalCommitted({ playlistId: 'x' })
|
||||
);
|
||||
expect(service.progress.isDeleting('x')).toBe(false);
|
||||
});
|
||||
|
||||
it('brings the source back and says so when the delete fails', async () => {
|
||||
deletePlaylist.mockResolvedValue(false);
|
||||
service.remove(playlist('a'));
|
||||
expireUndo();
|
||||
await flush();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(service.isPending('a')).toBe(false);
|
||||
expect(toasts[1].message).toBe('WORKSPACE.SOURCES.REMOVE_FAILED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
PlaylistContextFacade,
|
||||
sourceDisplayName,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
PlaylistDeleteActionService,
|
||||
RuntimeCapabilitiesService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { SourceDeleteProgressService } from './source-delete-progress.service';
|
||||
import { SourceRemovalToastComponent } from './source-removal-toast.component';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
|
||||
/** How long Undo stays available before the delete is committed. */
|
||||
export const SOURCE_REMOVAL_UNDO_MS = 4000;
|
||||
|
||||
export interface SourceRemovalOptions {
|
||||
/**
|
||||
* For a surface that closes on delete, such as the switcher popover,
|
||||
* whose focus trap the toast sits outside: Undo takes the focus at once,
|
||||
* and it comes back here when the toast closes.
|
||||
*/
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface PendingRemoval {
|
||||
playlist: PlaylistMeta;
|
||||
/** Its Undo toast, set once opened. */
|
||||
snackBar?: MatSnackBarRef<SourceRemovalToastComponent>;
|
||||
/** The playlist was the active one; `next` replaced it, if any. */
|
||||
wasActive: boolean;
|
||||
wasOnItsRoute: boolean;
|
||||
previousUrl: string;
|
||||
next: PlaylistMeta | null;
|
||||
undone: boolean;
|
||||
/** Undone or committed: nothing more happens to it. */
|
||||
settled: boolean;
|
||||
/** Commits when the Undo window ends, whatever happened to the toast. */
|
||||
timer: ReturnType<typeof setTimeout> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-source delete with Undo, for the Sources page and the switcher.
|
||||
*
|
||||
* Deleting a source drops its favourites, history and playback progress, so
|
||||
* nothing is deleted while Undo is offered: the source only leaves the
|
||||
* lists (`pendingIds`), and the real delete runs when the toast closes
|
||||
* without Undo. Quitting the app inside those four seconds keeps the
|
||||
* source. The window is the service's own timer, not the toast's: another
|
||||
* message can replace the toast early without shortening it. One removal
|
||||
* is pending at a time; a second delete commits the
|
||||
* first. A delete that reports progress (a large Xtream catalog) brings its
|
||||
* row back as "Removing…" with Cancel, which keeps the source. Bulk cleanup
|
||||
* keeps its confirmation dialog instead.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceRemovalService {
|
||||
private readonly deleteAction = inject(PlaylistDeleteActionService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly activity = inject(SourceActivityService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
/** Progress of a long delete; its row comes back to show it. */
|
||||
readonly progress = inject(SourceDeleteProgressService);
|
||||
|
||||
private readonly pendingState = signal<ReadonlySet<string>>(new Set());
|
||||
/** Sources hidden from every list while Undo or the delete runs. */
|
||||
readonly pendingIds = this.pendingState.asReadonly();
|
||||
private pending: PendingRemoval | null = null;
|
||||
|
||||
isPending(id: string): boolean {
|
||||
return this.pendingState().has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a source can become active in place of a removed one: not
|
||||
* leaving itself, and not held by a refresh, import or delete, since
|
||||
* opening it would start an import against a catalog being rewritten.
|
||||
*/
|
||||
canTakeOver(id: string): boolean {
|
||||
return (
|
||||
!this.isPending(id) &&
|
||||
!this.progress.isDeleting(id) &&
|
||||
!this.activity.isBusy(id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param next The source to switch to when `playlist` is the active one:
|
||||
* the caller's next row, so the switch follows the list on screen.
|
||||
*/
|
||||
remove(
|
||||
playlist: PlaylistMeta,
|
||||
next: PlaylistMeta | null = null,
|
||||
options: SourceRemovalOptions = {}
|
||||
): void {
|
||||
// A refresh from any entry point (or the startup auto-refresh) is
|
||||
// destructive too: it clears the catalog, then re-imports it.
|
||||
if (
|
||||
this.isPending(playlist._id) ||
|
||||
this.activity.isBusy(playlist._id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// A second delete commits the first at once.
|
||||
if (this.pending) {
|
||||
this.settle(this.pending);
|
||||
}
|
||||
|
||||
const wasActive =
|
||||
this.playlistContext.resolvedPlaylistId() === playlist._id;
|
||||
const wasOnItsRoute =
|
||||
this.playlistContext.routePlaylistId() === playlist._id;
|
||||
const previousUrl = this.router.url;
|
||||
const replacement =
|
||||
wasActive && next && this.canTakeOver(next._id) ? next : null;
|
||||
this.setPending(playlist._id, true);
|
||||
|
||||
if (replacement) {
|
||||
this.playlistContext.selectPlaylist(replacement);
|
||||
} else if (wasOnItsRoute) {
|
||||
void this.router.navigate(['/workspace', 'sources']);
|
||||
}
|
||||
|
||||
const name = this.nameOf(playlist);
|
||||
const message = replacement
|
||||
? this.translate.instant('WORKSPACE.SOURCES.REMOVED_SWITCHED', {
|
||||
name,
|
||||
next: this.nameOf(replacement),
|
||||
})
|
||||
: this.translate.instant('WORKSPACE.SOURCES.REMOVED', { name });
|
||||
const removal: PendingRemoval = {
|
||||
playlist,
|
||||
wasActive,
|
||||
wasOnItsRoute,
|
||||
previousUrl,
|
||||
next: replacement,
|
||||
undone: false,
|
||||
settled: false,
|
||||
timer: undefined,
|
||||
};
|
||||
const snackBar = this.snackBar.openFromComponent(
|
||||
SourceRemovalToastComponent,
|
||||
{
|
||||
data: {
|
||||
message,
|
||||
undoLabel: this.translate.instant(
|
||||
'PORTALS.MULTI_SOURCE.UNDO'
|
||||
),
|
||||
closeLabel: this.translate.instant('CLOSE'),
|
||||
returnFocusTo: options.returnFocusTo ?? null,
|
||||
commitNow: () => this.settle(removal),
|
||||
},
|
||||
duration: SOURCE_REMOVAL_UNDO_MS,
|
||||
panelClass: 'app-toast',
|
||||
}
|
||||
);
|
||||
removal.snackBar = snackBar;
|
||||
removal.timer = setTimeout(
|
||||
() => this.settle(removal),
|
||||
SOURCE_REMOVAL_UNDO_MS
|
||||
);
|
||||
this.pending = removal;
|
||||
snackBar.onAction().subscribe(() => this.undo(removal));
|
||||
}
|
||||
|
||||
/** Ends the Undo window: commits the delete unless it was undone. */
|
||||
private settle(removal: PendingRemoval): void {
|
||||
if (removal.settled) {
|
||||
return;
|
||||
}
|
||||
removal.settled = true;
|
||||
clearTimeout(removal.timer);
|
||||
if (this.pending === removal) {
|
||||
this.pending = null;
|
||||
}
|
||||
removal.snackBar?.dismiss();
|
||||
if (!removal.undone) {
|
||||
void this.commit(removal);
|
||||
}
|
||||
}
|
||||
|
||||
private undo(removal: PendingRemoval): void {
|
||||
if (removal.settled) {
|
||||
return;
|
||||
}
|
||||
removal.undone = true;
|
||||
this.settle(removal);
|
||||
this.setPending(removal.playlist._id, false);
|
||||
if (!removal.wasActive) {
|
||||
return;
|
||||
}
|
||||
// Only take the user back if they are still where the delete left
|
||||
// them: on its replacement, or with no other source chosen. A switch
|
||||
// they made since then wins, also when nothing replaced the source
|
||||
// (it was the only row a filter showed).
|
||||
const current = this.playlistContext.resolvedPlaylistId();
|
||||
const untouched =
|
||||
current === null ||
|
||||
current === removal.playlist._id ||
|
||||
current === removal.next?._id;
|
||||
if (!untouched) {
|
||||
return;
|
||||
}
|
||||
if (removal.wasOnItsRoute) {
|
||||
void this.router.navigateByUrl(removal.previousUrl);
|
||||
} else {
|
||||
this.playlistContext.activatePlaylist(removal.playlist._id);
|
||||
}
|
||||
}
|
||||
|
||||
private async commit(removal: PendingRemoval): Promise<void> {
|
||||
const { playlist } = removal;
|
||||
// A desktop Xtream delete works through the catalog database and
|
||||
// reports progress: its row returns at once as "Removing…".
|
||||
if (playlist.serverUrl && this.runtime.supportsXtreamSqliteDataSource) {
|
||||
this.progress.track(playlist._id, {
|
||||
operation: 'delete-playlist',
|
||||
status: 'started',
|
||||
});
|
||||
this.setPending(playlist._id, false);
|
||||
}
|
||||
try {
|
||||
const deleted = await this.deleteAction.deletePlaylist(playlist, {
|
||||
onEvent: (event) => {
|
||||
this.progress.track(playlist._id, event);
|
||||
if (this.progress.isDeleting(playlist._id)) {
|
||||
this.setPending(playlist._id, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (deleted) {
|
||||
const commit = () =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.playlistRemovalCommitted({
|
||||
playlistId: playlist._id,
|
||||
})
|
||||
);
|
||||
// The performance journeys time an Xtream row leaving the store.
|
||||
if (playlist.serverUrl) {
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
|
||||
commit,
|
||||
() => ({ items: 1 })
|
||||
);
|
||||
} else {
|
||||
commit();
|
||||
}
|
||||
} else if (!this.progress.takeCancelled(playlist._id)) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('WORKSPACE.SOURCES.REMOVE_FAILED', {
|
||||
name: this.nameOf(playlist),
|
||||
}),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 5000, panelClass: 'app-toast' }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.progress.clear(playlist._id);
|
||||
this.setPending(playlist._id, false);
|
||||
}
|
||||
}
|
||||
|
||||
private nameOf(playlist: PlaylistMeta): string {
|
||||
return (
|
||||
sourceDisplayName(playlist) ??
|
||||
this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
|
||||
);
|
||||
}
|
||||
|
||||
private setPending(id: string, pending: boolean): void {
|
||||
this.pendingState.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (pending) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DestroyRef, Injectable, inject, signal } from '@angular/core';
|
||||
|
||||
/** Often enough for durations shown in whole hours and days. */
|
||||
export const SOURCE_CLOCK_TICK_MS = 60_000;
|
||||
|
||||
/**
|
||||
* One shared minute clock for source rows, so "Not responding · 3 days"
|
||||
* moves on while a list stays open, without a timer per row.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceClock {
|
||||
private readonly state = signal(Date.now());
|
||||
readonly now = this.state.asReadonly();
|
||||
|
||||
constructor() {
|
||||
const timer = setInterval(
|
||||
() => this.state.set(Date.now()),
|
||||
SOURCE_CLOCK_TICK_MS
|
||||
);
|
||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<div
|
||||
class="source-row"
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="!draggable() || busy()"
|
||||
cdkDragPreviewContainer="parent"
|
||||
[class.source-row--compact]="compact()"
|
||||
[class.source-row--current]="current()"
|
||||
[class.source-row--highlighted]="highlighted()"
|
||||
[class.source-row--failing]="failing()"
|
||||
[class.source-row--menu-open]="menuOpen()"
|
||||
[class.source-row--busy]="busy()"
|
||||
(click)="onRowClick()"
|
||||
(keydown)="onRowKeydown($event)"
|
||||
>
|
||||
@if (draggable()) {
|
||||
<!-- Grabbing the handle is not opening the source. -->
|
||||
<mat-icon
|
||||
cdkDragHandle
|
||||
class="source-row__handle"
|
||||
aria-hidden="true"
|
||||
(click)="$event.stopPropagation()"
|
||||
>drag_indicator</mat-icon
|
||||
>
|
||||
}
|
||||
<app-source-tile
|
||||
[playlist]="playlist()"
|
||||
[status]="status()"
|
||||
[size]="compact() ? 'md' : 'lg'"
|
||||
/>
|
||||
<div
|
||||
class="source-row__body"
|
||||
[attr.role]="compact() ? null : 'button'"
|
||||
[attr.tabindex]="compact() ? null : 0"
|
||||
[attr.aria-label]="compact() ? null : name()"
|
||||
[attr.aria-current]="(!compact() && current()) || null"
|
||||
[attr.aria-disabled]="(!compact() && busy()) || null"
|
||||
(keydown.enter)="compact() || onActivationKey($event)"
|
||||
(keydown.space)="compact() || onActivationKey($event)"
|
||||
>
|
||||
<div class="source-row__title">
|
||||
<span class="source-row__name">{{ name() }}</span>
|
||||
@if (tag(); as tag) {
|
||||
<span
|
||||
class="source-row__tag"
|
||||
[class.source-row__tag--neutral]="tag.tone === 'neutral'"
|
||||
>{{ tag.label }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="source-row__meta">
|
||||
@for (part of meta(); track part.kind; let first = $first) {
|
||||
@if (!first) {
|
||||
<span class="source-row__sep" aria-hidden="true">·</span>
|
||||
}
|
||||
<span
|
||||
class="source-row__part"
|
||||
[class.source-row__part--url]="part.kind === 'url'"
|
||||
[class.source-row__part--number]="part.kind === 'channels'"
|
||||
>{{ part.text }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (busy() && busyMessage()) {
|
||||
<div class="source-row__busy" role="status">
|
||||
<span class="source-row__busy-message">{{
|
||||
busyMessage()
|
||||
}}</span>
|
||||
@if (busyProgress() !== null) {
|
||||
<span class="source-row__busy-value"
|
||||
>{{ busyProgress() }}%</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<mat-progress-bar
|
||||
class="source-row__busy-bar"
|
||||
[mode]="
|
||||
busyProgress() === null ? 'indeterminate' : 'determinate'
|
||||
"
|
||||
[value]="busyProgress() ?? 0"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="source-row__actions">
|
||||
@if (compact()) {
|
||||
@if (current()) {
|
||||
<mat-icon class="source-row__check" aria-hidden="true"
|
||||
>check</mat-icon
|
||||
>
|
||||
}
|
||||
<!-- Pointer-only: a focusable control inside a listbox option is
|
||||
an invalid structure. The list opens this menu from the
|
||||
keyboard (Shift+F10). -->
|
||||
<span
|
||||
class="source-row__more source-row__more--compact"
|
||||
aria-hidden="true"
|
||||
[matMenuTriggerFor]="menu"
|
||||
[matMenuTriggerRestoreFocus]="false"
|
||||
(menuOpened)="menuOpen.set(true)"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>more_horiz</mat-icon>
|
||||
</span>
|
||||
} @else {
|
||||
@if (canCancelBusy()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-row__action source-row__action--visible source-row__cancel"
|
||||
(click)="
|
||||
$event.stopPropagation(); cancelBusyRequested.emit()
|
||||
"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (failing() && canRecheck()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="source-row__retry"
|
||||
[disabled]="busy()"
|
||||
(click)="$event.stopPropagation(); recheck()"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
{{ 'RETRY' | translate }}
|
||||
</button>
|
||||
} @else if (canRefresh()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-row__action source-row__refresh"
|
||||
[class.source-row__action--visible]="refreshing()"
|
||||
[disabled]="busy()"
|
||||
(click)="$event.stopPropagation(); refreshRequested.emit()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
[attr.aria-label]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
aria-keyshortcuts="R"
|
||||
>
|
||||
@if (refreshing()) {
|
||||
<mat-spinner diameter="14" strokeWidth="2" />
|
||||
} @else {
|
||||
<mat-icon>sync</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-row__action source-row__edit"
|
||||
[disabled]="busy()"
|
||||
(click)="$event.stopPropagation(); editRequested.emit()"
|
||||
[matTooltip]="'WORKSPACE.SOURCES.ROW.EDIT' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.SOURCES.ROW.EDIT' | translate"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="source-row__action source-row__more"
|
||||
[disabled]="busy()"
|
||||
[matMenuTriggerFor]="menu"
|
||||
(menuOpened)="menuOpen.set(true)"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.PLAYLIST_ACTIONS' | translate"
|
||||
[attr.aria-label]="
|
||||
'HOME.PLAYLISTS.PLAYLIST_ACTIONS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>more_horiz</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-menu #menu="matMenu" class="app-menu" xPosition="before">
|
||||
<ng-template matMenuContent>
|
||||
@if (canRefresh()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="refreshRequested.emit()"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.REFRESH' | translate }}</span>
|
||||
@if (!compact()) {
|
||||
<kbd class="app-menu-item__kbd" aria-hidden="true">R</kbd>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (compact()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="playlistInfoRequested.emit()"
|
||||
>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
|
||||
</button>
|
||||
@if (hasAccountInfo()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="accountInfoRequested.emit()"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button mat-menu-item type="button" (click)="editRequested.emit()">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SOURCES.ROW.EDIT' | translate }}</span>
|
||||
</button>
|
||||
@if (copyUrl()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
(click)="copyUrlToClipboard()"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SOURCES.ROW.COPY_URL' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
<button mat-menu-item type="button" (click)="activated.emit()">
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SOURCES.ROW.OPEN' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (canRecheck()) {
|
||||
<button mat-menu-item type="button" (click)="recheck()">
|
||||
<mat-icon>network_check</mat-icon>
|
||||
<span>{{ 'SOURCE_HEALTH.RECHECK' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
class="app-menu-item--danger"
|
||||
[disabled]="busy() || !removable()"
|
||||
(click)="removeRequested.emit()"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.REMOVE' | translate }}</span>
|
||||
</button>
|
||||
</ng-template>
|
||||
</mat-menu>
|
||||
@@ -0,0 +1,366 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use '../../../../../../ui/styles/app-controls' as controls;
|
||||
@use '../../../../../../ui/styles/focus-ring';
|
||||
|
||||
// `--source-row-base` is the surface the list sits on (the page or the
|
||||
// popover). Row fills are solid mixes of it, so the status dot's ring, which
|
||||
// takes the row's fill, always matches what is behind the dot.
|
||||
:host {
|
||||
display: block;
|
||||
--source-row-base: var(--app-content-bg);
|
||||
}
|
||||
|
||||
.source-row {
|
||||
--source-row-fill: var(--source-row-base);
|
||||
--source-row-ring: var(--source-row-fill);
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 56px;
|
||||
padding: 8px 12px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background: var(--source-row-fill);
|
||||
cursor: pointer;
|
||||
|
||||
&:has(> .source-row__handle) {
|
||||
grid-template-columns: 18px 36px minmax(0, 1fr) auto;
|
||||
gap: 10px 14px;
|
||||
padding-inline-start: 4px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&.source-row--menu-open {
|
||||
--source-row-fill: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 5%,
|
||||
var(--source-row-base)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.source-row--busy {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// ── Compact (playlist switcher) ──────────────────────────────────────────
|
||||
.source-row--compact {
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
min-height: 52px;
|
||||
padding: 0 8px;
|
||||
border-radius: 9px;
|
||||
|
||||
&.source-row--highlighted {
|
||||
--source-row-fill: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 7%,
|
||||
var(--source-row-base)
|
||||
);
|
||||
// The listbox keeps focus in its search field; the highlighted
|
||||
// option is the focus indicator.
|
||||
@include focus-ring.focus-ring-declarations($offset: -2px);
|
||||
}
|
||||
}
|
||||
|
||||
// The active source, on the Sources page and in the switcher: the same
|
||||
// selected-row look as the side lists (a soft tint, and an outline in light
|
||||
// theme). The dot's ring approximates the tint.
|
||||
.source-row--current {
|
||||
--source-row-ring: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 16%,
|
||||
var(--source-row-base)
|
||||
);
|
||||
background: var(--app-list-selected-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--app-list-selected-outline);
|
||||
}
|
||||
|
||||
// ── Tile column ──────────────────────────────────────────────────────────
|
||||
.source-row__handle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
color: var(--app-muted-color);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
// Set on the tile element itself: its host declares defaults, which an
|
||||
// inherited value would not override.
|
||||
app-source-tile {
|
||||
--source-tile-ring: var(--source-row-ring);
|
||||
}
|
||||
|
||||
.source-row--failing app-source-tile {
|
||||
--source-tile-color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
// ── Text column ──────────────────────────────────────────────────────────
|
||||
.source-row__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
// The row draws this element's focus ring.
|
||||
outline: none;
|
||||
|
||||
.source-row--compact & {
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// The keyboard ring belongs to the whole row, the unit that opens; inset,
|
||||
// since the list's scroller would cut one drawn outside the row.
|
||||
.source-row:has(> .source-row__body:focus-visible) {
|
||||
@include focus-ring.focus-ring-declarations($offset: -2px);
|
||||
}
|
||||
|
||||
.source-row__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-row__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: var(--app-heading-color);
|
||||
|
||||
.source-row--compact & {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.source-row--failing & {
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
.source-row__tag {
|
||||
flex: none;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-danger-surface);
|
||||
color: var(--app-danger-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-row__tag--neutral {
|
||||
background: var(--app-neutral-tag-surface);
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.source-row__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.35;
|
||||
color: var(--app-body-color);
|
||||
|
||||
.source-row--compact & {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.source-row__sep {
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.source-row__part {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.source-row__part--number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.source-row__part--url {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--app-font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.source-row__busy {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.source-row__busy-message {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-row__busy-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.source-row__busy-bar {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
.source-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.source-row__action {
|
||||
@include controls.ghost-icon-button(32px, 14px);
|
||||
}
|
||||
|
||||
.source-row__retry {
|
||||
@include controls.ghost-button(sm);
|
||||
}
|
||||
|
||||
// Cancelling a running refresh or delete reads as destructive.
|
||||
.source-row__cancel {
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
icon-color: var(--mat-sys-error),
|
||||
)
|
||||
);
|
||||
|
||||
&:not([disabled]):hover {
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
icon-color: var(--mat-sys-error),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Row actions appear on hover, keyboard focus and while the menu is open.
|
||||
// A failing row keeps Retry in view; touch screens, which cannot hover,
|
||||
// always show them.
|
||||
.source-row__action,
|
||||
.source-row__more--compact {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.source-row:hover,
|
||||
.source-row:focus-within,
|
||||
.source-row--menu-open,
|
||||
.source-row--current,
|
||||
.source-row--highlighted {
|
||||
.source-row__action,
|
||||
.source-row__more--compact {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.source-row__action--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.source-row__action,
|
||||
.source-row__more--compact {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.source-row__action,
|
||||
.source-row__more--compact {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.source-row__check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
.source-row__more--compact {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
color: var(--app-body-color);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag and drop (custom order) ─────────────────────────────────────────
|
||||
.cdk-drag-preview {
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cdk-drag-animating {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Phones keep the ⋯ menu, which holds every action, and drop the rest.
|
||||
@media (max-width: 480px) {
|
||||
.source-row {
|
||||
gap: 10px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.source-row__action:not(.source-row__more):not(
|
||||
.source-row__action--visible
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-row__part--url,
|
||||
.source-row__sep:has(+ .source-row__part--url) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// UI-kit menu panel. The panel renders in the overlay container, so the
|
||||
// rule is global; it ships with the components that use `class="app-menu"`
|
||||
// rather than in the app's initial stylesheet.
|
||||
::ng-deep .mat-mdc-menu-panel.app-menu {
|
||||
@include controls.menu-panel;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces';
|
||||
import { SourceClock } from './source-clock';
|
||||
import { SourceRowComponent } from './source-row.component';
|
||||
|
||||
const M3U: PlaylistMeta = {
|
||||
_id: 'm3u',
|
||||
title: 'get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
url: 'http://biggie.to:8080/get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
count: 1803,
|
||||
importDate: new Date(2026, 8, 18).toISOString(),
|
||||
autoRefresh: false,
|
||||
};
|
||||
const LOCAL: PlaylistMeta = {
|
||||
_id: 'local',
|
||||
title: 'playlist (13)',
|
||||
count: 3220,
|
||||
importDate: new Date(2026, 8, 30).toISOString(),
|
||||
autoRefresh: false,
|
||||
};
|
||||
|
||||
describe('SourceRowComponent', () => {
|
||||
let fixture: ComponentFixture<SourceRowComponent>;
|
||||
let statuses: Map<string, SourceStatus>;
|
||||
let statusService: {
|
||||
statusOf: jest.Mock;
|
||||
tracks: jest.Mock;
|
||||
ensureChecked: jest.Mock;
|
||||
recheck: jest.Mock;
|
||||
};
|
||||
|
||||
const render = (
|
||||
playlist: PlaylistMeta,
|
||||
inputs: Record<string, unknown> = {}
|
||||
): HTMLElement => {
|
||||
fixture = TestBed.createComponent(SourceRowComponent);
|
||||
fixture.componentRef.setInput('playlist', playlist);
|
||||
for (const [name, value] of Object.entries(inputs)) {
|
||||
fixture.componentRef.setInput(name, value);
|
||||
}
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
statuses = new Map();
|
||||
statusService = {
|
||||
statusOf: jest.fn((p: PlaylistMeta) =>
|
||||
p.url || p.serverUrl ? (statuses.get(p._id) ?? null) : null
|
||||
),
|
||||
tracks: jest.fn((p: PlaylistMeta) => !!(p.url || p.serverUrl)),
|
||||
ensureChecked: jest.fn(),
|
||||
recheck: jest.fn(),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SourceRowComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: SourceStatusService, useValue: statusService },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the host and a credential-free URL, never the password', () => {
|
||||
const element = render(M3U);
|
||||
expect(element.querySelector('.source-row__name')?.textContent).toBe(
|
||||
'biggie.to:8080'
|
||||
);
|
||||
expect(
|
||||
element.querySelector('.source-row__part--url')?.textContent
|
||||
).toBe('http://biggie.to:8080/get.php?type=m3u_plus');
|
||||
expect(element.textContent).not.toMatch(/hunter2|password=/);
|
||||
expect(
|
||||
element
|
||||
.querySelector('.source-row__body')
|
||||
?.getAttribute('aria-label')
|
||||
).toBe('biggie.to:8080');
|
||||
});
|
||||
|
||||
it('draws no status dot for a local file', () => {
|
||||
const element = render(LOCAL);
|
||||
expect(element.querySelector('.source-tile__dot')).toBeNull();
|
||||
expect(element.querySelector('.source-row__part--url')).toBeNull();
|
||||
});
|
||||
|
||||
it('tags a failing source and offers Retry, which rechecks it', () => {
|
||||
statuses.set('m3u', { reachability: 'bad' });
|
||||
const element = render(M3U, { canRefresh: true });
|
||||
expect(element.querySelector('.source-row__tag')?.textContent).toBe(
|
||||
'WORKSPACE.SOURCES.ROW.UNREACHABLE'
|
||||
);
|
||||
const retry =
|
||||
element.querySelector<HTMLButtonElement>('.source-row__retry');
|
||||
expect(retry).not.toBeNull();
|
||||
retry?.click();
|
||||
expect(statusService.recheck).toHaveBeenCalledWith(M3U);
|
||||
expect(
|
||||
element
|
||||
.querySelector('.source-tile__dot')
|
||||
?.getAttribute('data-state')
|
||||
).toBe('bad');
|
||||
});
|
||||
|
||||
it("names the check's cause on the dot, and an M3U check's scope", () => {
|
||||
const dotLabel = (element: HTMLElement) =>
|
||||
element
|
||||
.querySelector('.source-tile__dot')
|
||||
?.getAttribute('aria-label');
|
||||
statuses.set('m3u', { reachability: 'bad', reason: 'auth' });
|
||||
expect(dotLabel(render(M3U))).toBe(
|
||||
'SOURCE_HEALTH.REASON.auth · SOURCE_HEALTH.M3U_SCOPE'
|
||||
);
|
||||
|
||||
// Without a kept cause the dot falls back to its colour's wording.
|
||||
statuses.set('xtream', { reachability: 'bad' });
|
||||
expect(
|
||||
dotLabel(
|
||||
render({
|
||||
...M3U,
|
||||
_id: 'xtream',
|
||||
url: undefined,
|
||||
serverUrl: 'http://portal.test',
|
||||
username: 'joe',
|
||||
password: 'hunter2',
|
||||
})
|
||||
)
|
||||
).toBe('SOURCE_HEALTH.REASON.network');
|
||||
});
|
||||
|
||||
it("keeps counting a failure's duration while the list stays open", () => {
|
||||
const start = Date.UTC(2026, 9, 13, 12);
|
||||
const now = signal(start);
|
||||
TestBed.overrideProvider(SourceClock, { useValue: { now } });
|
||||
statuses.set('m3u', {
|
||||
reachability: 'bad',
|
||||
failingSince: start - 10 * 60_000,
|
||||
});
|
||||
const element = render(M3U);
|
||||
const tag = () =>
|
||||
element.querySelector('.source-row__tag')?.textContent?.trim();
|
||||
expect(tag()).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE');
|
||||
|
||||
now.set(start + 2 * 60 * 60_000);
|
||||
fixture.detectChanges();
|
||||
expect(tag()).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR');
|
||||
});
|
||||
|
||||
it('opens on click and on Enter, but not while busy', () => {
|
||||
const element = render(M3U);
|
||||
const opened = jest.fn();
|
||||
fixture.componentInstance.activated.subscribe(opened);
|
||||
|
||||
element.querySelector<HTMLElement>('.source-row')?.click();
|
||||
element
|
||||
.querySelector('.source-row__body')
|
||||
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||
expect(opened).toHaveBeenCalledTimes(2);
|
||||
|
||||
fixture.componentRef.setInput('deleting', true);
|
||||
fixture.detectChanges();
|
||||
element.querySelector<HTMLElement>('.source-row')?.click();
|
||||
expect(opened).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not open the source from a click on the drag handle', () => {
|
||||
const element = render(M3U, { draggable: true });
|
||||
const opened = jest.fn();
|
||||
fixture.componentInstance.activated.subscribe(opened);
|
||||
element.querySelector<HTMLElement>('.source-row__handle')?.click();
|
||||
expect(opened).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the focused row with R', () => {
|
||||
const element = render(M3U, { canRefresh: true });
|
||||
const refresh = jest.fn();
|
||||
fixture.componentInstance.refreshRequested.subscribe(refresh);
|
||||
const row = element.querySelector('.source-row');
|
||||
|
||||
row?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'r', bubbles: true })
|
||||
);
|
||||
row?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'r',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is a plain option in the switcher, with a pointer-only menu', () => {
|
||||
const element = render(M3U, { size: 'compact', current: true });
|
||||
expect(
|
||||
element.querySelector('.source-row__body')?.getAttribute('role')
|
||||
).toBeNull();
|
||||
expect(element.querySelector('button')).toBeNull();
|
||||
expect(
|
||||
element
|
||||
.querySelector('.source-row__more--compact')
|
||||
?.getAttribute('aria-hidden')
|
||||
).toBe('true');
|
||||
expect(element.querySelector('.source-row__check')).not.toBeNull();
|
||||
expect(element.querySelector('.source-row__part--url')).toBeNull();
|
||||
});
|
||||
|
||||
it('checks the source while shown and cancels when removed', () => {
|
||||
render(M3U);
|
||||
expect(statusService.ensureChecked).toHaveBeenCalledWith(
|
||||
M3U,
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
const signal = statusService.ensureChecked.mock.calls[0][1];
|
||||
fixture.destroy();
|
||||
expect((signal as AbortSignal).aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not check the source when told not to', () => {
|
||||
render(M3U, { checkHealth: false });
|
||||
expect(statusService.ensureChecked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
|
||||
import { injectTranslationTick } from '@iptvnator/services';
|
||||
import {
|
||||
isPortalAccountPlaylist,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
sourceCopyUrl,
|
||||
sourceDisplayName,
|
||||
SourceFormatContext,
|
||||
sourceMetaParts,
|
||||
sourceStatusTag,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import { SourceClock } from './source-clock';
|
||||
import { SourceTileComponent } from './source-tile.component';
|
||||
|
||||
/**
|
||||
* One source in a list: the Sources page (`regular`, 56px, hover actions)
|
||||
* or the playlist switcher (`compact`, 52px, a ⋯ menu only).
|
||||
*
|
||||
* The regular row opens from the keyboard through its title block, a
|
||||
* `role="button"` sibling of the drag handle and the actions, so no control
|
||||
* sits inside a button. In the switcher the host is a listbox option driven
|
||||
* by `aria-activedescendant`; its ⋯ is pointer-only and the list opens the
|
||||
* menu from the keyboard (`openMenu()`).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-source-row',
|
||||
imports: [
|
||||
DragDropModule,
|
||||
MatButton,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatMenuModule,
|
||||
MatProgressBar,
|
||||
MatProgressSpinner,
|
||||
MatTooltip,
|
||||
SourceTileComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './source-row.component.html',
|
||||
styleUrl: './source-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SourceRowComponent {
|
||||
private readonly statusService = inject(SourceStatusService);
|
||||
private readonly clock = inject(SourceClock);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly menuTrigger = viewChild(MatMenuTrigger);
|
||||
|
||||
readonly playlist = input.required<PlaylistMeta>();
|
||||
readonly size = input<'regular' | 'compact'>('regular');
|
||||
/** The active playlist (switcher): filled row, check mark, ⋯ shown. */
|
||||
readonly current = input(false);
|
||||
/** The keyboard highlight of the switcher's listbox. */
|
||||
readonly highlighted = input(false);
|
||||
/** Runs a passive reachability check while the row is shown. */
|
||||
readonly checkHealth = input(true);
|
||||
readonly canRefresh = input(false);
|
||||
readonly draggable = input(false);
|
||||
readonly refreshing = input(false);
|
||||
readonly deleting = input(false);
|
||||
readonly busyMessage = input('');
|
||||
readonly busyProgress = input<number | null>(null);
|
||||
readonly canCancelBusy = input(false);
|
||||
/** False while something else, such as a refresh, holds the source. */
|
||||
readonly removable = input(true);
|
||||
|
||||
readonly activated = output<void>();
|
||||
readonly refreshRequested = output<void>();
|
||||
readonly editRequested = output<void>();
|
||||
readonly removeRequested = output<void>();
|
||||
readonly cancelBusyRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly menuClosed = output<void>();
|
||||
|
||||
readonly menuOpen = signal(false);
|
||||
readonly compact = computed(() => this.size() === 'compact');
|
||||
readonly busy = computed(() => this.refreshing() || this.deleting());
|
||||
readonly status = computed(() =>
|
||||
this.statusService.statusOf(this.playlist())
|
||||
);
|
||||
readonly failing = computed(() => {
|
||||
const reachability = this.status()?.reachability;
|
||||
return reachability === 'bad' || reachability === 'expired';
|
||||
});
|
||||
readonly canRecheck = computed(() =>
|
||||
this.statusService.tracks(this.playlist())
|
||||
);
|
||||
readonly copyUrl = computed(() => sourceCopyUrl(this.playlist()));
|
||||
readonly hasAccountInfo = computed(() =>
|
||||
isPortalAccountPlaylist(this.playlist())
|
||||
);
|
||||
|
||||
private readonly formatContext = computed<SourceFormatContext>(() => {
|
||||
this.languageTick();
|
||||
return {
|
||||
translate: (key, params) => this.translate.instant(key, params),
|
||||
locale: normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
),
|
||||
now: Date.now(),
|
||||
};
|
||||
});
|
||||
readonly name = computed(() => {
|
||||
this.languageTick();
|
||||
return (
|
||||
sourceDisplayName(this.playlist()) ??
|
||||
this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
|
||||
);
|
||||
});
|
||||
readonly meta = computed(() =>
|
||||
sourceMetaParts(this.playlist(), this.formatContext(), this.compact())
|
||||
);
|
||||
/** Reads the shared clock, so a failure's duration keeps counting. */
|
||||
readonly tag = computed(() =>
|
||||
sourceStatusTag(
|
||||
this.status(),
|
||||
{ ...this.formatContext(), now: this.clock.now() },
|
||||
this.compact()
|
||||
)
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const playlist = this.playlist();
|
||||
if (!this.checkHealth()) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
untracked(() =>
|
||||
this.statusService.ensureChecked(playlist, controller.signal)
|
||||
);
|
||||
onCleanup(() => controller.abort());
|
||||
});
|
||||
}
|
||||
|
||||
onRowClick(): void {
|
||||
if (!this.busy()) {
|
||||
this.activated.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter/Space on the title block; Space must not scroll the page. */
|
||||
onActivationKey(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.onRowClick();
|
||||
}
|
||||
|
||||
/** `R` refreshes the focused row, as its menu item says. */
|
||||
onRowKeydown(event: KeyboardEvent): void {
|
||||
const plain =
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey;
|
||||
if (
|
||||
plain &&
|
||||
event.key.toLowerCase() === 'r' &&
|
||||
!this.compact() &&
|
||||
this.canRefresh() &&
|
||||
!this.busy()
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.refreshRequested.emit();
|
||||
}
|
||||
}
|
||||
|
||||
recheck(): void {
|
||||
this.statusService.recheck(this.playlist());
|
||||
}
|
||||
|
||||
copyUrlToClipboard(): void {
|
||||
const url = this.copyUrl();
|
||||
if (url && this.clipboard.copy(url)) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the ⋯ menu from the keyboard (switcher: Shift+F10). */
|
||||
openMenu(): void {
|
||||
this.menuTrigger()?.openMenu();
|
||||
}
|
||||
|
||||
onMenuClosed(): void {
|
||||
this.menuOpen.set(false);
|
||||
this.menuClosed.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// The provider tile of a source row and the switcher trigger. The dot's
|
||||
// 2px ring takes `--source-tile-ring`, which the row sets to its own
|
||||
// background, so the dot reads as cut out of the tile in every state.
|
||||
:host {
|
||||
// Defaults a row or trigger overrides on the `app-source-tile` element.
|
||||
--source-tile-bg: var(--app-widget-bg);
|
||||
--source-tile-color: var(--app-body-color);
|
||||
--source-tile-ring: var(--app-content-bg);
|
||||
--source-tile-size: 36px;
|
||||
--source-tile-radius: 9px;
|
||||
--source-tile-icon: 17px;
|
||||
--source-tile-dot: 11px;
|
||||
--source-tile-dot-offset: -3px;
|
||||
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--source-tile-size);
|
||||
height: var(--source-tile-size);
|
||||
border-radius: var(--source-tile-radius);
|
||||
background: var(--source-tile-bg);
|
||||
color: var(--source-tile-color);
|
||||
}
|
||||
|
||||
:host(.source-tile--md) {
|
||||
--source-tile-size: 32px;
|
||||
--source-tile-radius: 8px;
|
||||
--source-tile-icon: 15px;
|
||||
--source-tile-dot: 9px;
|
||||
}
|
||||
|
||||
:host(.source-tile--sm) {
|
||||
--source-tile-size: 28px;
|
||||
--source-tile-radius: 7px;
|
||||
--source-tile-icon: 14px;
|
||||
--source-tile-dot: 9px;
|
||||
--source-tile-dot-offset: -2px;
|
||||
}
|
||||
|
||||
.source-tile__icon {
|
||||
width: var(--source-tile-icon);
|
||||
height: var(--source-tile-icon);
|
||||
font-size: var(--source-tile-icon);
|
||||
line-height: var(--source-tile-icon);
|
||||
}
|
||||
|
||||
.source-tile__dot {
|
||||
position: absolute;
|
||||
right: var(--source-tile-dot-offset);
|
||||
bottom: var(--source-tile-dot-offset);
|
||||
box-sizing: border-box;
|
||||
width: var(--source-tile-dot);
|
||||
height: var(--source-tile-dot);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--source-tile-ring);
|
||||
background: var(--app-muted-color);
|
||||
|
||||
&[data-state='ok'] {
|
||||
background: var(--app-success-color);
|
||||
}
|
||||
|
||||
&[data-state='bad'] {
|
||||
background: var(--app-live-color);
|
||||
}
|
||||
|
||||
&[data-state='expired'] {
|
||||
background: var(--app-expiry-warn-text);
|
||||
}
|
||||
}
|
||||
|
||||
.source-tile__dot--pending {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { injectTranslationTick } from '@iptvnator/services';
|
||||
import {
|
||||
getPlaylistSourceIcon,
|
||||
PlaylistMeta,
|
||||
resolvePlaylistSourceKind,
|
||||
SourceReachability,
|
||||
SourceStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* The health reason shown for each reachability when the check left no
|
||||
* cause, as in a browser build's "checking" status.
|
||||
*/
|
||||
const REACHABILITY_REASON: Record<SourceReachability, string> = {
|
||||
ok: 'available',
|
||||
bad: 'network',
|
||||
expired: 'expired',
|
||||
checking: 'checking',
|
||||
unknown: 'unknown',
|
||||
};
|
||||
|
||||
/**
|
||||
* The provider icon on a rounded tile, with the status dot at its corner:
|
||||
* green answers, red does not, orange is an expired account, grey is not
|
||||
* checked yet. Local files have no dot.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-source-tile',
|
||||
imports: [MatIcon, MatTooltip],
|
||||
template: `
|
||||
<mat-icon class="source-tile__icon" aria-hidden="true">{{
|
||||
icon()
|
||||
}}</mat-icon>
|
||||
@if (status(); as status) {
|
||||
<span
|
||||
class="source-tile__dot"
|
||||
role="img"
|
||||
[attr.data-state]="dotState()"
|
||||
[class.source-tile__dot--pending]="status.pending"
|
||||
[attr.aria-label]="description()"
|
||||
[matTooltip]="description()"
|
||||
></span>
|
||||
}
|
||||
`,
|
||||
styleUrl: './source-tile.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'source-tile',
|
||||
'[class.source-tile--sm]': "size() === 'sm'",
|
||||
'[class.source-tile--md]': "size() === 'md'",
|
||||
'[class.source-tile--lg]': "size() === 'lg'",
|
||||
},
|
||||
})
|
||||
export class SourceTileComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = injectTranslationTick();
|
||||
|
||||
readonly playlist =
|
||||
input.required<
|
||||
Pick<PlaylistMeta, 'macAddress' | 'serverUrl' | 'url'>
|
||||
>();
|
||||
readonly status = input<SourceStatus | null>(null);
|
||||
/** 28px (trigger), 32px (switcher) or 36px (Sources page). */
|
||||
readonly size = input<'sm' | 'md' | 'lg'>('lg');
|
||||
|
||||
readonly icon = computed(() => getPlaylistSourceIcon(this.playlist()));
|
||||
readonly dotState = computed(() => {
|
||||
const reachability = this.status()?.reachability;
|
||||
return reachability === 'checking' ? 'unknown' : reachability;
|
||||
});
|
||||
readonly description = computed(() => {
|
||||
this.languageTick();
|
||||
const status = this.status();
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
const reason = status.pending
|
||||
? 'checking'
|
||||
: (status.reason ?? REACHABILITY_REASON[status.reachability]);
|
||||
const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`);
|
||||
// An M3U check reaches the playlist URL, not its channels.
|
||||
const scope =
|
||||
resolvePlaylistSourceKind(this.playlist()) === 'm3u'
|
||||
? this.translate.instant('SOURCE_HEALTH.M3U_SCOPE')
|
||||
: '';
|
||||
const time = status.checkedAt
|
||||
? new Date(status.checkedAt).toLocaleString(
|
||||
normalizeDateLocale(this.translate.currentLang || 'en'),
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}
|
||||
)
|
||||
: '';
|
||||
return [label, scope, time].filter(Boolean).join(' · ');
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DatabaseService,
|
||||
DbOperationEvent,
|
||||
PlaybackPositionService,
|
||||
SourceActivityService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
@@ -168,6 +169,42 @@ describe('XtreamRefreshFlowService', () => {
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('holds the source in the shared registry for the run, and skips a held one', async () => {
|
||||
const activity = TestBed.inject(SourceActivityService);
|
||||
const item = createPlaylistMeta();
|
||||
let heldDuringDelete = false;
|
||||
let runningDuringDelete = false;
|
||||
databaseService.deleteXtreamPlaylistContent.mockImplementationOnce(
|
||||
() => {
|
||||
heldDuringDelete = activity.isBusy(item._id);
|
||||
runningDuringDelete = service.isRunning(item._id);
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
hiddenCategories: [],
|
||||
});
|
||||
}
|
||||
);
|
||||
service.confirmAndRefresh(item, createReporter().reporter);
|
||||
await confirmPromise;
|
||||
expect(heldDuringDelete).toBe(true);
|
||||
expect(runningDuringDelete).toBe(true);
|
||||
expect(activity.isBusy(item._id)).toBe(false);
|
||||
expect(service.isRunning(item._id)).toBe(false);
|
||||
|
||||
// A delete or another refresh already holds it: nothing runs.
|
||||
const release = activity.begin([item._id]);
|
||||
const second = createReporter();
|
||||
service.confirmAndRefresh(item, second.reporter);
|
||||
await confirmPromise;
|
||||
expect(second.calls).toEqual([]);
|
||||
expect(
|
||||
databaseService.deleteXtreamPlaylistContent
|
||||
).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
});
|
||||
|
||||
it('serializes destructive runs for one playlist across entry points', async () => {
|
||||
const item = createPlaylistMeta();
|
||||
const confirms: Array<Promise<void> | undefined> = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isDbAbortError,
|
||||
PlaybackPositionService,
|
||||
resetHostConnectivityGuard,
|
||||
SourceActivityService,
|
||||
XtreamPendingRestoreService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
@@ -79,6 +80,7 @@ export class XtreamRefreshFlowService {
|
||||
private readonly pendingRestoreService = inject(
|
||||
XtreamPendingRestoreService
|
||||
);
|
||||
private readonly activity = inject(SourceActivityService);
|
||||
|
||||
/**
|
||||
* Playlists with a destructive run in flight, across every entry point.
|
||||
@@ -91,7 +93,30 @@ export class XtreamRefreshFlowService {
|
||||
* losing favorites, history, hidden categories and playback positions on
|
||||
* re-import. This service is the only place that sees both.
|
||||
*/
|
||||
private readonly activeRefreshPlaylistIds = new Set<string>();
|
||||
private readonly activeRefreshPlaylistIds = signal<ReadonlySet<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether a destructive run is in flight for the playlist. Opening the
|
||||
* playlist meanwhile would start its import against a half-deleted
|
||||
* catalog, before the user data to restore has been parked.
|
||||
*/
|
||||
isRunning(playlistId: string): boolean {
|
||||
return this.activeRefreshPlaylistIds().has(playlistId);
|
||||
}
|
||||
|
||||
private setRunning(playlistId: string, running: boolean): void {
|
||||
this.activeRefreshPlaylistIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (running) {
|
||||
next.add(playlistId);
|
||||
} else {
|
||||
next.delete(playlistId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for confirmation, then deletes and re-imports the playlist. Callers
|
||||
@@ -119,8 +144,11 @@ export class XtreamRefreshFlowService {
|
||||
item: PlaylistMeta,
|
||||
reporter: XtreamRefreshProgressReporter
|
||||
): Promise<void> {
|
||||
// The shared registry also holds deletes, the startup auto-refresh
|
||||
// and M3U refreshes from either entry point.
|
||||
if (
|
||||
this.activeRefreshPlaylistIds.has(item._id) ||
|
||||
this.isRunning(item._id) ||
|
||||
this.activity.isBusy(item._id) ||
|
||||
reporter.isBusy(item._id)
|
||||
) {
|
||||
return;
|
||||
@@ -130,7 +158,8 @@ export class XtreamRefreshFlowService {
|
||||
this.databaseService.createOperationId('xtream-refresh');
|
||||
const run: XtreamRefreshRun = { playlistId: item._id, operationId };
|
||||
reporter.begin(run);
|
||||
this.activeRefreshPlaylistIds.add(item._id);
|
||||
this.setRunning(item._id, true);
|
||||
const release = this.activity.begin([item._id]);
|
||||
|
||||
try {
|
||||
// Before anything destructive: this deletes the cached catalog and
|
||||
@@ -204,7 +233,8 @@ export class XtreamRefreshFlowService {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.activeRefreshPlaylistIds.delete(item._id);
|
||||
this.setRunning(item._id, false);
|
||||
release();
|
||||
reporter.end(run);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './lib/playlist-context.facade';
|
||||
export * from './lib/playlist-recent-history.service';
|
||||
export * from './lib/playlist-file-import.service';
|
||||
export * from './lib/source-display/source-display';
|
||||
export * from './lib/source-display/source-format';
|
||||
export * from './lib/source-display/source-list';
|
||||
export * from './lib/source-display/sources-view-state.service';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { PlaylistRecentHistoryService } from './playlist-recent-history.service';
|
||||
|
||||
export interface PlaylistRouteContext {
|
||||
inWorkspace: boolean;
|
||||
@@ -73,6 +74,7 @@ export class PlaylistContextFacade {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly recentHistory = inject(PlaylistRecentHistoryService);
|
||||
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
@@ -539,6 +541,7 @@ export class PlaylistContextFacade {
|
||||
}
|
||||
|
||||
private writePersistedActivePlaylistId(playlistId: string): void {
|
||||
this.recentHistory.record(playlistId);
|
||||
try {
|
||||
localStorage.setItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY, playlistId);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
PLAYLIST_RECENT_HISTORY_STORAGE_KEY,
|
||||
PlaylistRecentHistoryService,
|
||||
} from './playlist-recent-history.service';
|
||||
|
||||
describe('PlaylistRecentHistoryService', () => {
|
||||
const create = () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [PlaylistRecentHistoryService],
|
||||
});
|
||||
return TestBed.inject(PlaylistRecentHistoryService);
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('keeps the three most recently opened, newest first', () => {
|
||||
const history = create();
|
||||
for (const id of ['a', 'b', 'c', 'b', 'd']) {
|
||||
history.record(id);
|
||||
}
|
||||
expect(history.ids()).toEqual(['d', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('survives a restart and ignores unreadable storage', () => {
|
||||
create().record('a');
|
||||
TestBed.resetTestingModule();
|
||||
expect(create().ids()).toEqual(['a']);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
localStorage.setItem(PLAYLIST_RECENT_HISTORY_STORAGE_KEY, '{oops');
|
||||
expect(create().ids()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export const PLAYLIST_RECENT_HISTORY_STORAGE_KEY =
|
||||
'playlist-switcher:recent:v1';
|
||||
/** The switcher's "Recent" group shows this many. */
|
||||
export const PLAYLIST_RECENT_HISTORY_SIZE = 3;
|
||||
|
||||
function readIds(): string[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
localStorage.getItem(PLAYLIST_RECENT_HISTORY_STORAGE_KEY) ?? '[]'
|
||||
);
|
||||
return Array.isArray(parsed)
|
||||
? parsed
|
||||
.filter((id): id is string => typeof id === 'string' && !!id)
|
||||
.slice(0, PLAYLIST_RECENT_HISTORY_SIZE)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The playlists opened most recently, newest first, kept across restarts.
|
||||
* IDs of deleted playlists stay until pushed out; readers skip the ones that
|
||||
* no longer exist.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistRecentHistoryService {
|
||||
private readonly state = signal<readonly string[]>(readIds());
|
||||
readonly ids = this.state.asReadonly();
|
||||
|
||||
record(playlistId: string): void {
|
||||
if (!playlistId || this.state()[0] === playlistId) {
|
||||
return;
|
||||
}
|
||||
const next = [
|
||||
playlistId,
|
||||
...this.state().filter((id) => id !== playlistId),
|
||||
].slice(0, PLAYLIST_RECENT_HISTORY_SIZE);
|
||||
this.state.set(next);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PLAYLIST_RECENT_HISTORY_STORAGE_KEY,
|
||||
JSON.stringify(next)
|
||||
);
|
||||
} catch {
|
||||
// The list still works for this session.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import {
|
||||
sourceCopyUrl,
|
||||
sourceDisplayName,
|
||||
sourceSortName,
|
||||
sourceDisplayUrl,
|
||||
sourceHost,
|
||||
} from './source-display';
|
||||
|
||||
const M3U_URL =
|
||||
'http://biggie.to:8080/get.php?username=joe&password=hunter2&type=m3u_plus';
|
||||
|
||||
describe('sourceDisplayName', () => {
|
||||
it('keeps a name the user chose', () => {
|
||||
expect(sourceDisplayName({ title: 'Free TV', url: M3U_URL })).toBe(
|
||||
'Free TV'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the host when the source has no name', () => {
|
||||
expect(sourceDisplayName({ title: '', url: M3U_URL })).toBe(
|
||||
'biggie.to:8080'
|
||||
);
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: '',
|
||||
serverUrl: 'http://xtream.tv:80',
|
||||
username: 'joe',
|
||||
password: 'hunter2',
|
||||
})
|
||||
).toBe('xtream.tv');
|
||||
});
|
||||
|
||||
it('shows the host for the name an URL import derives', () => {
|
||||
// The import stores the URL's last segment, query and all.
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
url: M3U_URL,
|
||||
})
|
||||
).toBe('biggie.to:8080');
|
||||
});
|
||||
|
||||
it('shows the host for a pasted URL', () => {
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'https://cdn.tv/lists/clean.m3u',
|
||||
url: 'https://cdn.tv/lists/clean.m3u',
|
||||
})
|
||||
).toBe('cdn.tv');
|
||||
});
|
||||
|
||||
it('drops a name that carries a credential', () => {
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'My portal pass== hunter2',
|
||||
serverUrl: 'http://xtream.tv',
|
||||
})
|
||||
).toBe('xtream.tv');
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'backup hunter2',
|
||||
serverUrl: 'http://xtream.tv',
|
||||
password: 'hunter2',
|
||||
})
|
||||
).toBe('xtream.tv');
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'Box 00:1A:79:00:00:01',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.tv/c/',
|
||||
})
|
||||
).toBe('portal.tv');
|
||||
});
|
||||
|
||||
it('keeps an account name in the name: it is shown by design', () => {
|
||||
expect(sourceDisplayName({ title: 'joe', url: M3U_URL })).toBe('joe');
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'joe',
|
||||
url: 'https://host.test/list?usr=joe&pass=hunter2',
|
||||
})
|
||||
).toBe('joe');
|
||||
});
|
||||
|
||||
it('reads keys, markers and values through every encoding layer', () => {
|
||||
for (const url of [
|
||||
'https://proxy.test/list?%2570assword=hunter2',
|
||||
'https://origin.test/%256Cive/joe/hunter2/1.ts',
|
||||
]) {
|
||||
expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe(
|
||||
new URL(url).host
|
||||
);
|
||||
}
|
||||
// A password encoded twice still matches the name it appears in.
|
||||
const url = `https://host.test/list?password=${encodeURIComponent(
|
||||
encodeURIComponent('pw#9876')
|
||||
)}`;
|
||||
expect(sourceDisplayName({ title: 'Mine pw#9876', url })).toBe(
|
||||
'host.test'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a name holding an account hidden behind encoded separators', () => {
|
||||
for (const url of [
|
||||
'https://origin.test/live%2Fjoe%2Fhunter2%2F1.ts',
|
||||
'https://origin.test/live/joe%2Fx/hunter2/1.ts',
|
||||
]) {
|
||||
expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe(
|
||||
'origin.test'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops a name holding a secret of a JSON config in the URL', () => {
|
||||
for (const json of [
|
||||
'{"password":"hunter2"}',
|
||||
'{"pass\\u0077ord":"hunter2"}',
|
||||
'{"proxy":{"upstream":"http://joe:hunter2@origin.test/a"}}',
|
||||
]) {
|
||||
const url = `https://proxy.test/list?config=${encodeURIComponent(json)}`;
|
||||
expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe(
|
||||
'proxy.test'
|
||||
);
|
||||
}
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'Mine',
|
||||
url: 'https://proxy.test/list?config=%7B%22hd%22%3A1%7D',
|
||||
})
|
||||
).toBe('Mine');
|
||||
});
|
||||
|
||||
it('reads a bracketed key that only starts like JSON as parameters', () => {
|
||||
const url = `https://proxy.test/list?config=${encodeURIComponent(
|
||||
'[password]=hunter2'
|
||||
)}`;
|
||||
expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe(
|
||||
'proxy.test'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a name that is nothing but a short password', () => {
|
||||
const xtream = {
|
||||
serverUrl: 'http://xtream.tv:8080',
|
||||
username: 'joe',
|
||||
password: 'pw',
|
||||
};
|
||||
expect(sourceDisplayName({ ...xtream, title: 'pw' })).toBe(
|
||||
'xtream.tv:8080'
|
||||
);
|
||||
expect(sourceDisplayName({ ...xtream, title: ' pw ' })).toBe(
|
||||
'xtream.tv:8080'
|
||||
);
|
||||
// A password has its own case: `PW` is not `pw`.
|
||||
expect(sourceDisplayName({ ...xtream, title: 'PW' })).toBe('PW');
|
||||
// Inside a longer name a short password is just letters.
|
||||
expect(sourceDisplayName({ ...xtream, title: 'Pwnova' })).toBe(
|
||||
'Pwnova'
|
||||
);
|
||||
expect(sourceDisplayUrl({ ...xtream, username: 'pw' })).toBe(
|
||||
'http://xtream.tv:8080'
|
||||
);
|
||||
});
|
||||
|
||||
it('finds a MAC from the URL whatever its letter case', () => {
|
||||
const mac = '00:1A:79:00:00:01';
|
||||
for (const url of [
|
||||
`https://host.test/list.m3u?mac=${mac}`,
|
||||
`https://proxy.test/list?url=${encodeURIComponent(
|
||||
`http://origin.test/c/?mac=${mac}`
|
||||
)}`,
|
||||
]) {
|
||||
expect(
|
||||
sourceDisplayName({ title: 'Box 00:1a:79:00:00:01', url })
|
||||
).toBe(new URL(url).host);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a name holding the password word in another case', () => {
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'Stress Portal',
|
||||
serverUrl: 'http://xtream.tv',
|
||||
username: 'stress',
|
||||
password: 'stress',
|
||||
})
|
||||
).toBe('Stress Portal');
|
||||
});
|
||||
|
||||
it('keeps a name that only shares a letter with a decoded password', () => {
|
||||
// `%2542` is the password as written; decoded it is just `B`.
|
||||
const url = 'https://host.test/list?password=%2542';
|
||||
expect(sourceDisplayName({ title: 'Bay News', url })).toBe('Bay News');
|
||||
expect(sourceDisplayName({ title: 'Mine %42', url })).toBe('host.test');
|
||||
});
|
||||
|
||||
it('drops a name holding a password under a short account key', () => {
|
||||
for (const key of ['pass', 'pas', 'PASS']) {
|
||||
const url = `https://host.test/list?user=joe&${key}=hunter2`;
|
||||
expect(sourceDisplayName({ title: 'hunter2', url })).toBe(
|
||||
'host.test'
|
||||
);
|
||||
expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe(
|
||||
'host.test'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the host for a name an import took from a URL fragment', () => {
|
||||
const url = 'https://host.test/list.m3u#api_key=secret123';
|
||||
expect(
|
||||
sourceDisplayName({ title: 'list.m3u#api_key=secret123', url })
|
||||
).toBe('host.test');
|
||||
// A typed name holding the fragment's secret collapses too.
|
||||
expect(sourceDisplayName({ title: 'My secret123', url })).toBe(
|
||||
'host.test'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a name holding a secret of a URL nested in the source URL', () => {
|
||||
const url = `https://proxy.test/list?url=${encodeURIComponent(
|
||||
'http://joe:hunter2@origin.test/get.php?password=pw4567&type=m3u'
|
||||
)}`;
|
||||
for (const title of ['Mine hunter2', 'Mine pw4567', 'Mine joe']) {
|
||||
expect(sourceDisplayName({ title, url })).toBe('proxy.test');
|
||||
}
|
||||
expect(sourceDisplayName({ title: 'Mine', url })).toBe('Mine');
|
||||
});
|
||||
|
||||
it('drops a name holding the password of an Xtream stream path', () => {
|
||||
const url = 'http://host.tv/LIVE/joe/hunter2/1.ts';
|
||||
expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe(
|
||||
'host.tv'
|
||||
);
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'My hunter2',
|
||||
url: 'https://host.test/live/joe/hunter2/list.m3u',
|
||||
})
|
||||
).toBe('host.test');
|
||||
});
|
||||
|
||||
it('drops a name holding a secret of a URL in the fragment', () => {
|
||||
const url = 'https://proxy.test/list#https://joe:hunter2@origin.test/a';
|
||||
for (const title of ['Mine hunter2', 'Mine joe']) {
|
||||
expect(sourceDisplayName({ title, url })).toBe('proxy.test');
|
||||
}
|
||||
expect(sourceDisplayName({ title: 'Mine', url })).toBe('Mine');
|
||||
});
|
||||
|
||||
it('keeps a playlist file name from a path that names no account', () => {
|
||||
const url = 'https://cdn.example/live/news/list.m3u';
|
||||
expect(sourceDisplayName({ title: 'list.m3u', url })).toBe('list.m3u');
|
||||
});
|
||||
|
||||
it('drops a name holding a secret of a nested URL encoded twice', () => {
|
||||
const url = `https://proxy.test/list?url=${encodeURIComponent(
|
||||
encodeURIComponent('http://joe:hunter2@origin.test/a.m3u')
|
||||
)}`;
|
||||
for (const title of ['Mine hunter2', 'Mine joe']) {
|
||||
expect(sourceDisplayName({ title, url })).toBe('proxy.test');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops a name holding a secret of a URL nested in a path segment', () => {
|
||||
const url = `https://proxy.test/fetch/${encodeURIComponent(
|
||||
'http://origin.test/live/joe/hunter2/1.ts'
|
||||
)}/token=tk98765`;
|
||||
for (const title of ['Mine hunter2', 'Mine joe', 'Mine tk98765']) {
|
||||
expect(sourceDisplayName({ title, url })).toBe('proxy.test');
|
||||
}
|
||||
expect(sourceDisplayName({ title: 'Mine', url })).toBe('Mine');
|
||||
});
|
||||
|
||||
it('returns null for a local file without a name', () => {
|
||||
expect(sourceDisplayName({ title: '' })).toBeNull();
|
||||
expect(sourceDisplayName({ title: '', filename: 'list.m3u' })).toBe(
|
||||
'list.m3u'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceSortName', () => {
|
||||
it('sorts a source by the name it shows', () => {
|
||||
expect(sourceSortName({ title: 'get.php?x=1', url: M3U_URL })).toBe(
|
||||
sourceDisplayName({ title: 'get.php?x=1', url: M3U_URL })
|
||||
);
|
||||
expect(sourceSortName({ title: '' })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceDisplayUrl', () => {
|
||||
it('shows an M3U URL without credentials', () => {
|
||||
expect(sourceDisplayUrl({ url: M3U_URL })).toBe(
|
||||
'http://biggie.to:8080/get.php?type=m3u_plus'
|
||||
);
|
||||
});
|
||||
|
||||
it('shortens a long path to its last segment', () => {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
url: 'https://raw.githubusercontent.com/user/repo/main/clean.m3u',
|
||||
})
|
||||
).toBe('https://raw.githubusercontent.com/…/clean.m3u');
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
url: 'https://iptv-org.github.io/iptv/index.m3u',
|
||||
})
|
||||
).toBe('https://iptv-org.github.io/iptv/index.m3u');
|
||||
});
|
||||
|
||||
it('shows host, port and account for Xtream, never the password', () => {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
serverUrl: 'http://xtream.tv:8080/',
|
||||
username: 'joe',
|
||||
password: 'hunter2',
|
||||
})
|
||||
).toBe('http://xtream.tv:8080 · joe');
|
||||
});
|
||||
|
||||
it('leaves out an account name that is, or holds, the password', () => {
|
||||
for (const username of ['hunter2', 'joe-hunter2']) {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
serverUrl: 'http://xtream.tv:8080/',
|
||||
username,
|
||||
password: 'hunter2',
|
||||
})
|
||||
).toBe('http://xtream.tv:8080');
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the portal URL for Stalker, never the MAC', () => {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl:
|
||||
'http://portal.tv/stalker_portal/c/?mac=00:1A:79:00:00:01',
|
||||
})
|
||||
).toBe('http://portal.tv/stalker_portal/c/');
|
||||
});
|
||||
|
||||
it('falls back to the origin when a secret sits in the path', () => {
|
||||
expect(
|
||||
sourceDisplayUrl({
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.tv/00:1A:79:00:00:01/c/',
|
||||
})
|
||||
).toBe('http://portal.tv');
|
||||
});
|
||||
|
||||
it('finds a secret whatever the case of its escapes or letters', () => {
|
||||
for (const portalUrl of [
|
||||
'http://portal.tv/00%3a1A%3a79%3a00%3a00%3a01/c/',
|
||||
'http://portal.tv/00:1a:79:00:00:01/c/',
|
||||
]) {
|
||||
expect(
|
||||
sourceDisplayUrl({ macAddress: '00:1A:79:00:00:01', portalUrl })
|
||||
).toBe('http://portal.tv');
|
||||
}
|
||||
expect(
|
||||
sourceDisplayName({
|
||||
title: 'Box 00:1a:79:00:00:01',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.tv/c/',
|
||||
})
|
||||
).toBe('portal.tv');
|
||||
});
|
||||
|
||||
it('has no URL for a local file or an unparsable address', () => {
|
||||
expect(sourceDisplayUrl({ title: 'local' })).toBeNull();
|
||||
expect(sourceDisplayUrl({ url: 'not a url' })).toBeNull();
|
||||
expect(
|
||||
sourceDisplayUrl({ serverUrl: 'nope', username: 'joe' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('never shows a password, token or MAC', () => {
|
||||
const sources = [
|
||||
{ url: M3U_URL },
|
||||
{ url: 'http://joe:hunter2@cdn.tv/a/b/c/list.m3u?token=hunter2' },
|
||||
{ serverUrl: 'http://x.tv', username: 'joe', password: 'hunter2' },
|
||||
{
|
||||
macAddress: 'hunter2',
|
||||
portalUrl: 'http://p.tv/c/?mac=hunter2&sn=hunter2',
|
||||
},
|
||||
];
|
||||
for (const source of sources) {
|
||||
expect(sourceDisplayUrl(source) ?? '').not.toMatch(/hunter2/);
|
||||
expect(
|
||||
sourceDisplayName({ ...source, title: '' }) ?? ''
|
||||
).not.toMatch(/hunter2/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceHost and sourceCopyUrl', () => {
|
||||
it('reads the endpoint by provider precedence', () => {
|
||||
const stalker = {
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.tv/c/',
|
||||
url: 'http://other.tv/list.m3u',
|
||||
};
|
||||
expect(sourceHost(stalker)).toBe('portal.tv');
|
||||
expect(sourceCopyUrl(stalker)).toBe('http://portal.tv/c/');
|
||||
expect(sourceCopyUrl({ url: M3U_URL })).toBe(M3U_URL);
|
||||
expect(sourceCopyUrl({})).toBeNull();
|
||||
expect(sourceHost({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
PlaylistMeta,
|
||||
playlistDisplayLabel,
|
||||
playlistNameHasCredentials,
|
||||
resolvePlaylistSourceKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { decodeFully, findXtreamAccount } from '@iptvnator/shared/logging';
|
||||
import {
|
||||
isDisplaySensitiveKey,
|
||||
stripUrlCredentials,
|
||||
unwrapNestedValue,
|
||||
} from './strip-url-credentials';
|
||||
|
||||
export type SourceDisplayFields = Partial<
|
||||
Pick<
|
||||
PlaylistMeta,
|
||||
| 'title'
|
||||
| 'filename'
|
||||
| 'url'
|
||||
| 'serverUrl'
|
||||
| 'portalUrl'
|
||||
| 'macAddress'
|
||||
| 'username'
|
||||
| 'password'
|
||||
>
|
||||
>;
|
||||
|
||||
/** The account name stays on screen by design; every other secret goes. */
|
||||
const SHOWN_ACCOUNT_KEYS = new Set(['username', 'login', 'user', 'usr']);
|
||||
/**
|
||||
* Shorter secrets match ordinary words inside a text, so they count only
|
||||
* as the whole text: a name that is just a two-letter password is hidden.
|
||||
*/
|
||||
const MIN_SECRET_LENGTH = 3;
|
||||
const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
/** Six hex pairs, as a STB MAC is written, wherever the secret came from. */
|
||||
const MAC_ADDRESS = /^[0-9a-f]{2}(?:[:-][0-9a-f]{2}){5}$/i;
|
||||
|
||||
function parseUrl(value: string | undefined): URL | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** The portal, server or playlist URL a source is loaded from. */
|
||||
export function sourceEndpoint(
|
||||
playlist: Pick<
|
||||
SourceDisplayFields,
|
||||
'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
|
||||
>
|
||||
): string | undefined {
|
||||
if (playlist.macAddress) {
|
||||
return playlist.portalUrl || playlist.url;
|
||||
}
|
||||
return playlist.serverUrl || playlist.url;
|
||||
}
|
||||
|
||||
/** Host and non-default port of the endpoint; null for a local file. */
|
||||
export function sourceHost(
|
||||
playlist: Pick<
|
||||
SourceDisplayFields,
|
||||
'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
|
||||
>
|
||||
): string | null {
|
||||
return parseUrl(sourceEndpoint(playlist))?.host || null;
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** How deep URLs nested in the source URL are searched for secrets. */
|
||||
const MAX_SECRET_NESTING = 4;
|
||||
|
||||
/**
|
||||
* The secrets a URL carries: its userinfo password, the password of an
|
||||
* Xtream stream path, every sensitive query or fragment parameter (the
|
||||
* source's own account name excepted), and the same again in any URL or
|
||||
* parameter list nested in a parameter or path segment, whose account names
|
||||
* count as secrets too.
|
||||
*/
|
||||
function collectUrlSecrets(
|
||||
url: URL,
|
||||
secrets: (string | undefined)[],
|
||||
depth: number
|
||||
): void {
|
||||
if (depth >= MAX_SECRET_NESTING) {
|
||||
return;
|
||||
}
|
||||
secrets.push(safeDecode(url.password));
|
||||
if (depth > 0) {
|
||||
secrets.push(safeDecode(url.username));
|
||||
}
|
||||
const visitNested = (raw: string): void => {
|
||||
const value = unwrapNestedValue(raw);
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
// Only real JSON: `[password]=…` starts like it but is a parameter
|
||||
// list, read below.
|
||||
const json = /^\s*[{[]/.test(value) ? parseJson(value) : null;
|
||||
if (json !== null && typeof json === 'object') {
|
||||
visitJson(json, 0);
|
||||
return;
|
||||
}
|
||||
const nested = ABSOLUTE_URL.test(value.trim()) ? parseUrl(value) : null;
|
||||
if (nested) {
|
||||
collectUrlSecrets(nested, secrets, depth + 1);
|
||||
} else if (value.includes('=')) {
|
||||
visit(new URLSearchParams(value));
|
||||
}
|
||||
};
|
||||
// A JSON config in a parameter: values under sensitive keys are
|
||||
// secrets, and any other string may be a URL or a parameter list.
|
||||
const visitJson = (value: unknown, level: number): void => {
|
||||
if (level >= MAX_SECRET_NESTING) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
visitNested(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach((item) => visitJson(item, level + 1));
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const [key, inner] of Object.entries(value)) {
|
||||
const scalar =
|
||||
typeof inner === 'string' || typeof inner === 'number';
|
||||
if (scalar && isDisplaySensitiveKey(key)) {
|
||||
secrets.push(String(inner));
|
||||
} else {
|
||||
visitJson(inner, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const visit = (params: URLSearchParams): void =>
|
||||
params.forEach((value, key) => {
|
||||
const normalized = decodeFully(key)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
if (isDisplaySensitiveKey(key)) {
|
||||
if (depth > 0 || !SHOWN_ACCOUNT_KEYS.has(normalized)) {
|
||||
secrets.push(value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
visitNested(value);
|
||||
});
|
||||
// An Xtream stream path carries the account:
|
||||
// `/live/<user>/<password>/<stream>`.
|
||||
const segments = url.pathname.split('/');
|
||||
const { accounts } = findXtreamAccount(segments, { streamFollows: true });
|
||||
for (const [user, password] of accounts) {
|
||||
secrets.push(password);
|
||||
if (depth > 0) {
|
||||
secrets.push(user);
|
||||
}
|
||||
}
|
||||
// A proxy may carry a whole URL in one segment.
|
||||
segments.map(safeDecode).forEach(visitNested);
|
||||
visit(url.searchParams);
|
||||
// A fragment may hold a parameter list or a whole URL.
|
||||
visitNested(url.hash.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Values that must never be on screen for this source: the password, the
|
||||
* MAC address, and every secret its URL carries (`collectUrlSecrets`), in
|
||||
* both decoded and encoded form.
|
||||
*/
|
||||
function sourceSecrets(playlist: SourceDisplayFields): string[] {
|
||||
const secrets = [playlist.password, playlist.macAddress];
|
||||
const url = parseUrl(sourceEndpoint(playlist));
|
||||
if (url) {
|
||||
collectUrlSecrets(url, secrets, 0);
|
||||
}
|
||||
const forms: string[] = [];
|
||||
for (const secret of secrets) {
|
||||
if (secret) {
|
||||
// A value encoded more than once reads as itself in a name.
|
||||
forms.push(secret, encodeURIComponent(secret), decodeFully(secret));
|
||||
}
|
||||
}
|
||||
return forms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one of the texts shows the secret: as the whole text, or inside
|
||||
* it when the secret is long enough. Each form on its own: `%2542` decodes
|
||||
* to `B`, which may only hide a text that is nothing but `B`.
|
||||
*/
|
||||
function shows(texts: readonly string[], secret: string): boolean {
|
||||
return texts.some(
|
||||
(text) =>
|
||||
text === secret ||
|
||||
(secret.length >= MIN_SECRET_LENGTH && text.includes(secret))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the text shows any of the source's secrets, read as written and
|
||||
* fully decoded (so `%3a` escapes count as `:`). A password is compared in
|
||||
* its own case: `Stress` in a name is not the password `stress`. A MAC is
|
||||
* the same address in either letter case, so it is compared lower-cased:
|
||||
* the source's own, and any MAC-shaped secret its URL carries (`?mac=…`,
|
||||
* nested ones included).
|
||||
*/
|
||||
export function containsSourceSecret(
|
||||
text: string,
|
||||
playlist: SourceDisplayFields
|
||||
): boolean {
|
||||
const texts = [text, decodeFully(text)].map((form) => form.trim());
|
||||
const lowerTexts = texts.map((form) => form.toLowerCase());
|
||||
const ownMac = playlist.macAddress?.toLowerCase();
|
||||
return sourceSecrets(playlist).some((secret) => {
|
||||
const plain = decodeFully(secret).toLowerCase();
|
||||
const isMac = plain === ownMac || MAC_ADDRESS.test(plain);
|
||||
return isMac
|
||||
? shows(lowerTexts, secret.toLowerCase())
|
||||
: shows(texts, secret);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A name an import made from the URL rather than one the user typed: the
|
||||
* URL itself, or the end of a URL with a query or fragment, such as
|
||||
* `get.php?username=…&password=…` or `list.m3u#api_key=…`.
|
||||
*/
|
||||
function isUrlDerivedName(name: string, url: string | undefined): boolean {
|
||||
if (ABSOLUTE_URL.test(name)) {
|
||||
return true;
|
||||
}
|
||||
return !!url && /[?#]/.test(name) && url.trim().endsWith(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a source list shows. A name the user chose stays; an empty name,
|
||||
* a URL or a URL-derived name becomes the host, and any name that carries a
|
||||
* credential collapses to the host as well. Null when there is nothing safe
|
||||
* to show; the caller renders its "untitled" label.
|
||||
*/
|
||||
export function sourceDisplayName(
|
||||
playlist: SourceDisplayFields
|
||||
): string | null {
|
||||
const raw = (playlist.title || playlist.filename || '').trim();
|
||||
const host = sourceHost(playlist);
|
||||
if (!raw) {
|
||||
return host;
|
||||
}
|
||||
if (
|
||||
isUrlDerivedName(raw, playlist.url) ||
|
||||
playlistNameHasCredentials(raw)
|
||||
) {
|
||||
return host ?? (playlistDisplayLabel(raw) || null);
|
||||
}
|
||||
return containsSourceSecret(raw, playlist) ? host : raw;
|
||||
}
|
||||
|
||||
/** The name a list shows and sorts a source by; empty when it has none. */
|
||||
export function sourceSortName(playlist: SourceDisplayFields): string {
|
||||
return sourceDisplayName(playlist) ?? '';
|
||||
}
|
||||
|
||||
/** `origin/…/last` once a path has more than two segments. */
|
||||
function compactPath(pathname: string): string {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
return segments.length > 2
|
||||
? `/…/${segments[segments.length - 1]}`
|
||||
: pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* The location a source list shows beside the name, without credentials:
|
||||
* `scheme://host:port · username` for Xtream, the portal URL for Stalker,
|
||||
* the playlist URL with a shortened path for M3U, and null for a local file.
|
||||
*/
|
||||
export function sourceDisplayUrl(playlist: SourceDisplayFields): string | null {
|
||||
const kind = resolvePlaylistSourceKind(playlist);
|
||||
if (kind === 'xtream') {
|
||||
const server = parseUrl(playlist.serverUrl);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
const base = `${server.protocol}//${server.host}`;
|
||||
// An account name that is, or holds, the password stays off screen.
|
||||
const user = playlist.username;
|
||||
return user && !containsSourceSecret(user, playlist)
|
||||
? `${base} · ${user}`
|
||||
: base;
|
||||
}
|
||||
|
||||
const stripped = parseUrl(
|
||||
stripUrlCredentials(sourceEndpoint(playlist) ?? '') ?? undefined
|
||||
);
|
||||
if (!stripped) {
|
||||
return null;
|
||||
}
|
||||
const origin = `${stripped.protocol}//${stripped.host}`;
|
||||
const shown = `${origin}${compactPath(stripped.pathname)}${stripped.search}${stripped.hash}`;
|
||||
return containsSourceSecret(shown, playlist) ? origin : shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full address "Copy URL" puts on the clipboard; credentials included,
|
||||
* since that is what the user asked for. Null for a local file.
|
||||
*/
|
||||
export function sourceCopyUrl(
|
||||
playlist: Pick<
|
||||
SourceDisplayFields,
|
||||
'macAddress' | 'serverUrl' | 'portalUrl' | 'url'
|
||||
>
|
||||
): string | null {
|
||||
return sourceEndpoint(playlist)?.trim() || null;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
formatSourceDate,
|
||||
formatSourceDuration,
|
||||
SourceFormatContext,
|
||||
sourceMetaParts,
|
||||
sourceStatusTag,
|
||||
sourceTypeLabel,
|
||||
} from './source-format';
|
||||
|
||||
const NOW = new Date(2026, 9, 12, 12, 0).getTime();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** Echoes the key and its parameters, so specs read what was asked for. */
|
||||
const translate = (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key} ${JSON.stringify(params)}` : key;
|
||||
|
||||
const context = (locale = 'en'): SourceFormatContext => ({
|
||||
translate,
|
||||
locale,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
describe('formatSourceDate', () => {
|
||||
it('shows day and month in the current year', () => {
|
||||
const date = new Date(2026, 8, 6).toISOString();
|
||||
expect(formatSourceDate(date, 'en', NOW)).toBe('Sep 6');
|
||||
expect(formatSourceDate(date, 'ru', NOW)).toMatch(/^6 сент/);
|
||||
});
|
||||
|
||||
it('adds the year for another year', () => {
|
||||
const date = new Date(2025, 8, 6).toISOString();
|
||||
expect(formatSourceDate(date, 'en', NOW)).toBe('Sep 6, 2025');
|
||||
});
|
||||
|
||||
it('adds the time on request', () => {
|
||||
const date = new Date(2026, 8, 6, 21, 34).getTime();
|
||||
expect(formatSourceDate(date, 'en-GB', NOW, true)).toBe(
|
||||
'6 Sept, 21:34'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads epoch milliseconds stored as a string', () => {
|
||||
const value = String(new Date(2026, 0, 2).getTime());
|
||||
expect(formatSourceDate(value, 'en', NOW)).toBe('Jan 2');
|
||||
});
|
||||
|
||||
it('returns null for an empty or invalid value', () => {
|
||||
expect(formatSourceDate(undefined, 'en', NOW)).toBeNull();
|
||||
expect(formatSourceDate('', 'en', NOW)).toBeNull();
|
||||
expect(formatSourceDate('not a date', 'en', NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to English for an unknown locale', () => {
|
||||
const date = new Date(2026, 8, 6).toISOString();
|
||||
expect(formatSourceDate(date, 'not_a-locale!', NOW)).toBe('Sep 6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSourceDuration', () => {
|
||||
it('counts whole days, then hours, in the UI language', () => {
|
||||
expect(formatSourceDuration(NOW - 3 * DAY - 5, 'en', NOW)).toBe(
|
||||
'3 days'
|
||||
);
|
||||
expect(formatSourceDuration(NOW - 3 * DAY, 'ru', NOW)).toBe('3 дня');
|
||||
expect(formatSourceDuration(NOW - 5 * 3_600_000, 'en', NOW)).toBe(
|
||||
'5 hours'
|
||||
);
|
||||
});
|
||||
|
||||
it('says nothing under an hour', () => {
|
||||
expect(formatSourceDuration(NOW - 60_000, 'en', NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceMetaParts', () => {
|
||||
const m3u = {
|
||||
title: 'Free TV',
|
||||
url: 'https://cdn.tv/a/b/list.m3u?token=secret1',
|
||||
count: 16805,
|
||||
importDate: new Date(2026, 8, 30).toISOString(),
|
||||
updateDate: new Date(2026, 9, 8, 3, 0).getTime(),
|
||||
};
|
||||
|
||||
it('lists type, channels, added, updated and URL in that order', () => {
|
||||
const parts = sourceMetaParts(m3u, context());
|
||||
expect(parts.map((part) => part.kind)).toEqual([
|
||||
'type',
|
||||
'channels',
|
||||
'added',
|
||||
'updated',
|
||||
'url',
|
||||
]);
|
||||
expect(parts[0].text).toBe('M3U');
|
||||
expect(parts[1].text).toBe(
|
||||
'HOME.PLAYLISTS.CHANNELS_COUNT {"count":"16,805"}'
|
||||
);
|
||||
expect(parts[4].text).toBe('https://cdn.tv/…/list.m3u');
|
||||
});
|
||||
|
||||
it('counts a single channel in the singular', () => {
|
||||
expect(sourceMetaParts({ ...m3u, count: 1 }, context())[1].text).toBe(
|
||||
'WORKSPACE.SOURCES.ROW.CHANNELS_ONE {"count":"1"}'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out empty parts and zero channels', () => {
|
||||
const parts = sourceMetaParts(
|
||||
{ title: 'x', count: 0, importDate: '' },
|
||||
context()
|
||||
);
|
||||
expect(parts).toEqual([
|
||||
{ kind: 'type', text: 'WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows no channel count for Xtream and Stalker', () => {
|
||||
const parts = sourceMetaParts(
|
||||
{
|
||||
title: 'x',
|
||||
serverUrl: 'http://x.tv',
|
||||
username: 'joe',
|
||||
password: 'p4ss',
|
||||
count: 0,
|
||||
importDate: '',
|
||||
},
|
||||
context()
|
||||
);
|
||||
expect(parts.map((part) => part.kind)).toEqual(['type', 'url']);
|
||||
expect(parts[0].text).toBe('Xtream');
|
||||
});
|
||||
|
||||
it('keeps type, channels and updated in the compact row', () => {
|
||||
expect(
|
||||
sourceMetaParts(m3u, context(), true).map((part) => part.kind)
|
||||
).toEqual(['type', 'channels', 'updated']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceTypeLabel', () => {
|
||||
it('names every provider', () => {
|
||||
expect(sourceTypeLabel({ url: 'http://x' }, translate)).toBe('M3U');
|
||||
expect(sourceTypeLabel({}, translate)).toBe(
|
||||
'WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE'
|
||||
);
|
||||
expect(sourceTypeLabel({ serverUrl: 'http://x' }, translate)).toBe(
|
||||
'Xtream'
|
||||
);
|
||||
expect(sourceTypeLabel({ macAddress: 'm' }, translate)).toBe('Stalker');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceStatusTag', () => {
|
||||
it('says how long a source has not responded', () => {
|
||||
expect(
|
||||
sourceStatusTag(
|
||||
{ reachability: 'bad', failingSince: NOW - 3 * DAY },
|
||||
context()
|
||||
)
|
||||
).toEqual({
|
||||
tone: 'danger',
|
||||
label: 'WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR {"duration":"3 days"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the duration in the compact row and under an hour', () => {
|
||||
const status = {
|
||||
reachability: 'bad',
|
||||
failingSince: NOW - 3 * DAY,
|
||||
} as const;
|
||||
expect(sourceStatusTag(status, context(), true)?.label).toBe(
|
||||
'WORKSPACE.SOURCES.ROW.UNREACHABLE'
|
||||
);
|
||||
expect(
|
||||
sourceStatusTag(
|
||||
{ reachability: 'bad', failingSince: NOW - 1000 },
|
||||
context()
|
||||
)?.label
|
||||
).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE');
|
||||
});
|
||||
|
||||
it('tags expired accounts and unchecked sources', () => {
|
||||
expect(sourceStatusTag({ reachability: 'expired' }, context())).toEqual(
|
||||
{
|
||||
tone: 'danger',
|
||||
label: 'WORKSPACE.SOURCES.ROW.EXPIRED',
|
||||
}
|
||||
);
|
||||
expect(sourceStatusTag({ reachability: 'unknown' }, context())).toEqual(
|
||||
{
|
||||
tone: 'neutral',
|
||||
label: 'SOURCE_HEALTH.REASON.unknown',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
sourceStatusTag({ reachability: 'unknown' }, context(), true)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('has no tag for a healthy, checking or local source', () => {
|
||||
expect(sourceStatusTag({ reachability: 'ok' }, context())).toBeNull();
|
||||
expect(
|
||||
sourceStatusTag({ reachability: 'checking' }, context())
|
||||
).toBeNull();
|
||||
expect(sourceStatusTag(null, context())).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
PlaylistMeta,
|
||||
resolvePlaylistSourceKind,
|
||||
SourceStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SourceDisplayFields, sourceDisplayUrl } from './source-display';
|
||||
|
||||
/** `TranslateService.instant` or a test double. */
|
||||
export type SourceTranslate = (
|
||||
key: string,
|
||||
params?: Record<string, unknown>
|
||||
) => string;
|
||||
|
||||
export interface SourceFormatContext {
|
||||
translate: SourceTranslate;
|
||||
/** A BCP 47 locale for `Intl`, e.g. from `normalizeDateLocale`. */
|
||||
locale: string;
|
||||
now: number;
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Provider names are brands, the same in every language. */
|
||||
const KIND_LABELS = {
|
||||
m3u: 'M3U',
|
||||
xtream: 'Xtream',
|
||||
stalker: 'Stalker',
|
||||
} as const;
|
||||
|
||||
function toDate(value: string | number | undefined | null): Date | null {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
const date =
|
||||
typeof value === 'number' || /^\d+$/.test(value)
|
||||
? new Date(Number(value))
|
||||
: new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function dateFormat(
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions
|
||||
): Intl.DateTimeFormat {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, options);
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat('en', options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short date in the UI language: day and month, the year only when it is
|
||||
* not the current one, and the time when asked for.
|
||||
*/
|
||||
export function formatSourceDate(
|
||||
value: string | number | undefined | null,
|
||||
locale: string,
|
||||
now: number,
|
||||
withTime = false
|
||||
): string | null {
|
||||
const date = toDate(value);
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
const sameYear = date.getFullYear() === new Date(now).getFullYear();
|
||||
return dateFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** "3 days" / "5 hours" in the UI language; null under an hour. */
|
||||
export function formatSourceDuration(
|
||||
since: number,
|
||||
locale: string,
|
||||
now: number
|
||||
): string | null {
|
||||
const elapsed = now - since;
|
||||
const [unit, amount] =
|
||||
elapsed >= DAY_MS
|
||||
? (['day', Math.floor(elapsed / DAY_MS)] as const)
|
||||
: (['hour', Math.floor(elapsed / HOUR_MS)] as const);
|
||||
if (amount < 1) {
|
||||
return null;
|
||||
}
|
||||
// The app's TypeScript lib (es2018) predates unit formatting; every
|
||||
// supported runtime has it.
|
||||
const options = {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: 'long',
|
||||
} as unknown as Intl.NumberFormatOptions;
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, options).format(amount);
|
||||
} catch {
|
||||
return new Intl.NumberFormat('en', options).format(amount);
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceTypeLabel(
|
||||
playlist: Pick<PlaylistMeta, 'macAddress' | 'serverUrl' | 'url'>,
|
||||
translate: SourceTranslate
|
||||
): string {
|
||||
const kind = resolvePlaylistSourceKind(playlist);
|
||||
if (kind === 'm3u' && !playlist.url) {
|
||||
return translate('WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE');
|
||||
}
|
||||
return KIND_LABELS[kind];
|
||||
}
|
||||
|
||||
export type SourceMetaPartKind =
|
||||
'type' | 'channels' | 'added' | 'updated' | 'url';
|
||||
|
||||
export interface SourceMetaPart {
|
||||
kind: SourceMetaPartKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type SourceMetaFields = SourceDisplayFields &
|
||||
Partial<Pick<PlaylistMeta, 'count' | 'importDate' | 'updateDate'>>;
|
||||
|
||||
/**
|
||||
* The meta line, always in this order: type · channels · added · updated ·
|
||||
* URL, with empty parts left out. Only M3U playlists know their channel
|
||||
* count. The compact switcher row keeps type, channels and updated.
|
||||
*/
|
||||
export function sourceMetaParts(
|
||||
playlist: SourceMetaFields,
|
||||
context: SourceFormatContext,
|
||||
compact = false
|
||||
): SourceMetaPart[] {
|
||||
const { translate, locale, now } = context;
|
||||
const parts: SourceMetaPart[] = [
|
||||
{ kind: 'type', text: sourceTypeLabel(playlist, translate) },
|
||||
];
|
||||
const isM3u = resolvePlaylistSourceKind(playlist) === 'm3u';
|
||||
if (isM3u && (playlist.count ?? 0) > 0) {
|
||||
const count = new Intl.NumberFormat(locale).format(playlist.count ?? 0);
|
||||
parts.push({
|
||||
kind: 'channels',
|
||||
text: translate(
|
||||
playlist.count === 1
|
||||
? 'WORKSPACE.SOURCES.ROW.CHANNELS_ONE'
|
||||
: 'HOME.PLAYLISTS.CHANNELS_COUNT',
|
||||
{ count }
|
||||
),
|
||||
});
|
||||
}
|
||||
const added = compact
|
||||
? null
|
||||
: formatSourceDate(playlist.importDate, locale, now);
|
||||
if (added) {
|
||||
parts.push({
|
||||
kind: 'added',
|
||||
text: translate('WORKSPACE.SOURCES.ROW.ADDED', { date: added }),
|
||||
});
|
||||
}
|
||||
const updated = formatSourceDate(playlist.updateDate, locale, now, true);
|
||||
if (updated) {
|
||||
parts.push({
|
||||
kind: 'updated',
|
||||
text: translate('WORKSPACE.SOURCES.ROW.UPDATED', { date: updated }),
|
||||
});
|
||||
}
|
||||
const url = compact ? null : sourceDisplayUrl(playlist);
|
||||
if (url) {
|
||||
parts.push({ kind: 'url', text: url });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export interface SourceStatusTag {
|
||||
label: string;
|
||||
tone: 'danger' | 'neutral';
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag after the name. A healthy source has none: green is the dot's
|
||||
* job. The compact row drops the duration and the "not checked" tag.
|
||||
*/
|
||||
export function sourceStatusTag(
|
||||
status: SourceStatus | null,
|
||||
context: SourceFormatContext,
|
||||
compact = false
|
||||
): SourceStatusTag | null {
|
||||
const { translate, locale, now } = context;
|
||||
switch (status?.reachability) {
|
||||
case 'bad': {
|
||||
const duration =
|
||||
!compact && status.failingSince !== undefined
|
||||
? formatSourceDuration(status.failingSince, locale, now)
|
||||
: null;
|
||||
return {
|
||||
tone: 'danger',
|
||||
label: duration
|
||||
? translate('WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR', {
|
||||
duration,
|
||||
})
|
||||
: translate('WORKSPACE.SOURCES.ROW.UNREACHABLE'),
|
||||
};
|
||||
}
|
||||
case 'expired':
|
||||
return {
|
||||
tone: 'danger',
|
||||
label: translate('WORKSPACE.SOURCES.ROW.EXPIRED'),
|
||||
};
|
||||
case 'unknown':
|
||||
return compact
|
||||
? null
|
||||
: {
|
||||
tone: 'neutral',
|
||||
label: translate('SOURCE_HEALTH.REASON.unknown'),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
countSourcesByStatus,
|
||||
countSourcesByType,
|
||||
filterSources,
|
||||
matchesSourceQuery,
|
||||
orderFailingFirst,
|
||||
replacementAfterRemoval,
|
||||
SourceBucketOf,
|
||||
} from './source-list';
|
||||
import {
|
||||
SOURCES_VIEW_STORAGE_KEY,
|
||||
SourcesViewStateService,
|
||||
} from './sources-view-state.service';
|
||||
|
||||
const base = { count: 0, importDate: '', autoRefresh: false };
|
||||
const m3u: PlaylistMeta = {
|
||||
...base,
|
||||
_id: 'm3u',
|
||||
title: 'Free TV',
|
||||
url: 'https://cdn.example/list.m3u',
|
||||
};
|
||||
const file: PlaylistMeta = { ...base, _id: 'file', title: 'local list' };
|
||||
const xtream: PlaylistMeta = {
|
||||
...base,
|
||||
_id: 'xtream',
|
||||
title: '',
|
||||
serverUrl: 'http://biggie.to:8080',
|
||||
username: 'joe',
|
||||
password: 'hunter2',
|
||||
};
|
||||
const stalker: PlaylistMeta = {
|
||||
...base,
|
||||
_id: 'stalker',
|
||||
title: 'Portal',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://portal.example/c/',
|
||||
};
|
||||
const all = [m3u, file, xtream, stalker];
|
||||
const bucketOf: SourceBucketOf = (playlist) =>
|
||||
(({ m3u: 'ok', xtream: 'bad', stalker: 'ok' })[playlist._id] as
|
||||
'ok' | 'bad' | undefined) ?? null;
|
||||
|
||||
describe('source list filters', () => {
|
||||
it('combines type, status and query', () => {
|
||||
const ids = (
|
||||
type: 'all' | 'm3u',
|
||||
status: 'all' | 'ok' | 'bad',
|
||||
q = ''
|
||||
) =>
|
||||
filterSources(all, { type, status }, q, bucketOf).map((p) => p._id);
|
||||
expect(ids('all', 'all')).toEqual(['m3u', 'file', 'xtream', 'stalker']);
|
||||
expect(ids('m3u', 'all')).toEqual(['m3u', 'file']);
|
||||
expect(ids('all', 'bad')).toEqual(['xtream']);
|
||||
// A local file has no status, so it is in neither status group.
|
||||
expect(ids('m3u', 'ok')).toEqual(['m3u']);
|
||||
expect(ids('all', 'all', 'portal')).toEqual(['stalker']);
|
||||
});
|
||||
|
||||
it('finds a source by its host, not by its password', () => {
|
||||
expect(matchesSourceQuery(xtream, 'biggie')).toBe(true);
|
||||
expect(matchesSourceQuery(xtream, 'hunter2')).toBe(false);
|
||||
expect(matchesSourceQuery(m3u, 'FREE')).toBe(true);
|
||||
});
|
||||
|
||||
it('counts each group within the other group’s choice', () => {
|
||||
expect(countSourcesByType(all, 'all', bucketOf)).toEqual({
|
||||
all: 4,
|
||||
m3u: 2,
|
||||
xtream: 1,
|
||||
stalker: 1,
|
||||
});
|
||||
expect(countSourcesByType(all, 'ok', bucketOf)).toEqual({
|
||||
all: 2,
|
||||
m3u: 1,
|
||||
xtream: 0,
|
||||
stalker: 1,
|
||||
});
|
||||
expect(countSourcesByStatus(all, 'all', bucketOf)).toEqual({
|
||||
all: 4,
|
||||
ok: 2,
|
||||
bad: 1,
|
||||
});
|
||||
expect(countSourcesByStatus(all, 'm3u', bucketOf)).toEqual({
|
||||
all: 2,
|
||||
ok: 1,
|
||||
bad: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('puts failing sources first and keeps the order otherwise', () => {
|
||||
expect(orderFailingFirst(all, bucketOf).map((p) => p._id)).toEqual([
|
||||
'xtream',
|
||||
'm3u',
|
||||
'file',
|
||||
'stalker',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replacementAfterRemoval', () => {
|
||||
const rows = ['a', 'b', 'c', 'd'].map((id) => ({ _id: id }));
|
||||
const none = () => false;
|
||||
|
||||
it('hands over to the next row, or the previous one at the end', () => {
|
||||
expect(replacementAfterRemoval(rows, 'b', none)?._id).toBe('c');
|
||||
expect(replacementAfterRemoval(rows, 'd', none)?._id).toBe('c');
|
||||
expect(replacementAfterRemoval([{ _id: 'a' }], 'a', none)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips rows that are leaving too', () => {
|
||||
const deleting = (row: { _id: string }) =>
|
||||
row._id === 'c' || row._id === 'd';
|
||||
expect(replacementAfterRemoval(rows, 'b', deleting)?._id).toBe('a');
|
||||
expect(
|
||||
replacementAfterRemoval(rows, 'a', (row) => row._id !== 'a')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SourcesViewStateService', () => {
|
||||
const create = () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [SourcesViewStateService],
|
||||
});
|
||||
return TestBed.inject(SourcesViewStateService);
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('keeps the filters across a restart', () => {
|
||||
const state = create();
|
||||
state.setType('xtream');
|
||||
state.setStatus('bad');
|
||||
TestBed.resetTestingModule();
|
||||
expect(create().filter()).toEqual({ type: 'xtream', status: 'bad' });
|
||||
});
|
||||
|
||||
it('resets both filters', () => {
|
||||
const state = create();
|
||||
state.setType('m3u');
|
||||
state.reset();
|
||||
expect(state.filter()).toEqual({ type: 'all', status: 'all' });
|
||||
});
|
||||
|
||||
it('ignores unknown stored values', () => {
|
||||
localStorage.setItem(
|
||||
SOURCES_VIEW_STORAGE_KEY,
|
||||
JSON.stringify({ type: 'radio', status: 'maybe' })
|
||||
);
|
||||
expect(create().filter()).toEqual({ type: 'all', status: 'all' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
foldSearchText,
|
||||
PlaylistMeta,
|
||||
PlaylistSourceKind,
|
||||
resolvePlaylistSourceKind,
|
||||
SourceStatusBucket,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { sourceDisplayName, sourceHost } from './source-display';
|
||||
|
||||
export type SourcesTypeFilter = 'all' | PlaylistSourceKind;
|
||||
export type SourcesStatusFilter = 'all' | SourceStatusBucket;
|
||||
|
||||
export interface SourcesFilter {
|
||||
type: SourcesTypeFilter;
|
||||
status: SourcesStatusFilter;
|
||||
}
|
||||
|
||||
/** A source's status bucket; null for local files and unchecked sources. */
|
||||
export type SourceBucketOf = (
|
||||
playlist: PlaylistMeta
|
||||
) => SourceStatusBucket | null;
|
||||
|
||||
export type SourceTypeCounts = Record<SourcesTypeFilter, number>;
|
||||
export type SourceStatusCounts = Record<SourcesStatusFilter, number>;
|
||||
|
||||
export function matchesSourceType(
|
||||
playlist: PlaylistMeta,
|
||||
type: SourcesTypeFilter
|
||||
): boolean {
|
||||
return type === 'all' || resolvePlaylistSourceKind(playlist) === type;
|
||||
}
|
||||
|
||||
export function matchesSourceStatus(
|
||||
playlist: PlaylistMeta,
|
||||
status: SourcesStatusFilter,
|
||||
bucketOf: SourceBucketOf
|
||||
): boolean {
|
||||
return status === 'all' || bucketOf(playlist) === status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by what the row shows: the name (stored and displayed) and the
|
||||
* host, folded for case and accents. An empty query matches everything.
|
||||
*/
|
||||
export function matchesSourceQuery(
|
||||
playlist: PlaylistMeta,
|
||||
query: string
|
||||
): boolean {
|
||||
const folded = foldSearchText(query).trim();
|
||||
if (!folded) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
playlist.title,
|
||||
playlist.filename,
|
||||
sourceDisplayName(playlist),
|
||||
sourceHost(playlist),
|
||||
];
|
||||
return haystack.some(
|
||||
(value) => !!value && foldSearchText(value).includes(folded)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterSources(
|
||||
playlists: readonly PlaylistMeta[],
|
||||
filter: SourcesFilter,
|
||||
query: string,
|
||||
bucketOf: SourceBucketOf
|
||||
): PlaylistMeta[] {
|
||||
return playlists.filter(
|
||||
(playlist) =>
|
||||
matchesSourceType(playlist, filter.type) &&
|
||||
matchesSourceStatus(playlist, filter.status, bucketOf) &&
|
||||
matchesSourceQuery(playlist, query)
|
||||
);
|
||||
}
|
||||
|
||||
/** Type counts within the current status choice, as the side list shows. */
|
||||
export function countSourcesByType(
|
||||
playlists: readonly PlaylistMeta[],
|
||||
status: SourcesStatusFilter,
|
||||
bucketOf: SourceBucketOf
|
||||
): SourceTypeCounts {
|
||||
const counts: SourceTypeCounts = { all: 0, m3u: 0, xtream: 0, stalker: 0 };
|
||||
for (const playlist of playlists) {
|
||||
if (matchesSourceStatus(playlist, status, bucketOf)) {
|
||||
counts.all += 1;
|
||||
counts[resolvePlaylistSourceKind(playlist)] += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Status counts within the current type choice. */
|
||||
export function countSourcesByStatus(
|
||||
playlists: readonly PlaylistMeta[],
|
||||
type: SourcesTypeFilter,
|
||||
bucketOf: SourceBucketOf
|
||||
): SourceStatusCounts {
|
||||
const counts: SourceStatusCounts = { all: 0, ok: 0, bad: 0 };
|
||||
for (const playlist of playlists) {
|
||||
if (matchesSourceType(playlist, type)) {
|
||||
counts.all += 1;
|
||||
const bucket = bucketOf(playlist);
|
||||
if (bucket) {
|
||||
counts[bucket] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Moves sources that do not respond to the top, keeping the order. */
|
||||
export function orderFailingFirst(
|
||||
playlists: readonly PlaylistMeta[],
|
||||
bucketOf: SourceBucketOf
|
||||
): PlaylistMeta[] {
|
||||
const failing: PlaylistMeta[] = [];
|
||||
const rest: PlaylistMeta[] = [];
|
||||
for (const playlist of playlists) {
|
||||
(bucketOf(playlist) === 'bad' ? failing : rest).push(playlist);
|
||||
}
|
||||
return [...failing, ...rest];
|
||||
}
|
||||
|
||||
/**
|
||||
* The source that takes over when `removedId` leaves a list: the nearest
|
||||
* row after it, else before it, skipping rows that are leaving too (a
|
||||
* delete still running). Null when no other source can take over.
|
||||
*/
|
||||
export function replacementAfterRemoval<T extends { _id: string }>(
|
||||
rows: readonly T[],
|
||||
removedId: string,
|
||||
leaving: (row: T) => boolean
|
||||
): T | null {
|
||||
const index = rows.findIndex((row) => row._id === removedId);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
for (let next = index + 1; next < rows.length; next += 1) {
|
||||
if (!leaving(rows[next])) {
|
||||
return rows[next];
|
||||
}
|
||||
}
|
||||
for (let previous = index - 1; previous >= 0; previous -= 1) {
|
||||
if (!leaving(rows[previous])) {
|
||||
return rows[previous];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
SourcesFilter,
|
||||
SourcesStatusFilter,
|
||||
SourcesTypeFilter,
|
||||
} from './source-list';
|
||||
|
||||
export const SOURCES_VIEW_STORAGE_KEY = 'sources:view:v1';
|
||||
|
||||
const TYPES: readonly SourcesTypeFilter[] = ['all', 'm3u', 'xtream', 'stalker'];
|
||||
const STATUSES: readonly SourcesStatusFilter[] = ['all', 'ok', 'bad'];
|
||||
const DEFAULT_FILTER: SourcesFilter = { type: 'all', status: 'all' };
|
||||
|
||||
function readFilter(): SourcesFilter {
|
||||
try {
|
||||
const raw = localStorage.getItem(SOURCES_VIEW_STORAGE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as Partial<SourcesFilter>) : {};
|
||||
return {
|
||||
type: TYPES.includes(parsed.type as SourcesTypeFilter)
|
||||
? (parsed.type as SourcesTypeFilter)
|
||||
: 'all',
|
||||
status: STATUSES.includes(parsed.status as SourcesStatusFilter)
|
||||
? (parsed.status as SourcesStatusFilter)
|
||||
: 'all',
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_FILTER };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Sources page's type and status filters, kept across restarts. The two
|
||||
* combine (AND); each side-list group counts within the other's choice.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourcesViewStateService {
|
||||
private readonly state = signal<SourcesFilter>(readFilter());
|
||||
readonly filter = this.state.asReadonly();
|
||||
|
||||
setType(type: SourcesTypeFilter): void {
|
||||
this.write({ ...this.state(), type });
|
||||
}
|
||||
|
||||
setStatus(status: SourcesStatusFilter): void {
|
||||
this.write({ ...this.state(), status });
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.write({ ...DEFAULT_FILTER });
|
||||
}
|
||||
|
||||
private write(filter: SourcesFilter): void {
|
||||
this.state.set(filter);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SOURCES_VIEW_STORAGE_KEY,
|
||||
JSON.stringify(filter)
|
||||
);
|
||||
} catch {
|
||||
// Filters still apply for this session.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { stripUrlCredentials } from './strip-url-credentials';
|
||||
|
||||
describe('stripUrlCredentials', () => {
|
||||
it.each([
|
||||
[
|
||||
'http://host.tv:8080/get.php?username=joe&password=hunter2&type=m3u_plus',
|
||||
'http://host.tv:8080/get.php?type=m3u_plus',
|
||||
],
|
||||
['http://joe:hunter2@host.tv/list.m3u', 'http://host.tv/list.m3u'],
|
||||
[
|
||||
'http://host.tv/live/joe/hunter2/123.ts',
|
||||
'http://host.tv/live/123.ts',
|
||||
],
|
||||
[
|
||||
'https://cdn.tv/playlist.m3u8?token=abc123&quality=hd',
|
||||
'https://cdn.tv/playlist.m3u8?quality=hd',
|
||||
],
|
||||
['http://portal.tv/c/?mac=00:1A:79:00:00:01', 'http://portal.tv/c/'],
|
||||
[
|
||||
'http://host.tv/list.m3u#user=joe&pass=hunter2',
|
||||
'http://host.tv/list.m3u',
|
||||
],
|
||||
['http://[::1]:3211/get.php?password=p', 'http://[::1]:3211/get.php'],
|
||||
[
|
||||
'https://iptv-org.github.io/iptv/index.m3u',
|
||||
'https://iptv-org.github.io/iptv/index.m3u',
|
||||
],
|
||||
])('shows %s as %s', (input, expected) => {
|
||||
expect(stripUrlCredentials(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('never returns a password from a credential-bearing URL', () => {
|
||||
const urls = [
|
||||
'http://joe:hunter2@host.tv/get.php?username=joe&password=hunter2',
|
||||
'http://host.tv/movie/joe/hunter2/9.mkv?auth=hunter2',
|
||||
'http://host.tv/x#password=hunter2',
|
||||
];
|
||||
for (const url of urls) {
|
||||
expect(stripUrlCredentials(url)).not.toMatch(/hunter2/);
|
||||
}
|
||||
});
|
||||
|
||||
it('cleans a credential-bearing URL nested in a kept parameter', () => {
|
||||
const proxied = `https://proxy.test/list?url=${encodeURIComponent(
|
||||
'http://joe:hunter2@origin.test/get.php?username=joe&password=hunter2&type=m3u'
|
||||
)}&quality=hd`;
|
||||
const shown = stripUrlCredentials(proxied);
|
||||
expect(shown).not.toMatch(/hunter2|joe/);
|
||||
expect(new URL(shown ?? '').searchParams.get('url')).toBe(
|
||||
'http://origin.test/get.php?type=m3u'
|
||||
);
|
||||
expect(new URL(shown ?? '').searchParams.get('quality')).toBe('hd');
|
||||
});
|
||||
|
||||
it('cleans nested parameter lists, fragments and path segments', () => {
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
`https://proxy.test/list?next=${encodeURIComponent(
|
||||
'token=abc&page=2'
|
||||
)}`
|
||||
)
|
||||
).toBe('https://proxy.test/list?next=page%3D2');
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
`https://proxy.test/list#src=${encodeURIComponent(
|
||||
'http://joe:hunter2@origin.test/a.m3u'
|
||||
)}`
|
||||
)
|
||||
).not.toMatch(/hunter2/);
|
||||
const inPath = `https://proxy.test/fetch/${encodeURIComponent(
|
||||
'http://joe:hunter2@origin.test/a.m3u'
|
||||
)}`;
|
||||
expect(stripUrlCredentials(inPath)).not.toMatch(/hunter2|joe/);
|
||||
});
|
||||
|
||||
it('knows Xtream account segments in any case, also nested', () => {
|
||||
expect(
|
||||
stripUrlCredentials('http://host.tv/LIVE/joe/hunter2/1.ts')
|
||||
).toBe('http://host.tv/LIVE/1.ts');
|
||||
const nested = `https://proxy.test/list?url=${encodeURIComponent(
|
||||
'https://origin.test/LIVE/joe/hunter2/1.ts'
|
||||
)}`;
|
||||
expect(stripUrlCredentials(nested)).not.toMatch(/hunter2|joe/);
|
||||
});
|
||||
|
||||
it('reads parameter names and path markers through every encoding layer', () => {
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
'https://proxy.test/list?%2570assword=hunter2&hd=1'
|
||||
)
|
||||
).toBe('https://proxy.test/list?hd=1');
|
||||
expect(
|
||||
stripUrlCredentials('https://proxy.test/list?%2570ass=hunter2&hd=1')
|
||||
).toBe('https://proxy.test/list?hd=1');
|
||||
const layered = 'https://origin.test/%256Cive/joe/hunter2/1.ts';
|
||||
expect(stripUrlCredentials(layered)).not.toMatch(/hunter2|joe/);
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
`https://proxy.test/list?url=${encodeURIComponent(layered)}`
|
||||
)
|
||||
).not.toMatch(/hunter2|joe/);
|
||||
});
|
||||
|
||||
it('drops an Xtream account hidden behind encoded separators', () => {
|
||||
for (const url of [
|
||||
'https://origin.test/live%2Fjoe%2Fhunter2%2F1.ts',
|
||||
'https://origin.test/live%2Fjoe/hunter2/1.ts',
|
||||
'https://origin.test/live%252Fjoe%252Fhunter2%252F1.ts',
|
||||
// A slash encoded inside the user name, not between segments.
|
||||
'https://origin.test/live/joe%2Fx/hunter2/1.ts',
|
||||
'https://origin.test/live/joe/hun%2Fter2/1.ts',
|
||||
]) {
|
||||
expect(stripUrlCredentials(url)).not.toMatch(/hunter2|hun|joe/);
|
||||
}
|
||||
// Separators alone are no account: the path stays as written.
|
||||
expect(
|
||||
stripUrlCredentials('https://cdn.test/files%2Flive%2Flist.m3u')
|
||||
).toBe('https://cdn.test/files%2Flive%2Flist.m3u');
|
||||
});
|
||||
|
||||
it('drops a JSON value and a quoted key that name a secret', () => {
|
||||
for (const json of [
|
||||
'{"password":"hunter2"}',
|
||||
'{"pass\\u0077ord":"hunter2"}',
|
||||
'[{"auth":{"token":"hunter2"}}]',
|
||||
]) {
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
`https://proxy.test/list?config=${encodeURIComponent(json)}&hd=1`
|
||||
)
|
||||
).toBe('https://proxy.test/list?hd=1');
|
||||
}
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
`https://proxy.test/list?note=${encodeURIComponent(
|
||||
"see 'token': 'hunter2'"
|
||||
)}&hd=1`
|
||||
)
|
||||
).toBe('https://proxy.test/list?hd=1');
|
||||
// Brackets that are not JSON stay: `[1080p]` is a folder name.
|
||||
expect(stripUrlCredentials('https://cdn.test/[1080p]/list.m3u')).toBe(
|
||||
'https://cdn.test/[1080p]/list.m3u'
|
||||
);
|
||||
});
|
||||
|
||||
it('cleans a fragment that holds a URL rather than parameters', () => {
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
'https://proxy.test/list#https://joe:hunter2@origin.test/a.m3u'
|
||||
)
|
||||
).toBe('https://proxy.test/list#https://origin.test/a.m3u');
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
`https://proxy.test/list#${encodeURIComponent(
|
||||
'https://joe:hunter2@origin.test/a.m3u'
|
||||
)}`
|
||||
)
|
||||
).not.toMatch(/hunter2|joe/);
|
||||
expect(
|
||||
stripUrlCredentials('https://proxy.test/list#joe:hunter2@origin')
|
||||
).toBe('https://proxy.test/list');
|
||||
// A plain anchor stays as written.
|
||||
expect(stripUrlCredentials('https://host.test/list.m3u#part-2')).toBe(
|
||||
'https://host.test/list.m3u#part-2'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a playlist path that only starts like an Xtream stream', () => {
|
||||
// No stream follows `/news/list.m3u`: it names no account.
|
||||
expect(stripUrlCredentials('https://cdn.test/live/news/list.m3u')).toBe(
|
||||
'https://cdn.test/live/news/list.m3u'
|
||||
);
|
||||
expect(
|
||||
stripUrlCredentials('https://cdn.test/live/joe/hunter2/list.m3u')
|
||||
).toBe('https://cdn.test/live/list.m3u');
|
||||
});
|
||||
|
||||
it('cleans a nested URL encoded more than once', () => {
|
||||
const origin = 'https://joe:hunter2@origin.test/a.m3u?token=tk98765';
|
||||
for (const rounds of [1, 2, 3]) {
|
||||
let value = origin;
|
||||
for (let round = 0; round < rounds; round += 1) {
|
||||
value = encodeURIComponent(value);
|
||||
}
|
||||
const shown = stripUrlCredentials(
|
||||
`https://proxy.test/list?url=${value}&hd=1`
|
||||
);
|
||||
expect(shown).not.toMatch(/hunter2|joe|tk98765/);
|
||||
expect(new URL(shown ?? '').searchParams.get('url')).toBe(
|
||||
'https://origin.test/a.m3u'
|
||||
);
|
||||
}
|
||||
// Still encoded after every round it decodes: not shown at all.
|
||||
let deep = origin;
|
||||
for (let round = 0; round < 8; round += 1) {
|
||||
deep = encodeURIComponent(deep);
|
||||
}
|
||||
expect(
|
||||
stripUrlCredentials(`https://proxy.test/list?url=${deep}&hd=1`)
|
||||
).toBe('https://proxy.test/list?hd=1');
|
||||
});
|
||||
|
||||
it('keeps a nested URL’s own encoded parameters as written', () => {
|
||||
const nested = `https://origin.test/a.m3u?q=${encodeURIComponent(
|
||||
'a&b=c'
|
||||
)}`;
|
||||
const shown = stripUrlCredentials(
|
||||
`https://proxy.test/list?url=${encodeURIComponent(nested)}`
|
||||
);
|
||||
expect(new URL(shown ?? '').searchParams.get('url')).toBe(nested);
|
||||
// Plain text with a stray percent sign is not an escape.
|
||||
expect(
|
||||
stripUrlCredentials('https://proxy.test/list?note=100%25&hd=1')
|
||||
).toBe('https://proxy.test/list?note=100%25&hd=1');
|
||||
});
|
||||
|
||||
it('drops a kept part that still names a secret', () => {
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
'https://proxy.test/list?via=joe:hunter2@origin.test&hd=1'
|
||||
)
|
||||
).toBe('https://proxy.test/list?hd=1');
|
||||
expect(
|
||||
stripUrlCredentials(
|
||||
`https://proxy.test/list?note=${encodeURIComponent(
|
||||
'token: abc'
|
||||
)}&hd=1`
|
||||
)
|
||||
).toBe('https://proxy.test/list?hd=1');
|
||||
});
|
||||
|
||||
it('drops URLs nested deeper than it cleans', () => {
|
||||
let url = 'http://joe:hunter2@origin.test/a.m3u';
|
||||
for (let depth = 0; depth < 6; depth += 1) {
|
||||
url = `https://hop${depth}.test/?u=${encodeURIComponent(url)}`;
|
||||
}
|
||||
expect(stripUrlCredentials(url)).not.toMatch(/hunter2/);
|
||||
});
|
||||
|
||||
it('stays fast on long runs a backtracking pattern would retry', () => {
|
||||
const run = 'a'.repeat(50_000);
|
||||
const started = performance.now();
|
||||
for (const value of [run, `${run}.`.repeat(2), 'a.'.repeat(25_000)]) {
|
||||
stripUrlCredentials(
|
||||
`https://host.test/list?note=${encodeURIComponent(value)}`
|
||||
);
|
||||
}
|
||||
// About 0.25 s in total when idle; a backtracking pattern takes
|
||||
// minutes. The margin absorbs a loaded CI runner.
|
||||
expect(performance.now() - started).toBeLessThan(3_000);
|
||||
});
|
||||
|
||||
it('returns null for text that is not an absolute URL', () => {
|
||||
expect(stripUrlCredentials('get.php?username=joe')).toBeNull();
|
||||
expect(stripUrlCredentials('')).toBeNull();
|
||||
expect(stripUrlCredentials('not a url')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
decodeFully,
|
||||
findXtreamAccount,
|
||||
isSensitiveKey,
|
||||
looksLikeSearchParams,
|
||||
} from '@iptvnator/shared/logging';
|
||||
|
||||
/**
|
||||
* Short account keys some playlist URLs use. A log line keeps them for
|
||||
* diagnosis; a list on screen has no reason to.
|
||||
*/
|
||||
const SHORT_ACCOUNT_KEYS = new Set(['user', 'usr', 'pass', 'pas']);
|
||||
|
||||
/** How deep URLs nested in URLs are cleaned; anything deeper is dropped. */
|
||||
const MAX_NESTING = 4;
|
||||
/** Percent-decoding rounds a nested value gets before it is classified. */
|
||||
const MAX_DECODE_ROUNDS = 4;
|
||||
// Linear on any input: a match starts only where a name can start (the
|
||||
// lookbehinds), and every name part is bounded, so a long run of letters
|
||||
// cannot make a scan retry from each position.
|
||||
const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]{0,31}:\/\//iu;
|
||||
const EMBEDDED_URL = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s"'<>]+/giu;
|
||||
/** `joe:hunter2@origin.test`: an authority's userinfo without a scheme. */
|
||||
const BARE_USERINFO = /(?:^|[\s/])[^\s/:@]{1,256}:[^\s/@]{0,256}@[^\s/@]/u;
|
||||
// A key may be quoted, as in JSON or code: `"token": …`.
|
||||
const KEYED_PAIR =
|
||||
/(?<![a-z0-9_.-])([a-z][a-z0-9_.-]{0,63})["']?\s{0,8}[=:]/giu;
|
||||
const JSON_SHAPED = /^[{[]/;
|
||||
|
||||
/**
|
||||
* A nested value as it must be classified: percent-decoded one round at a
|
||||
* time until it reads as a URL or a parameter list, or stops changing, so a
|
||||
* URL encoded twice (`https%253A%252F%252F…`) is cleaned as the URL it is.
|
||||
* A URL found is not decoded further: its own encoded parameters stay
|
||||
* intact for the next level. Null when it is still encoded after the last
|
||||
* round: it cannot be shown as safe.
|
||||
*/
|
||||
export function unwrapNestedValue(value: string): string | null {
|
||||
let current = value;
|
||||
for (let round = 0; ; round += 1) {
|
||||
const trimmed = current.trim();
|
||||
if (ABSOLUTE_URL.test(trimmed) || looksLikeSearchParams(trimmed)) {
|
||||
return current;
|
||||
}
|
||||
let next: string;
|
||||
try {
|
||||
next = decodeURIComponent(current);
|
||||
} catch {
|
||||
// A stray `%` (`100%`) is text, not an escape.
|
||||
return current;
|
||||
}
|
||||
if (next === current) {
|
||||
return current;
|
||||
}
|
||||
if (round === MAX_DECODE_ROUNDS) {
|
||||
return null;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** A JSON object or array, such as a config in a parameter; `[1080p]` is not. */
|
||||
function isJsonLiteral(value: string): boolean {
|
||||
if (!JSON_SHAPED.test(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return typeof JSON.parse(value) === 'object';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A parameter whose value must not be on screen: every key the log redactor
|
||||
* treats as sensitive, plus the short account keys (`pass`, `usr`, …).
|
||||
* The name check uses the same predicate, so it hides what the URL hides.
|
||||
*/
|
||||
export function isDisplaySensitiveKey(key: string): boolean {
|
||||
return (
|
||||
SHORT_ACCOUNT_KEYS.has(decodeFully(key).toLowerCase()) ||
|
||||
isSensitiveKey(key)
|
||||
);
|
||||
}
|
||||
|
||||
/** Free text that names a secret, such as `token=…` or `password: …`. */
|
||||
function hasSensitivePair(value: string): boolean {
|
||||
const pairs = new RegExp(KEYED_PAIR.source, KEYED_PAIR.flags);
|
||||
for (let match = pairs.exec(value); match; match = pairs.exec(value)) {
|
||||
if (isDisplaySensitiveKey(match[1])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A kept URL part (a parameter value or a path segment) with any URL,
|
||||
* parameter list or credential nested in it cleaned the same way, or null
|
||||
* when it cannot be shown safely and must be dropped.
|
||||
*/
|
||||
function sanitizeNested(value: string, depth: number): string | null {
|
||||
const decoded = unwrapNestedValue(value);
|
||||
if (depth >= MAX_NESTING || decoded === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = decoded.trim();
|
||||
if (ABSOLUTE_URL.test(trimmed)) {
|
||||
return stripUrl(trimmed, depth + 1);
|
||||
}
|
||||
if (looksLikeSearchParams(trimmed)) {
|
||||
return withoutSensitiveParams(new URLSearchParams(trimmed), depth + 1);
|
||||
}
|
||||
// JSON can spell a key so no pattern reads it (`"pass\u0077ord"`): a
|
||||
// list has no need to show a config object, so it goes whole.
|
||||
if (
|
||||
isJsonLiteral(trimmed) ||
|
||||
BARE_USERINFO.test(decoded) ||
|
||||
hasSensitivePair(decoded)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const cleaned = decoded.replace(
|
||||
EMBEDDED_URL,
|
||||
(candidate) => stripUrl(candidate, depth + 1) ?? ''
|
||||
);
|
||||
// Nothing to clean: keep the value as it was written.
|
||||
return cleaned === decoded ? value : cleaned;
|
||||
}
|
||||
|
||||
function withoutSensitiveParams(
|
||||
params: URLSearchParams,
|
||||
depth: number
|
||||
): string {
|
||||
const kept = new URLSearchParams();
|
||||
params.forEach((value, key) => {
|
||||
if (isDisplaySensitiveKey(key)) {
|
||||
return;
|
||||
}
|
||||
const clean = sanitizeNested(value, depth);
|
||||
if (clean !== null) {
|
||||
kept.append(key, clean);
|
||||
}
|
||||
});
|
||||
return kept.toString();
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
function stripUrl(value: string, depth: number): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
|
||||
// The account segments of an Xtream stream path go whole, however the
|
||||
// marker is spelled and even when encoded separators join them into one.
|
||||
const segments = url.pathname.split('/');
|
||||
const account = findXtreamAccount(segments, { streamFollows: true });
|
||||
const kept: string[] = [];
|
||||
segments.forEach((segment, index) => {
|
||||
if (account.segments.has(index)) {
|
||||
return;
|
||||
}
|
||||
const decoded = decodeSegment(segment);
|
||||
const clean = sanitizeNested(decoded, depth);
|
||||
if (clean !== null) {
|
||||
kept.push(clean === decoded ? segment : encodeURIComponent(clean));
|
||||
}
|
||||
});
|
||||
url.pathname = kept.join('/');
|
||||
|
||||
const search = withoutSensitiveParams(url.searchParams, depth);
|
||||
url.search = search ? `?${search}` : '';
|
||||
|
||||
// Any fragment, not only a parameter list: `#https://joe:pw@…` is a URL.
|
||||
const fragment = url.hash.slice(1);
|
||||
if (fragment) {
|
||||
const clean = sanitizeNested(fragment, depth);
|
||||
if (!clean) {
|
||||
url.hash = '';
|
||||
} else if (clean !== fragment) {
|
||||
url.hash = clean;
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* A URL safe to show on screen. It lives beside its only consumer
|
||||
* (`source-display`): behind the shared logging barrel, which startup code
|
||||
* imports, it rode into the initial bundle.
|
||||
* Where the log redactor replaces secrets with
|
||||
* a marker, this drops them: the userinfo, every query or fragment parameter
|
||||
* the redactor treats as sensitive (`username`, `password`, `token`, `mac`,
|
||||
* …), and the two account segments after `/live/`, `/movie/`, `/series/` or
|
||||
* `/timeshift/` when a stream follows them. A URL or parameter list nested
|
||||
* in a kept parameter or path segment (a proxy's `?url=…`, even encoded
|
||||
* twice) is cleaned the same way, and a part that still names a secret (`joe:hunter2@…`, `token=…`) is
|
||||
* dropped. A list can then say where a source points without any part of
|
||||
* its credentials.
|
||||
*
|
||||
* Returns null for text that is not an absolute URL, so a caller never
|
||||
* renders the unparsed original by mistake.
|
||||
*/
|
||||
export function stripUrlCredentials(value: string): string | null {
|
||||
return stripUrl(value, 0);
|
||||
}
|
||||
+59
-43
@@ -131,58 +131,74 @@
|
||||
role="separator"
|
||||
></div>
|
||||
}
|
||||
<div
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.REFINE_RATING_SECTION'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="refine-menu-section-title"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{
|
||||
'WORKSPACE.REFINE_RATING_SECTION'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
<!-- The thresholds open in a submenu, so the
|
||||
menu stays short enough for a small window;
|
||||
the row shows the active one. The wrapper
|
||||
keeps the row out of MatMenu's direct
|
||||
items: the submenu offset is the first
|
||||
direct item's offsetTop, which for this
|
||||
last row pushed the submenu off screen. -->
|
||||
<div role="none">
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="minRating() === null"
|
||||
(click)="setMinRating(null)"
|
||||
class="refine-rating-trigger"
|
||||
[matMenuTriggerFor]="ratingMenu"
|
||||
>
|
||||
<mat-icon>star_outline</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.REFINE_RATING_SECTION'
|
||||
| translate
|
||||
}}</span>
|
||||
@if (minRating(); as rating) {
|
||||
<span class="refine-rating-value"
|
||||
>{{ rating }}.0+</span
|
||||
>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</mat-menu>
|
||||
<mat-menu
|
||||
#ratingMenu="matMenu"
|
||||
class="refine-menu refine-rating-menu"
|
||||
>
|
||||
<div
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.REFINE_RATING_SECTION' | translate
|
||||
"
|
||||
>
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="minRating() === null"
|
||||
(click)="setMinRating(null)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.FILTER_RATING_ANY' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
@for (
|
||||
threshold of ratingThresholds;
|
||||
track threshold
|
||||
) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="
|
||||
minRating() === threshold
|
||||
"
|
||||
(click)="setMinRating(threshold)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck
|
||||
>check</mat-icon
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.FILTER_RATING_ANY'
|
||||
| translate
|
||||
'WORKSPACE.FILTER_RATING_MIN'
|
||||
| translate: { value: threshold }
|
||||
}}</span>
|
||||
</button>
|
||||
@for (
|
||||
threshold of ratingThresholds;
|
||||
track threshold
|
||||
) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="
|
||||
minRating() === threshold
|
||||
"
|
||||
(click)="setMinRating(threshold)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck
|
||||
>check</mat-icon
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.FILTER_RATING_MIN'
|
||||
| translate
|
||||
: { value: threshold }
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
|
||||
+15
@@ -160,6 +160,21 @@
|
||||
background: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
|
||||
}
|
||||
|
||||
// The label span fills the row, so the active threshold sits at its end,
|
||||
// before the submenu arrow.
|
||||
.refine-rating-trigger ::ng-deep .mat-mdc-menu-item-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.refine-rating-value {
|
||||
margin-inline-start: auto;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@container category-content (max-width: 920px) {
|
||||
.category-content-header {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
|
||||
+26
-2
@@ -113,6 +113,16 @@ describe('CategoryContentViewComponent', () => {
|
||||
getItemProgress: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
/** Opens the rating thresholds from the open refine menu. */
|
||||
const openRatingSubmenu = () => {
|
||||
(
|
||||
document.querySelector(
|
||||
'.refine-rating-trigger'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
routeReady.set(true);
|
||||
playlist.set(null);
|
||||
@@ -320,10 +330,15 @@ describe('CategoryContentViewComponent', () => {
|
||||
refineButton?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const overlayText = document.body.textContent ?? '';
|
||||
let overlayText = document.body.textContent ?? '';
|
||||
expect(overlayText).toContain('WORKSPACE.REFINE_SORT_SECTION');
|
||||
expect(overlayText).toContain('WORKSPACE.SORT_DATE_DESC');
|
||||
expect(overlayText).toContain('WORKSPACE.REFINE_RATING_SECTION');
|
||||
// The thresholds wait in a submenu, so the menu fits a short window.
|
||||
expect(overlayText).not.toContain('WORKSPACE.FILTER_RATING_ANY');
|
||||
|
||||
openRatingSubmenu();
|
||||
overlayText = document.body.textContent ?? '';
|
||||
expect(overlayText).toContain('WORKSPACE.FILTER_RATING_ANY');
|
||||
});
|
||||
|
||||
@@ -446,6 +461,13 @@ describe('CategoryContentViewComponent', () => {
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
// The row that opens the thresholds names the active one.
|
||||
expect(
|
||||
document
|
||||
.querySelector('.refine-rating-trigger .refine-rating-value')
|
||||
?.textContent?.trim()
|
||||
).toBe('8.0+');
|
||||
openRatingSubmenu();
|
||||
|
||||
const groups = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
@@ -481,7 +503,9 @@ describe('CategoryContentViewComponent', () => {
|
||||
'false WORKSPACE.FILTER_RATING_MIN',
|
||||
]);
|
||||
expect(
|
||||
document.querySelectorAll('.refine-menu [mat-menu-item]')
|
||||
document.querySelectorAll(
|
||||
'.refine-menu [mat-menu-item]:not(.refine-rating-trigger)'
|
||||
)
|
||||
).toHaveLength(
|
||||
document.querySelectorAll('.refine-menu [role="menuitemradio"]')
|
||||
.length
|
||||
|
||||
@@ -4,6 +4,10 @@ export * from './lib/multi-source';
|
||||
export * from './lib/live-channel-playback-queue';
|
||||
|
||||
export * from './lib/source-health.service';
|
||||
export * from './lib/source-health-history.service';
|
||||
export * from './lib/source-status.service';
|
||||
export * from './lib/source-status-checks';
|
||||
export { sourceStatusBucket } from './lib/source-reachability';
|
||||
|
||||
export * from './lib/source-cleanup.service';
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SourceHealthEvidenceService } from '@iptvnator/services';
|
||||
import {
|
||||
SOURCE_HEALTH_HISTORY_STORAGE_KEY,
|
||||
SourceHealthHistoryService,
|
||||
} from './source-health-history.service';
|
||||
|
||||
const failed = (checkedAt: number) => ({
|
||||
state: 'unavailable' as const,
|
||||
reason: 'network' as const,
|
||||
confirmedInactive: false,
|
||||
checkedAt,
|
||||
});
|
||||
|
||||
describe('SourceHealthHistoryService', () => {
|
||||
const create = () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SourceHealthHistoryService,
|
||||
SourceHealthEvidenceService,
|
||||
],
|
||||
});
|
||||
return TestBed.inject(SourceHealthHistoryService);
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('keeps the start of a failing run across a restart', () => {
|
||||
create().record(['a'], failed(100));
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const restarted = create();
|
||||
restarted.record(['a'], failed(900));
|
||||
expect(restarted.entries().get('a')).toEqual({
|
||||
state: 'bad',
|
||||
checkedAt: 900,
|
||||
failingSince: 100,
|
||||
reason: 'network',
|
||||
});
|
||||
});
|
||||
|
||||
it('stores no URL or credential', () => {
|
||||
create().record(['a'], failed(100));
|
||||
expect(localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY)).toBe(
|
||||
'{"a":{"state":"bad","checkedAt":100,"failingSince":100,"reason":"network"}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the fingerprint of the checked connection, and starts over for another', () => {
|
||||
const history = create();
|
||||
history.record(['a'], failed(100), 'aaaa1111');
|
||||
history.record(['a'], failed(900), 'bbbb2222');
|
||||
expect(history.entries().get('a')).toEqual({
|
||||
state: 'bad',
|
||||
checkedAt: 900,
|
||||
failingSince: 900,
|
||||
reason: 'network',
|
||||
connection: 'bbbb2222',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a stored cause this build cannot describe', () => {
|
||||
localStorage.setItem(
|
||||
SOURCE_HEALTH_HISTORY_STORAGE_KEY,
|
||||
'{"a":{"state":"bad","checkedAt":1,"reason":"<script>"},"b":{"state":"bad","checkedAt":1,"reason":"auth"}}'
|
||||
);
|
||||
const history = create();
|
||||
expect(history.entries().get('a')).toEqual({
|
||||
state: 'bad',
|
||||
checkedAt: 1,
|
||||
});
|
||||
expect(history.entries().get('b')?.reason).toBe('auth');
|
||||
});
|
||||
|
||||
it('ignores inconclusive checks', () => {
|
||||
const history = create();
|
||||
history.record(['a'], {
|
||||
state: 'unavailable',
|
||||
reason: 'paused',
|
||||
confirmedInactive: false,
|
||||
checkedAt: 100,
|
||||
});
|
||||
expect(history.entries().size).toBe(0);
|
||||
expect(localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('forgets a deleted source and everything on a reset', () => {
|
||||
const history = create();
|
||||
const evidence = TestBed.inject(SourceHealthEvidenceService);
|
||||
history.record(['a', 'b'], failed(100));
|
||||
|
||||
evidence.connections.next({ id: 'a' });
|
||||
expect([...history.entries().keys()]).toEqual(['b']);
|
||||
|
||||
// An edit keeps the entry; the next check replaces it.
|
||||
evidence.connections.next({ id: 'b', playlist: { title: 'b' } });
|
||||
expect([...history.entries().keys()]).toEqual(['b']);
|
||||
|
||||
evidence.connections.next({});
|
||||
expect(history.entries().size).toBe(0);
|
||||
});
|
||||
|
||||
it('starts empty from unreadable storage', () => {
|
||||
localStorage.setItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY, '{nope');
|
||||
expect(create().entries().size).toBe(0);
|
||||
localStorage.setItem(
|
||||
SOURCE_HEALTH_HISTORY_STORAGE_KEY,
|
||||
'{"a":{"state":"weird","checkedAt":1}}'
|
||||
);
|
||||
TestBed.resetTestingModule();
|
||||
expect(create().entries().size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { SourceHealthEvidenceService } from '@iptvnator/services';
|
||||
import {
|
||||
SourceHealthHistoryEntry,
|
||||
SourceHealthSnapshot,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { isSourceHealthReason } from './source-health-identity';
|
||||
import {
|
||||
nextSourceHealthHistory,
|
||||
sourceReachabilityFromHealth,
|
||||
} from './source-reachability';
|
||||
|
||||
export const SOURCE_HEALTH_HISTORY_STORAGE_KEY = 'source-health:history:v1';
|
||||
|
||||
function readHistory(): Map<string, SourceHealthHistoryEntry> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return new Map();
|
||||
}
|
||||
const entries = new Map<string, SourceHealthHistoryEntry>();
|
||||
for (const [id, entry] of Object.entries(
|
||||
parsed as Record<string, SourceHealthHistoryEntry>
|
||||
)) {
|
||||
if (
|
||||
typeof entry?.checkedAt !== 'number' ||
|
||||
!['ok', 'bad', 'expired'].includes(entry.state)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// A reason this build cannot describe would show as a raw key.
|
||||
const { reason, connection, ...rest } = entry;
|
||||
entries.set(id, {
|
||||
...rest,
|
||||
...(isSourceHealthReason(reason) ? { reason } : {}),
|
||||
...(typeof connection === 'string' ? { connection } : {}),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(
|
||||
entries: ReadonlyMap<string, SourceHealthHistoryEntry>
|
||||
): Record<string, SourceHealthHistoryEntry> {
|
||||
const record: Record<string, SourceHealthHistoryEntry> = {};
|
||||
entries.forEach((entry, id) => (record[id] = entry));
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last conclusive health result of each source, by playlist ID, kept
|
||||
* across restarts. Live checks stay in memory (`SourceHealthService`); this
|
||||
* keeps only what a list needs before they run again: the state, when it
|
||||
* was checked, since when a source has failed and why, and a fingerprint
|
||||
* of the connection checked. No URL or credential is stored, so the
|
||||
* entries need no redaction.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceHealthHistoryService {
|
||||
private readonly state =
|
||||
signal<ReadonlyMap<string, SourceHealthHistoryEntry>>(readHistory());
|
||||
readonly entries = this.state.asReadonly();
|
||||
|
||||
constructor() {
|
||||
inject(SourceHealthEvidenceService).connections.subscribe(
|
||||
({ id, playlist }) => {
|
||||
if (!id) {
|
||||
this.write(new Map());
|
||||
} else if (!playlist) {
|
||||
this.forget(id);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param connection Fingerprint of the checked connection
|
||||
* (`fingerprintSourceHealthKey`); a result for another connection than
|
||||
* the kept one starts the history over.
|
||||
*/
|
||||
record(
|
||||
ids: readonly string[],
|
||||
snapshot: SourceHealthSnapshot,
|
||||
connection?: string
|
||||
): void {
|
||||
const reachability = sourceReachabilityFromHealth(snapshot);
|
||||
const next = new Map(this.state());
|
||||
let changed = false;
|
||||
for (const id of ids) {
|
||||
const previous = next.get(id);
|
||||
const entry = nextSourceHealthHistory(
|
||||
previous,
|
||||
reachability,
|
||||
snapshot.checkedAt,
|
||||
{ reason: snapshot.reason, connection }
|
||||
);
|
||||
if (entry && entry !== previous) {
|
||||
next.set(id, entry);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.write(next);
|
||||
}
|
||||
}
|
||||
|
||||
forget(id: string): void {
|
||||
if (!this.state().has(id)) {
|
||||
return;
|
||||
}
|
||||
const next = new Map(this.state());
|
||||
next.delete(id);
|
||||
this.write(next);
|
||||
}
|
||||
|
||||
private write(next: Map<string, SourceHealthHistoryEntry>): void {
|
||||
this.state.set(next);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SOURCE_HEALTH_HISTORY_STORAGE_KEY,
|
||||
JSON.stringify(toRecord(next))
|
||||
);
|
||||
} catch {
|
||||
// The history is a convenience; the live checks still work.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
sourceHealthKey,
|
||||
type PlaylistMeta,
|
||||
type SourceHealthReason,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const SOURCE_HEALTH_REASONS: ReadonlySet<string> = new Set<SourceHealthReason>([
|
||||
'available',
|
||||
'disabled',
|
||||
'expired',
|
||||
'auth',
|
||||
'http',
|
||||
'timeout',
|
||||
'network',
|
||||
'paused',
|
||||
'invalid',
|
||||
'unknown',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
/** Whether stored data names a reason this build can describe. */
|
||||
export function isSourceHealthReason(
|
||||
value: unknown
|
||||
): value is SourceHealthReason {
|
||||
return typeof value === 'string' && SOURCE_HEALTH_REASONS.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A 32-bit FNV-1a fingerprint of a source health key: enough to tell that a
|
||||
* source now points somewhere else, without storing where (or with which
|
||||
* credentials). Kept with the persisted health history.
|
||||
*/
|
||||
export function fingerprintSourceHealthKey(key: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < key.length; index += 1) {
|
||||
hash ^= key.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
export function sourceHealthFingerprint(p: Partial<PlaylistMeta>): string {
|
||||
return fingerprintSourceHealthKey(sourceHealthKey(p));
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
sourceHealthUnknown,
|
||||
sourceHealthError,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { fingerprintSourceHealthKey } from './source-health-identity';
|
||||
import { SourceHealthHistoryService } from './source-health-history.service';
|
||||
import { SourceHealthProbesService } from './source-health-probes.service';
|
||||
|
||||
interface Job {
|
||||
@@ -32,6 +34,7 @@ interface Job {
|
||||
export class SourceHealthService {
|
||||
private readonly probes = inject(SourceHealthProbesService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly history = inject(SourceHealthHistoryService);
|
||||
readonly snapshots = signal<ReadonlyMap<string, SourceHealthSnapshot>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -301,5 +304,11 @@ export class SourceHealthService {
|
||||
: previous?.lastSuccessAt,
|
||||
};
|
||||
this.snapshots.update((map) => new Map(map).set(key, snapshot));
|
||||
// Every playlist with these connection details shares the result.
|
||||
const ids = [...this.identities]
|
||||
.filter(([, identity]) => identity === key)
|
||||
.map(([id]) => id);
|
||||
if (ids.length)
|
||||
this.history.record(ids, snapshot, fingerprintSourceHealthKey(key));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
nextSourceHealthHistory,
|
||||
sourceReachabilityFromHealth,
|
||||
sourceReachabilityFromPortalStatus,
|
||||
sourceStatusBucket,
|
||||
} from './source-reachability';
|
||||
|
||||
describe('source reachability', () => {
|
||||
it.each([
|
||||
[{ state: 'active', reason: 'available' }, 'ok'],
|
||||
[{ state: 'expired', reason: 'expired' }, 'expired'],
|
||||
[{ state: 'inactive', reason: 'disabled' }, 'bad'],
|
||||
[{ state: 'inactive', reason: 'auth' }, 'bad'],
|
||||
[{ state: 'checking', reason: 'unknown' }, 'checking'],
|
||||
[{ state: 'unavailable', reason: 'network' }, 'bad'],
|
||||
[{ state: 'unavailable', reason: 'timeout' }, 'bad'],
|
||||
[{ state: 'unavailable', reason: 'http' }, 'bad'],
|
||||
[{ state: 'unavailable', reason: 'invalid' }, 'bad'],
|
||||
// Checks that never reached the source say nothing about it.
|
||||
[{ state: 'unavailable', reason: 'paused' }, 'unknown'],
|
||||
[{ state: 'unavailable', reason: 'cancelled' }, 'unknown'],
|
||||
[{ state: 'unavailable', reason: 'unknown' }, 'unknown'],
|
||||
[{ state: 'unknown', reason: 'unknown' }, 'unknown'],
|
||||
] as const)('maps %o to %s', (snapshot, expected) => {
|
||||
expect(sourceReachabilityFromHealth(snapshot)).toBe(expected);
|
||||
});
|
||||
|
||||
it('treats a source without a snapshot as never checked', () => {
|
||||
expect(sourceReachabilityFromHealth(undefined)).toBe('unknown');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['active', 'ok'],
|
||||
['expired', 'expired'],
|
||||
['inactive', 'bad'],
|
||||
['unavailable', 'bad'],
|
||||
['checking', 'checking'],
|
||||
] as const)('maps the portal status %s to %s', (status, expected) => {
|
||||
expect(sourceReachabilityFromPortalStatus(status)).toBe(expected);
|
||||
});
|
||||
|
||||
it('files expired accounts with the sources that do not respond', () => {
|
||||
expect(sourceStatusBucket('ok')).toBe('ok');
|
||||
expect(sourceStatusBucket('bad')).toBe('bad');
|
||||
expect(sourceStatusBucket('expired')).toBe('bad');
|
||||
expect(sourceStatusBucket('checking')).toBeNull();
|
||||
expect(sourceStatusBucket('unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextSourceHealthHistory', () => {
|
||||
it('starts a failing run at the first failed check', () => {
|
||||
expect(nextSourceHealthHistory(undefined, 'bad', 100)).toEqual({
|
||||
state: 'bad',
|
||||
checkedAt: 100,
|
||||
failingSince: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the start of the run across later failures', () => {
|
||||
const first = nextSourceHealthHistory(
|
||||
{ state: 'ok', checkedAt: 50, lastSuccessAt: 50 },
|
||||
'bad',
|
||||
100
|
||||
);
|
||||
const second = nextSourceHealthHistory(first, 'expired', 400);
|
||||
expect(second).toEqual({
|
||||
state: 'expired',
|
||||
checkedAt: 400,
|
||||
failingSince: 100,
|
||||
lastSuccessAt: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the cause of a failure, never of a success', () => {
|
||||
const failed = nextSourceHealthHistory(undefined, 'bad', 100, {
|
||||
reason: 'auth',
|
||||
});
|
||||
expect(failed?.reason).toBe('auth');
|
||||
expect(
|
||||
nextSourceHealthHistory(failed, 'ok', 200, { reason: 'available' })
|
||||
).toEqual({ state: 'ok', checkedAt: 200, lastSuccessAt: 200 });
|
||||
});
|
||||
|
||||
it('starts over for an edited connection, keeps the run for the same one', () => {
|
||||
const failed = nextSourceHealthHistory(undefined, 'bad', 100, {
|
||||
connection: 'old',
|
||||
});
|
||||
expect(
|
||||
nextSourceHealthHistory(failed, 'bad', 900, { connection: 'old' })
|
||||
?.failingSince
|
||||
).toBe(100);
|
||||
expect(
|
||||
nextSourceHealthHistory(failed, 'bad', 900, { connection: 'new' })
|
||||
).toEqual({
|
||||
state: 'bad',
|
||||
checkedAt: 900,
|
||||
failingSince: 900,
|
||||
connection: 'new',
|
||||
});
|
||||
// An inconclusive check of the new connection keeps nothing old.
|
||||
expect(
|
||||
nextSourceHealthHistory(failed, 'unknown', 900, {
|
||||
connection: 'new',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ends the run when the source answers again', () => {
|
||||
expect(
|
||||
nextSourceHealthHistory(
|
||||
{ state: 'bad', checkedAt: 100, failingSince: 10 },
|
||||
'ok',
|
||||
500
|
||||
)
|
||||
).toEqual({ state: 'ok', checkedAt: 500, lastSuccessAt: 500 });
|
||||
});
|
||||
|
||||
it('keeps the previous entry for an inconclusive check', () => {
|
||||
const previous = {
|
||||
state: 'bad',
|
||||
checkedAt: 100,
|
||||
failingSince: 10,
|
||||
} as const;
|
||||
expect(nextSourceHealthHistory(previous, 'unknown', 900)).toBe(
|
||||
previous
|
||||
);
|
||||
expect(nextSourceHealthHistory(previous, 'checking', 900)).toBe(
|
||||
previous
|
||||
);
|
||||
expect(
|
||||
nextSourceHealthHistory(undefined, 'unknown', 900)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
ConclusiveSourceReachability,
|
||||
SourceHealthHistoryEntry,
|
||||
SourceHealthReason,
|
||||
SourceHealthSnapshot,
|
||||
SourceReachability,
|
||||
SourceStatusBucket,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
// Runtime helpers live here, beside the lazily loaded services that use
|
||||
// them: in `@iptvnator/shared/interfaces` they rode into a chunk the app
|
||||
// preloads at startup.
|
||||
|
||||
/**
|
||||
* Health reasons that mean the source itself did not answer properly. A
|
||||
* paused, cancelled or unknown check says nothing about the source.
|
||||
*/
|
||||
const UNREACHABLE_REASONS = new Set(['network', 'timeout', 'http', 'invalid']);
|
||||
|
||||
export function sourceReachabilityFromHealth(
|
||||
snapshot: Pick<SourceHealthSnapshot, 'state' | 'reason'> | undefined
|
||||
): SourceReachability {
|
||||
switch (snapshot?.state) {
|
||||
case 'active':
|
||||
return 'ok';
|
||||
case 'expired':
|
||||
return 'expired';
|
||||
case 'inactive':
|
||||
return 'bad';
|
||||
case 'checking':
|
||||
return 'checking';
|
||||
case 'unavailable':
|
||||
return UNREACHABLE_REASONS.has(snapshot.reason) ? 'bad' : 'unknown';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser build's Xtream portal status, by the same rules. */
|
||||
export function sourceReachabilityFromPortalStatus(
|
||||
status: 'active' | 'inactive' | 'expired' | 'unavailable' | 'checking'
|
||||
): SourceReachability {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'ok';
|
||||
case 'expired':
|
||||
return 'expired';
|
||||
case 'checking':
|
||||
return 'checking';
|
||||
default:
|
||||
return 'bad';
|
||||
}
|
||||
}
|
||||
|
||||
export function isConclusiveReachability(
|
||||
reachability: SourceReachability
|
||||
): reachability is ConclusiveSourceReachability {
|
||||
return (
|
||||
reachability === 'ok' ||
|
||||
reachability === 'bad' ||
|
||||
reachability === 'expired'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expired accounts count as not responding: the source answers, but the
|
||||
* account cannot play anything, which is what the filter is for.
|
||||
*/
|
||||
export function sourceStatusBucket(
|
||||
reachability: SourceReachability
|
||||
): SourceStatusBucket | null {
|
||||
if (reachability === 'ok') {
|
||||
return 'ok';
|
||||
}
|
||||
return reachability === 'bad' || reachability === 'expired' ? 'bad' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds one check into the kept history. An inconclusive check keeps the
|
||||
* previous entry; a failure keeps the start of the current failing run, so
|
||||
* a source that has not answered for three days still says three days after
|
||||
* every later failed check.
|
||||
*/
|
||||
export function nextSourceHealthHistory(
|
||||
kept: SourceHealthHistoryEntry | undefined,
|
||||
reachability: SourceReachability,
|
||||
checkedAt: number,
|
||||
detail: { reason?: SourceHealthReason; connection?: string } = {}
|
||||
): SourceHealthHistoryEntry | undefined {
|
||||
const { reason, connection } = detail;
|
||||
// A check of another connection starts the history over.
|
||||
const previous =
|
||||
kept?.connection && connection && kept.connection !== connection
|
||||
? undefined
|
||||
: kept;
|
||||
if (!isConclusiveReachability(reachability)) {
|
||||
return previous;
|
||||
}
|
||||
const checked = connection ? { connection } : {};
|
||||
if (reachability === 'ok') {
|
||||
return { state: 'ok', checkedAt, lastSuccessAt: checkedAt, ...checked };
|
||||
}
|
||||
const wasFailing = previous !== undefined && previous.state !== 'ok';
|
||||
return {
|
||||
state: reachability,
|
||||
checkedAt,
|
||||
failingSince: wasFailing
|
||||
? (previous.failingSince ?? previous.checkedAt)
|
||||
: checkedAt,
|
||||
...(previous?.lastSuccessAt !== undefined
|
||||
? { lastSuccessAt: previous.lastSuccessAt }
|
||||
: {}),
|
||||
...(reason ? { reason } : {}),
|
||||
...checked,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { keepSourcesChecked } from './source-status-checks';
|
||||
import { SourceStatusService } from './source-status.service';
|
||||
|
||||
const source = (id: string, url?: string): PlaylistMeta =>
|
||||
({
|
||||
_id: id,
|
||||
title: id,
|
||||
url,
|
||||
importDate: '',
|
||||
autoRefresh: false,
|
||||
count: 0,
|
||||
}) as PlaylistMeta;
|
||||
|
||||
const library = signal<PlaylistMeta[]>([]);
|
||||
|
||||
@Component({ selector: 'app-host', template: '' })
|
||||
class HostComponent {
|
||||
constructor() {
|
||||
keepSourcesChecked(() => library());
|
||||
}
|
||||
}
|
||||
|
||||
describe('keepSourcesChecked', () => {
|
||||
let status: {
|
||||
supportsStatusFilter: boolean;
|
||||
tracks: jest.Mock;
|
||||
ensureChecked: jest.Mock;
|
||||
};
|
||||
|
||||
const mount = () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: SourceStatusService, useValue: status }],
|
||||
});
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
TestBed.tick();
|
||||
return fixture;
|
||||
};
|
||||
const signalFor = (id: string): AbortSignal =>
|
||||
status.ensureChecked.mock.calls.find(
|
||||
([playlist]: [PlaylistMeta]) => playlist._id === id
|
||||
)?.[1];
|
||||
|
||||
beforeEach(() => {
|
||||
status = {
|
||||
supportsStatusFilter: true,
|
||||
tracks: jest.fn((playlist: PlaylistMeta) => !!playlist.url),
|
||||
ensureChecked: jest.fn(),
|
||||
};
|
||||
library.set([
|
||||
source('a', 'http://a.test/list.m3u'),
|
||||
source('b', 'http://b.test/list.m3u'),
|
||||
source('local'),
|
||||
]);
|
||||
});
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('checks every network source, shown or not, once', () => {
|
||||
mount();
|
||||
expect(status.ensureChecked.mock.calls.map(([p]) => p._id)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
|
||||
// A new result elsewhere in the library starts no second check.
|
||||
library.update((list) => [...list]);
|
||||
TestBed.tick();
|
||||
expect(status.ensureChecked).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('cancels a removed source, checks an edited one again, and stops on destroy', () => {
|
||||
const fixture = mount();
|
||||
const firstA = signalFor('a');
|
||||
library.set([
|
||||
source('a', 'http://a.test/moved.m3u'),
|
||||
source('c', 'http://c.test/list.m3u'),
|
||||
]);
|
||||
TestBed.tick();
|
||||
expect(firstA.aborted).toBe(true);
|
||||
expect(signalFor('b').aborted).toBe(true);
|
||||
expect(status.ensureChecked).toHaveBeenCalledTimes(4);
|
||||
|
||||
fixture.destroy();
|
||||
const [, , secondA, c] = status.ensureChecked.mock.calls.map(
|
||||
([, signal]) => signal as AbortSignal
|
||||
);
|
||||
expect(secondA.aborted).toBe(true);
|
||||
expect(c.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the browser build to its rows', () => {
|
||||
status.supportsStatusFilter = false;
|
||||
mount();
|
||||
expect(status.ensureChecked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DestroyRef, effect, inject, untracked } from '@angular/core';
|
||||
import { PlaylistMeta, sourceHealthKey } from '@iptvnator/shared/interfaces';
|
||||
import { SourceStatusService } from './source-status.service';
|
||||
|
||||
/**
|
||||
* Keeps every listed source checked for as long as the caller lives,
|
||||
* whatever rows it shows. A status filter hides the sources that have no
|
||||
* result yet, and their rows were what started the checks, so a saved
|
||||
* "Responding" filter stayed empty after a restart.
|
||||
*
|
||||
* Desktop only: there the status filter exists and a check is one shared
|
||||
* job per source, which a row's own check joins rather than repeats. A
|
||||
* source whose address changes is checked again. Call it in an injection
|
||||
* context.
|
||||
*/
|
||||
export function keepSourcesChecked(
|
||||
playlists: () => readonly PlaylistMeta[]
|
||||
): void {
|
||||
const status = inject(SourceStatusService);
|
||||
if (!status.supportsStatusFilter) {
|
||||
return;
|
||||
}
|
||||
const running = new Map<string, AbortController>();
|
||||
|
||||
effect(() => {
|
||||
const wanted = new Map<string, PlaylistMeta>();
|
||||
for (const playlist of playlists()) {
|
||||
if (status.tracks(playlist)) {
|
||||
wanted.set(
|
||||
`${playlist._id}\u0000${sourceHealthKey(playlist)}`,
|
||||
playlist
|
||||
);
|
||||
}
|
||||
}
|
||||
// The checks publish their results through signals; reading them
|
||||
// here would restart every check on every result.
|
||||
untracked(() => {
|
||||
for (const [key, controller] of running) {
|
||||
if (!wanted.has(key)) {
|
||||
controller.abort();
|
||||
running.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, playlist] of wanted) {
|
||||
if (!running.has(key)) {
|
||||
const controller = new AbortController();
|
||||
running.set(key, controller);
|
||||
status.ensureChecked(playlist, controller.signal);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
running.forEach((controller) => controller.abort());
|
||||
running.clear();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
PortalStatusService,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
SourceHealthHistoryEntry,
|
||||
SourceHealthSnapshot,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { sourceHealthFingerprint } from './source-health-identity';
|
||||
import { SourceHealthHistoryService } from './source-health-history.service';
|
||||
import { SourceHealthService } from './source-health.service';
|
||||
import { SourceStatusService } from './source-status.service';
|
||||
|
||||
const base = {
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
};
|
||||
const m3u: PlaylistMeta = {
|
||||
...base,
|
||||
_id: 'm',
|
||||
title: 'm',
|
||||
url: 'https://m.test/list',
|
||||
};
|
||||
const local: PlaylistMeta = { ...base, _id: 'l', title: 'l' };
|
||||
const xtream: PlaylistMeta = {
|
||||
...base,
|
||||
_id: 'x',
|
||||
title: 'x',
|
||||
serverUrl: 'http://x.test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
};
|
||||
|
||||
describe('SourceStatusService', () => {
|
||||
let snapshots: Map<string, SourceHealthSnapshot>;
|
||||
let history: ReturnType<
|
||||
typeof signal<Map<string, SourceHealthHistoryEntry>>
|
||||
>;
|
||||
let health: { get: jest.Mock; check: jest.Mock; recheck: jest.Mock };
|
||||
let portal: {
|
||||
getCachedStatus: jest.Mock;
|
||||
checkPortalStatus: jest.Mock;
|
||||
};
|
||||
|
||||
const create = (supportsSourceHealth: boolean) => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SourceStatusService,
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsSourceHealth },
|
||||
},
|
||||
{ provide: SourceHealthService, useValue: health },
|
||||
{
|
||||
provide: SourceHealthHistoryService,
|
||||
useValue: { entries: history },
|
||||
},
|
||||
{ provide: PortalStatusService, useValue: portal },
|
||||
],
|
||||
});
|
||||
return TestBed.inject(SourceStatusService);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
snapshots = new Map();
|
||||
history = signal(new Map());
|
||||
health = {
|
||||
get: jest.fn((p: PlaylistMeta) => snapshots.get(p._id)),
|
||||
check: jest.fn().mockResolvedValue(undefined),
|
||||
recheck: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
portal = {
|
||||
getCachedStatus: jest.fn().mockReturnValue(null),
|
||||
checkPortalStatus: jest.fn().mockResolvedValue('active'),
|
||||
};
|
||||
});
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
describe('desktop', () => {
|
||||
it('has no status for a local file', () => {
|
||||
expect(create(true).statusOf(local)).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a live result with the kept failing run', () => {
|
||||
snapshots.set('m', {
|
||||
state: 'unavailable',
|
||||
reason: 'network',
|
||||
confirmedInactive: false,
|
||||
checkedAt: 900,
|
||||
});
|
||||
history.set(
|
||||
new Map([
|
||||
['m', { state: 'bad', checkedAt: 900, failingSince: 100 }],
|
||||
])
|
||||
);
|
||||
expect(create(true).statusOf(m3u)).toEqual({
|
||||
reachability: 'bad',
|
||||
failingSince: 100,
|
||||
checkedAt: 900,
|
||||
reason: 'network',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a result kept for the source before its address changed', () => {
|
||||
history.set(
|
||||
new Map([
|
||||
[
|
||||
'm',
|
||||
{
|
||||
state: 'bad',
|
||||
checkedAt: 50,
|
||||
failingSince: 10,
|
||||
connection: 'another-address',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const service = create(true);
|
||||
expect(service.statusOf(m3u)).toEqual({
|
||||
reachability: 'unknown',
|
||||
});
|
||||
|
||||
history.set(
|
||||
new Map([
|
||||
[
|
||||
'm',
|
||||
{
|
||||
state: 'bad',
|
||||
checkedAt: 50,
|
||||
connection: sourceHealthFingerprint(m3u),
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
expect(service.statusOf(m3u)?.reachability).toBe('bad');
|
||||
});
|
||||
|
||||
it("keeps a failed check's cause across a restart", () => {
|
||||
history.set(
|
||||
new Map([
|
||||
[
|
||||
'm',
|
||||
{
|
||||
state: 'bad',
|
||||
checkedAt: 50,
|
||||
failingSince: 10,
|
||||
reason: 'auth',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
expect(create(true).statusOf(m3u)?.reason).toBe('auth');
|
||||
});
|
||||
|
||||
it('shows the kept result before and while the source is checked', () => {
|
||||
history.set(
|
||||
new Map([
|
||||
['m', { state: 'bad', checkedAt: 50, failingSince: 10 }],
|
||||
])
|
||||
);
|
||||
const service = create(true);
|
||||
expect(service.statusOf(m3u)).toEqual({
|
||||
reachability: 'bad',
|
||||
failingSince: 10,
|
||||
checkedAt: 50,
|
||||
pending: false,
|
||||
});
|
||||
snapshots.set('m', {
|
||||
state: 'checking',
|
||||
reason: 'unknown',
|
||||
confirmedInactive: false,
|
||||
checkedAt: 0,
|
||||
});
|
||||
expect(service.statusOf(m3u)?.pending).toBe(true);
|
||||
});
|
||||
|
||||
it('says why an inconclusive check is inconclusive', () => {
|
||||
snapshots.set('m', {
|
||||
state: 'unavailable',
|
||||
reason: 'paused',
|
||||
confirmedInactive: false,
|
||||
checkedAt: 700,
|
||||
});
|
||||
expect(create(true).statusOf(m3u)).toEqual({
|
||||
reachability: 'unknown',
|
||||
reason: 'paused',
|
||||
checkedAt: 700,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an unchecked source as unknown', () => {
|
||||
expect(create(true).statusOf(m3u)).toEqual({
|
||||
reachability: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates checks to the health service', () => {
|
||||
const service = create(true);
|
||||
const controller = new AbortController();
|
||||
service.ensureChecked(m3u, controller.signal);
|
||||
service.ensureChecked(local, controller.signal);
|
||||
service.recheck(m3u);
|
||||
expect(health.check).toHaveBeenCalledTimes(1);
|
||||
expect(health.check).toHaveBeenCalledWith(m3u, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(health.recheck).toHaveBeenCalledWith(m3u);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser build', () => {
|
||||
it('tracks only Xtream accounts', () => {
|
||||
const service = create(false);
|
||||
expect(service.statusOf(m3u)).toBeNull();
|
||||
expect(service.statusOf(xtream)).toEqual({
|
||||
reachability: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows checking, then the portal status', async () => {
|
||||
let resolve!: (status: string) => void;
|
||||
portal.checkPortalStatus.mockReturnValue(
|
||||
new Promise((r) => (resolve = r))
|
||||
);
|
||||
const service = create(false);
|
||||
service.ensureChecked(xtream, new AbortController().signal);
|
||||
expect(service.statusOf(xtream)?.reachability).toBe('checking');
|
||||
resolve('expired');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.statusOf(xtream)?.reachability).toBe('expired');
|
||||
});
|
||||
|
||||
it('serves a cached portal status without a request', () => {
|
||||
portal.getCachedStatus.mockReturnValue('inactive');
|
||||
const service = create(false);
|
||||
service.ensureChecked(xtream, new AbortController().signal);
|
||||
expect(portal.checkPortalStatus).not.toHaveBeenCalled();
|
||||
expect(service.statusOf(xtream)).toEqual({
|
||||
reachability: 'bad',
|
||||
reason: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an edited account's status from the old account's slow check", async () => {
|
||||
let finishOld!: (status: string) => void;
|
||||
portal.checkPortalStatus
|
||||
.mockReturnValueOnce(new Promise((r) => (finishOld = r)))
|
||||
.mockResolvedValueOnce('active');
|
||||
const service = create(false);
|
||||
service.recheck(xtream);
|
||||
|
||||
const edited = { ...xtream, password: 'changed' } as PlaylistMeta;
|
||||
service.ensureChecked(edited, new AbortController().signal);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.statusOf(edited)?.reachability).toBe('ok');
|
||||
|
||||
finishOld('inactive');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.statusOf(edited)?.reachability).toBe('ok');
|
||||
});
|
||||
|
||||
it('drops a result that lands after the list closed', async () => {
|
||||
const service = create(false);
|
||||
const controller = new AbortController();
|
||||
service.ensureChecked(xtream, controller.signal);
|
||||
controller.abort();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(service.statusOf(xtream)?.reachability).toBe('unknown');
|
||||
});
|
||||
|
||||
it('bypasses the cache on recheck', () => {
|
||||
create(false).recheck(xtream);
|
||||
expect(portal.checkPortalStatus).toHaveBeenCalledWith(
|
||||
'http://x.test',
|
||||
'u',
|
||||
'p',
|
||||
{ skipCache: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Injectable, Injector, inject, signal } from '@angular/core';
|
||||
import {
|
||||
PortalStatus,
|
||||
PortalStatusService,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
SourceHealthReason,
|
||||
sourceHealthType,
|
||||
SourceStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { sourceHealthFingerprint } from './source-health-identity';
|
||||
import { SourceHealthHistoryService } from './source-health-history.service';
|
||||
import { SourceHealthService } from './source-health.service';
|
||||
import {
|
||||
isConclusiveReachability,
|
||||
sourceReachabilityFromHealth,
|
||||
sourceReachabilityFromPortalStatus,
|
||||
} from './source-reachability';
|
||||
|
||||
type XtreamAccount = PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
function isXtreamAccount(playlist: PlaylistMeta): playlist is XtreamAccount {
|
||||
return !!(playlist.serverUrl && playlist.username && playlist.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* One status per network source for every list that shows one: the Sources
|
||||
* page, its status filter and the playlist switcher.
|
||||
*
|
||||
* - Desktop: the live health check, else the last result kept across
|
||||
* restarts, so a source that failed yesterday is listed as failing before
|
||||
* it is checked again.
|
||||
* - Browser build: the Xtream portal status only, as before; it has no
|
||||
* probes for M3U URLs or Stalker portals.
|
||||
*
|
||||
* Local files have no status (`null`).
|
||||
*/
|
||||
/** What the browser build's portal status says about its cause. */
|
||||
const PORTAL_STATUS_REASON: Record<
|
||||
PortalStatus,
|
||||
SourceHealthReason | undefined
|
||||
> = {
|
||||
active: 'available',
|
||||
inactive: 'disabled',
|
||||
expired: 'expired',
|
||||
unavailable: 'network',
|
||||
checking: undefined,
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceStatusService {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly history = inject(SourceHealthHistoryService);
|
||||
private readonly portalStatus = inject(PortalStatusService);
|
||||
/**
|
||||
* Browser-build portal statuses by playlist and connection
|
||||
* (`portalKey`): a slow check of an account the user has since edited
|
||||
* lands under the old key and cannot overwrite the new account's.
|
||||
*/
|
||||
private readonly portalStatuses = signal<ReadonlyMap<string, PortalStatus>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
/** Whether the status of every network source is known (desktop). */
|
||||
get supportsStatusFilter(): boolean {
|
||||
return this.runtime.supportsSourceHealth;
|
||||
}
|
||||
|
||||
tracks(playlist: PlaylistMeta): boolean {
|
||||
return this.runtime.supportsSourceHealth
|
||||
? !!sourceHealthType(playlist)
|
||||
: isXtreamAccount(playlist);
|
||||
}
|
||||
|
||||
statusOf(playlist: PlaylistMeta): SourceStatus | null {
|
||||
if (!this.tracks(playlist)) {
|
||||
return null;
|
||||
}
|
||||
if (!this.runtime.supportsSourceHealth) {
|
||||
const status = this.portalStatuses().get(portalKey(playlist));
|
||||
return status
|
||||
? {
|
||||
reachability: sourceReachabilityFromPortalStatus(status),
|
||||
reason: PORTAL_STATUS_REASON[status],
|
||||
}
|
||||
: { reachability: 'unknown' };
|
||||
}
|
||||
const live = this.health.get(playlist);
|
||||
const reachability = sourceReachabilityFromHealth(live);
|
||||
const entry = this.history.entries().get(playlist._id);
|
||||
// A result kept for the source's old address or account is not its.
|
||||
const kept =
|
||||
entry?.connection &&
|
||||
entry.connection !== sourceHealthFingerprint(playlist)
|
||||
? undefined
|
||||
: entry;
|
||||
if (isConclusiveReachability(reachability)) {
|
||||
return {
|
||||
reachability,
|
||||
failingSince: kept?.failingSince,
|
||||
checkedAt: live?.checkedAt,
|
||||
reason: live?.reason,
|
||||
};
|
||||
}
|
||||
if (kept) {
|
||||
return {
|
||||
reachability: kept.state,
|
||||
failingSince: kept.failingSince,
|
||||
checkedAt: kept.checkedAt,
|
||||
pending: reachability === 'checking',
|
||||
reason: kept.reason,
|
||||
};
|
||||
}
|
||||
// An inconclusive check still says why ("Connection checks paused").
|
||||
return reachability === 'checking' || !live
|
||||
? { reachability }
|
||||
: {
|
||||
reachability,
|
||||
reason: live.reason,
|
||||
checkedAt: live.checkedAt || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** A passive check: cached results count; the signal cancels it. */
|
||||
ensureChecked(playlist: PlaylistMeta, signal: AbortSignal): void {
|
||||
if (!this.tracks(playlist)) {
|
||||
return;
|
||||
}
|
||||
if (this.runtime.supportsSourceHealth) {
|
||||
void this.health.check(playlist, { signal });
|
||||
} else if (isXtreamAccount(playlist)) {
|
||||
void this.checkPortal(playlist, false, signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** "Check again" / "Retry": a fresh check that bypasses every cache. */
|
||||
recheck(playlist: PlaylistMeta): void {
|
||||
if (!this.tracks(playlist)) {
|
||||
return;
|
||||
}
|
||||
if (this.runtime.supportsSourceHealth) {
|
||||
void this.health.recheck(playlist);
|
||||
} else if (isXtreamAccount(playlist)) {
|
||||
void this.checkPortal(playlist, true);
|
||||
}
|
||||
}
|
||||
|
||||
private get health(): SourceHealthService {
|
||||
return this.injector.get(SourceHealthService);
|
||||
}
|
||||
|
||||
private async checkPortal(
|
||||
playlist: XtreamAccount,
|
||||
fresh: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const { serverUrl, username, password } = playlist;
|
||||
const key = portalKey(playlist);
|
||||
const cached = fresh
|
||||
? null
|
||||
: this.portalStatus.getCachedStatus(serverUrl, username, password);
|
||||
if (cached) {
|
||||
this.setPortalStatus(key, cached);
|
||||
return;
|
||||
}
|
||||
if (fresh || !this.portalStatuses().has(key)) {
|
||||
this.setPortalStatus(key, 'checking');
|
||||
}
|
||||
let status: PortalStatus;
|
||||
try {
|
||||
status = await this.portalStatus.checkPortalStatus(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
fresh ? { skipCache: true } : undefined
|
||||
);
|
||||
} catch {
|
||||
status = 'unavailable';
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
// A slow portal must not land after the list has closed; drop a
|
||||
// placeholder so the next opening checks again.
|
||||
if (this.portalStatuses().get(key) === 'checking') {
|
||||
this.setPortalStatus(key, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.setPortalStatus(key, status);
|
||||
}
|
||||
|
||||
private setPortalStatus(key: string, status: PortalStatus | null): void {
|
||||
this.portalStatuses.update((current) => {
|
||||
const next = new Map(current);
|
||||
if (status) {
|
||||
next.set(key, status);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A playlist's portal status key: its ID and its connection. */
|
||||
function portalKey(playlist: PlaylistMeta): string {
|
||||
return `${playlist._id}\u0000${sourceHealthFingerprint(playlist)}`;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
export type KeyboardShortcutGroupId =
|
||||
| 'global'
|
||||
| 'navigation'
|
||||
| 'playback'
|
||||
| 'dialogs';
|
||||
'global' | 'navigation' | 'playback' | 'dialogs';
|
||||
|
||||
export interface PlatformShortcutChord {
|
||||
mac: readonly string[];
|
||||
@@ -10,9 +7,7 @@ export interface PlatformShortcutChord {
|
||||
}
|
||||
|
||||
export type KeyboardShortcutChord =
|
||||
| string
|
||||
| readonly string[]
|
||||
| PlatformShortcutChord;
|
||||
string | readonly string[] | PlatformShortcutChord;
|
||||
|
||||
export interface KeyboardShortcutDefinition {
|
||||
id: string;
|
||||
@@ -73,6 +68,14 @@ export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [
|
||||
keys: [commandChord('K')],
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: 'open-playlist-switcher',
|
||||
group: 'global',
|
||||
labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_PLAYLIST_SWITCHER',
|
||||
icon: 'playlist_play',
|
||||
keys: [commandChord('P')],
|
||||
order: 15,
|
||||
},
|
||||
{
|
||||
id: 'open-global-search',
|
||||
group: 'global',
|
||||
|
||||
+14
@@ -1,4 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SourceActivityService } from '@iptvnator/services';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
import {
|
||||
createAbortError,
|
||||
@@ -203,6 +204,19 @@ describe('withContent loading-cached phase', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('holds the source busy for removal and refresh until the import settles', async () => {
|
||||
const gate = createDeferred<unknown[]>();
|
||||
dataSource.getContent.mockImplementation(() => gate.promise);
|
||||
const activity = TestBed.inject(SourceActivityService);
|
||||
|
||||
const initialization = store.initializeContent();
|
||||
expect(activity.isBusy('playlist-1')).toBe(true);
|
||||
|
||||
gate.resolve([]);
|
||||
await initialization;
|
||||
expect(activity.isBusy('playlist-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('still marks the import as running once a remote phase is reported', async () => {
|
||||
dataSource.getCategories.mockImplementation(
|
||||
(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DbOperationEvent,
|
||||
isDbAbortError,
|
||||
resetHostConnectivityGuard,
|
||||
SourceActivityService,
|
||||
XtreamPendingRestoreService,
|
||||
XtreamImportStatus,
|
||||
} from '@iptvnator/services';
|
||||
@@ -244,6 +245,7 @@ export function withContent() {
|
||||
const dataService = inject(DataService);
|
||||
const databaseService = inject(DatabaseService);
|
||||
const pendingRestoreService = inject(XtreamPendingRestoreService);
|
||||
const sourceActivity = inject(SourceActivityService);
|
||||
const xtreamApiService = inject(XtreamApiService);
|
||||
const importTypes: ContentType[] = ['live', 'vod', 'series'];
|
||||
let activeInitializationPromise: Promise<void> | null = null;
|
||||
@@ -1112,9 +1114,15 @@ export function withContent() {
|
||||
return activeInitializationPromise;
|
||||
}
|
||||
|
||||
// Deletion and refreshes wait for the import's writes.
|
||||
const playlistId = getCredentialsFromStore()?.playlistId;
|
||||
const release = playlistId
|
||||
? sourceActivity.begin([playlistId])
|
||||
: undefined;
|
||||
const initializationPromise = executeContentInitialization(
|
||||
ignoreBlockedState
|
||||
).finally(() => {
|
||||
release?.();
|
||||
if (activeInitializationPromise === initializationPromise) {
|
||||
activeInitializationPromise = null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
XtreamPlaylistData,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { SourceActivityService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { XtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service';
|
||||
|
||||
@@ -284,6 +285,30 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
expect(xtreamStore.initializeContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('holds the routed source busy until the import it awaits settles', async () => {
|
||||
xtreamStore.checkPortalStatus.mockImplementation(async () => {
|
||||
portalStatus.set('active');
|
||||
return 'active';
|
||||
});
|
||||
const importDone = createDeferred<void>();
|
||||
xtreamStore.initializeContent.mockImplementationOnce(
|
||||
() => importDone.promise
|
||||
);
|
||||
const activity = TestBed.inject(SourceActivityService);
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
// Taken at once, before any await: a refresh's own hold ends when
|
||||
// its navigation does, and nothing may find the source idle then.
|
||||
expect(activity.isBusy(PLAYLIST_ID)).toBe(true);
|
||||
await flushEffects();
|
||||
expect(xtreamStore.initializeContent).toHaveBeenCalled();
|
||||
expect(activity.isBusy(PLAYLIST_ID)).toBe(true);
|
||||
|
||||
importDone.resolve();
|
||||
await flushEffects();
|
||||
expect(activity.isBusy(PLAYLIST_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the routed section loading before status checks complete during playlist switches', async () => {
|
||||
const portalStatusCheck = createDeferred<PortalStatusType>();
|
||||
xtreamStore.checkPortalStatus.mockReturnValue(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
XtreamPlaylistData,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { SourceActivityService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
|
||||
function normalizeOptionalConnectionValue(
|
||||
@@ -212,6 +213,9 @@ export class XtreamWorkspaceRouteSession {
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly activity = inject(SourceActivityService);
|
||||
/** Sources this sync loop holds busy, released when the loop settles. */
|
||||
private readonly heldSources = new Map<string, () => void>();
|
||||
|
||||
private syncInFlight = false;
|
||||
private syncPending = false;
|
||||
@@ -266,6 +270,7 @@ export class XtreamWorkspaceRouteSession {
|
||||
}
|
||||
|
||||
private scheduleSyncRouteContext(): void {
|
||||
this.holdRoutedSource();
|
||||
if (this.syncInFlight) {
|
||||
this.syncPending = true;
|
||||
return;
|
||||
@@ -281,10 +286,26 @@ export class XtreamWorkspaceRouteSession {
|
||||
} while (this.syncPending);
|
||||
} finally {
|
||||
this.syncInFlight = false;
|
||||
this.heldSources.forEach((release) => release());
|
||||
this.heldSources.clear();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the routed source busy from the moment its route is reached
|
||||
* until the sync, and the import it awaits, settle. An Xtream refresh
|
||||
* releases its own hold when its navigation ends, and this one is taken
|
||||
* on that NavigationEnd, so removal never sees the source idle between
|
||||
* the catalog delete and the re-import.
|
||||
*/
|
||||
private holdRoutedSource(): void {
|
||||
const playlistId = getXtreamRouteTarget(this.router.url).playlistId;
|
||||
if (playlistId && !this.heldSources.has(playlistId)) {
|
||||
this.heldSources.set(playlistId, this.activity.begin([playlistId]));
|
||||
}
|
||||
}
|
||||
|
||||
private async syncRouteContext(): Promise<void> {
|
||||
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
|
||||
const playlistId =
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { SortBy, SortOrder, SortService } from './sort.service';
|
||||
|
||||
const playlist = (
|
||||
id: string,
|
||||
fields: Partial<PlaylistMeta> = {}
|
||||
): PlaylistMeta => ({
|
||||
_id: id,
|
||||
title: id,
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe('SortService', () => {
|
||||
const create = () => {
|
||||
TestBed.configureTestingModule({ providers: [SortService] });
|
||||
return TestBed.inject(SortService);
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('sorts by the last refresh, falling back to the import date', () => {
|
||||
const ids = create()
|
||||
.sortPlaylists(
|
||||
[
|
||||
playlist('old', { importDate: '2025-01-01T00:00:00Z' }),
|
||||
playlist('refreshed', {
|
||||
importDate: '2024-01-01T00:00:00Z',
|
||||
updateDate: Date.parse('2026-06-01T00:00:00Z'),
|
||||
}),
|
||||
playlist('new', { importDate: '2026-03-01T00:00:00Z' }),
|
||||
],
|
||||
{ by: SortBy.UPDATED, order: SortOrder.DESC }
|
||||
)
|
||||
.map((item) => item._id);
|
||||
expect(ids).toEqual(['refreshed', 'new', 'old']);
|
||||
});
|
||||
|
||||
it('sorts by name as the list shows it, when told how', () => {
|
||||
const sources = [
|
||||
playlist('zed', { title: 'zed' }),
|
||||
playlist('url', { title: 'get.php?username=zzz&password=x' }),
|
||||
playlist('mid', { title: 'mid' }),
|
||||
];
|
||||
const service = create();
|
||||
const byTitle = service
|
||||
.sortPlaylists(sources, { by: SortBy.NAME, order: SortOrder.ASC })
|
||||
.map((item) => item._id);
|
||||
expect(byTitle).toEqual(['url', 'mid', 'zed']);
|
||||
|
||||
// The URL-named source shows as its host, "abc.test".
|
||||
const shown = (item: PlaylistMeta) =>
|
||||
item._id === 'url' ? 'abc.test' : item.title;
|
||||
const byShownName = service
|
||||
.sortPlaylists(
|
||||
sources,
|
||||
{ by: SortBy.NAME, order: SortOrder.DESC },
|
||||
shown
|
||||
)
|
||||
.map((item) => item._id);
|
||||
expect(byShownName).toEqual(['zed', 'mid', 'url']);
|
||||
});
|
||||
|
||||
it('sorts by channel count, with no count as zero', () => {
|
||||
const ids = create()
|
||||
.sortPlaylists(
|
||||
[
|
||||
playlist('xtream', {
|
||||
count: undefined as unknown as number,
|
||||
}),
|
||||
playlist('big', { count: 900 }),
|
||||
playlist('small', { count: 3 }),
|
||||
],
|
||||
{ by: SortBy.CHANNELS, order: SortOrder.DESC }
|
||||
)
|
||||
.map((item) => item._id);
|
||||
expect(ids).toEqual(['big', 'small', 'xtream']);
|
||||
});
|
||||
|
||||
it('keeps the failing-first choice across a restart', async () => {
|
||||
create().setSortOptions({
|
||||
by: SortBy.NAME,
|
||||
order: SortOrder.ASC,
|
||||
failingFirst: true,
|
||||
});
|
||||
TestBed.resetTestingModule();
|
||||
expect(await firstValueFrom(create().getSortOptions())).toEqual({
|
||||
by: SortBy.NAME,
|
||||
order: SortOrder.ASC,
|
||||
failingFirst: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to newest first for an unknown saved sort', async () => {
|
||||
localStorage.setItem(
|
||||
'iptvnator-sort-options',
|
||||
JSON.stringify({ by: 'size', order: 'asc' })
|
||||
);
|
||||
expect(await firstValueFrom(create().getSortOptions())).toEqual({
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.DESC,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,11 @@ import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export enum SortBy {
|
||||
DATE_ADDED = 'date',
|
||||
/** Last refresh, falling back to the import date. */
|
||||
UPDATED = 'updated',
|
||||
NAME = 'name',
|
||||
/** Channel count; only M3U playlists store one. */
|
||||
CHANNELS = 'channels',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
@@ -16,10 +20,22 @@ export enum SortOrder {
|
||||
export interface SortOptions {
|
||||
by: SortBy;
|
||||
order: SortOrder;
|
||||
/**
|
||||
* Lists that know source health put the sources that do not respond
|
||||
* first, keeping this order within each part.
|
||||
*/
|
||||
failingFirst?: boolean;
|
||||
}
|
||||
|
||||
const SORT_BY_VALUES = new Set<string>(Object.values(SortBy));
|
||||
|
||||
const SORT_OPTIONS_STORAGE_KEY = 'iptvnator-sort-options';
|
||||
|
||||
function updatedAt(playlist: PlaylistMeta): number {
|
||||
const time = playlist.updateDate ?? new Date(playlist.importDate).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -53,13 +69,17 @@ export class SortService {
|
||||
|
||||
// Validate that the saved options match our expected types
|
||||
if (
|
||||
(parsedOptions.by === SortBy.DATE_ADDED ||
|
||||
parsedOptions.by === SortBy.NAME ||
|
||||
parsedOptions.by === SortBy.CUSTOM) &&
|
||||
SORT_BY_VALUES.has(parsedOptions.by) &&
|
||||
(parsedOptions.order === SortOrder.ASC ||
|
||||
parsedOptions.order === SortOrder.DESC)
|
||||
) {
|
||||
return parsedOptions;
|
||||
return {
|
||||
by: parsedOptions.by,
|
||||
order: parsedOptions.order,
|
||||
...(parsedOptions.failingFirst === true
|
||||
? { failingFirst: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
return defaultOptions;
|
||||
@@ -83,9 +103,16 @@ export class SortService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nameOf The name a list shows for a source, for Name order: a
|
||||
* list that shows a source by its host must sort it by its host, not by
|
||||
* a stored title such as `get.php?username=…`.
|
||||
*/
|
||||
sortPlaylists(
|
||||
playlists: PlaylistMeta[],
|
||||
options: SortOptions
|
||||
options: SortOptions,
|
||||
nameOf: (playlist: PlaylistMeta) => string = (playlist) =>
|
||||
playlist.title
|
||||
): PlaylistMeta[] {
|
||||
const { by, order } = options;
|
||||
|
||||
@@ -93,13 +120,17 @@ export class SortService {
|
||||
let comparison = 0;
|
||||
|
||||
if (by === SortBy.NAME) {
|
||||
comparison = a.title
|
||||
comparison = nameOf(a)
|
||||
.toLowerCase()
|
||||
.localeCompare(b.title.toLowerCase());
|
||||
.localeCompare(nameOf(b).toLowerCase());
|
||||
} else if (by === SortBy.DATE_ADDED) {
|
||||
const dateA = new Date(a.importDate).getTime();
|
||||
const dateB = new Date(b.importDate).getTime();
|
||||
comparison = dateA - dateB;
|
||||
} else if (by === SortBy.UPDATED) {
|
||||
comparison = updatedAt(a) - updatedAt(b);
|
||||
} else if (by === SortBy.CHANNELS) {
|
||||
comparison = (a.count ?? 0) - (b.count ?? 0);
|
||||
} else if (by === SortBy.CUSTOM) {
|
||||
// Sort by position field (lower position comes first)
|
||||
const posA = a.position ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@@ -113,4 +113,5 @@ export * from './lib/xtream-connection-test';
|
||||
export * from './lib/portal-request-failure.util';
|
||||
|
||||
export * from './lib/source-health';
|
||||
export * from './lib/source-reachability';
|
||||
export * from './lib/server-storage.contract';
|
||||
@@ -16,7 +16,9 @@ describe('playlistDisplayLabel', () => {
|
||||
// The default :80 is dropped by URL normalization, which reads better
|
||||
// anyway; a non-default port is kept (see the case above).
|
||||
expect(
|
||||
playlistDisplayLabel('http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2')
|
||||
playlistDisplayLabel(
|
||||
'http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2'
|
||||
)
|
||||
).toBe('marveltv.info');
|
||||
});
|
||||
|
||||
@@ -67,7 +69,9 @@ describe('playlistDisplayLabel', () => {
|
||||
describe('playlistNameHasCredentials', () => {
|
||||
it('detects query-string credentials', () => {
|
||||
expect(
|
||||
playlistNameHasCredentials('http://x.tv/get.php?username=a&password=b')
|
||||
playlistNameHasCredentials(
|
||||
'http://x.tv/get.php?username=a&password=b'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -81,4 +85,13 @@ describe('playlistNameHasCredentials', () => {
|
||||
expect(playlistNameHasCredentials('cord-cutter')).toBe(false);
|
||||
expect(playlistNameHasCredentials('http://plain.tv:8080')).toBe(false);
|
||||
});
|
||||
|
||||
it('answers the same for the same name on every call', () => {
|
||||
const name = 'Portal usr== abc pas== def';
|
||||
expect([1, 2, 3].map(() => playlistNameHasCredentials(name))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
/** Query keys whose presence means the string is a credential-bearing URL. */
|
||||
const CREDENTIAL_KEYS = /(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i;
|
||||
const CREDENTIAL_KEYS =
|
||||
/(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i;
|
||||
|
||||
/** Hand-typed credential fragments, e.g. "usr== Tl24uy0xGi pas== abc". */
|
||||
const INLINE_CREDENTIALS =
|
||||
@@ -56,6 +57,9 @@ export function playlistNameHasCredentials(
|
||||
rawName: string | null | undefined
|
||||
): boolean {
|
||||
const name = rawName ?? '';
|
||||
// INLINE_CREDENTIALS is global for `replace`; `test` on a global pattern
|
||||
// resumes at its lastIndex, so a second call would skip the match.
|
||||
INLINE_CREDENTIALS.lastIndex = 0;
|
||||
return CREDENTIAL_KEYS.test(name) || INLINE_CREDENTIALS.test(name);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { SourceHealthReason } from './source-health';
|
||||
|
||||
/**
|
||||
* What a source list shows about one network source, folded from the six
|
||||
* health states: `ok` answers, `bad` does not, `expired` answers with an
|
||||
* expired account, `checking` is mid-check with nothing known yet, and
|
||||
* `unknown` was never checked or the check was inconclusive.
|
||||
*/
|
||||
export type SourceReachability =
|
||||
'ok' | 'bad' | 'expired' | 'checking' | 'unknown';
|
||||
|
||||
/** A reachability a check actually established. */
|
||||
export type ConclusiveSourceReachability = Extract<
|
||||
SourceReachability,
|
||||
'ok' | 'bad' | 'expired'
|
||||
>;
|
||||
|
||||
/** The two choices of a "responds / does not respond" status filter. */
|
||||
export type SourceStatusBucket = 'ok' | 'bad';
|
||||
|
||||
/** A network source's status as a list shows it. */
|
||||
export interface SourceStatus {
|
||||
reachability: SourceReachability;
|
||||
/** When the current run of failed checks began. */
|
||||
failingSince?: number;
|
||||
checkedAt?: number;
|
||||
/** A check is running; `reachability` is the last known result. */
|
||||
pending?: boolean;
|
||||
/**
|
||||
* Why the check concluded what it did (`auth`, `disabled`, `timeout`…),
|
||||
* when known; a list otherwise describes the reachability alone.
|
||||
*/
|
||||
reason?: SourceHealthReason;
|
||||
}
|
||||
|
||||
/** The last conclusive result kept for a source across restarts. */
|
||||
export interface SourceHealthHistoryEntry {
|
||||
state: ConclusiveSourceReachability;
|
||||
checkedAt: number;
|
||||
/** When the current run of failed checks began; absent while `ok`. */
|
||||
failingSince?: number;
|
||||
lastSuccessAt?: number;
|
||||
/** The cause of the last failed check; absent while `ok`. */
|
||||
reason?: SourceHealthReason;
|
||||
/**
|
||||
* Fingerprint of the connection that was checked
|
||||
* (`sourceHealthFingerprint`): a result for an edited address or account
|
||||
* is not this source's any more.
|
||||
*/
|
||||
connection?: string;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getPlaylistSourceIcon,
|
||||
resolvePlaylistSourceIconKey,
|
||||
resolvePlaylistSourceKind,
|
||||
SOURCE_TYPE_ICONS,
|
||||
} from './source-type-icons.util';
|
||||
|
||||
@@ -40,4 +41,14 @@ describe('source type icons', () => {
|
||||
SOURCE_TYPE_ICONS[expected]
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ macAddress: '00:1A:79:00:00:01', serverUrl: 'http://x' }, 'stalker'],
|
||||
// An Xtream row missing a credential is still Xtream, not M3U: the
|
||||
// old list filter dropped it from every type.
|
||||
[{ serverUrl: 'http://xtream' }, 'xtream'],
|
||||
[{}, 'm3u'],
|
||||
] as const)('puts %o in the %s family', (playlist, expected) => {
|
||||
expect(resolvePlaylistSourceKind(playlist)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -48,3 +48,20 @@ export function getPlaylistSourceIcon(
|
||||
): string {
|
||||
return SOURCE_TYPE_ICONS[resolvePlaylistSourceIconKey(playlist)];
|
||||
}
|
||||
|
||||
/** The provider family a stored playlist belongs to. */
|
||||
export type PlaylistSourceKind = 'm3u' | 'xtream' | 'stalker';
|
||||
|
||||
/**
|
||||
* The provider family by the same precedence as the icon, so every type
|
||||
* filter, count and label agrees with the icon on the row: a playlist
|
||||
* counted as Xtream is also listed and drawn as Xtream.
|
||||
*/
|
||||
export function resolvePlaylistSourceKind(
|
||||
playlist: Pick<PlaylistMeta, 'macAddress' | 'serverUrl'>
|
||||
): PlaylistSourceKind {
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
return playlist.serverUrl ? 'xtream' : 'm3u';
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
export {
|
||||
isSensitiveKey,
|
||||
looksLikeSearchParams,
|
||||
REDACTED_VALUE,
|
||||
redactSensitiveData,
|
||||
} from './lib/redact-sensitive-data';
|
||||
export { decodeFully, findXtreamAccount } from './lib/url-credential-parts';
|
||||
export type { XtreamAccountScan } from './lib/url-credential-parts';
|
||||
export type { RedactionOptions } from './lib/redact-sensitive-data';
|
||||
export {
|
||||
SQL_TRACE_STATEMENT_TYPE,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { REDACTED_VALUE, redactSensitiveData } from './redact-sensitive-data';
|
||||
import {
|
||||
isSensitiveKey,
|
||||
REDACTED_VALUE,
|
||||
redactSensitiveData,
|
||||
} from './redact-sensitive-data';
|
||||
import { decodeFully } from './url-credential-parts';
|
||||
|
||||
const TEST_SECRETS = [
|
||||
'settings-api-key-secret',
|
||||
@@ -300,6 +305,63 @@ describe('redactSensitiveData', () => {
|
||||
expect(output).not.toContain(password);
|
||||
});
|
||||
|
||||
it('redacts Xtream path credentials whatever the marker case', () => {
|
||||
const output = serialized(
|
||||
redactSensitiveData(
|
||||
'https://example.com/LIVE/case-user-secret/case-pass-secret/1.ts'
|
||||
)
|
||||
);
|
||||
expect(output).not.toContain('case-user-secret');
|
||||
expect(output).not.toContain('case-pass-secret');
|
||||
});
|
||||
|
||||
it('reads keys and path markers through every encoding layer', () => {
|
||||
expect(isSensitiveKey('%2570assword')).toBe(true);
|
||||
expect(isSensitiveKey('%252570assword')).toBe(true);
|
||||
const output = serialized(
|
||||
redactSensitiveData([
|
||||
'https://proxy.example/list?%2570assword=layer-pass-secret&hd=1',
|
||||
'https://example.com/%256Cive/layer-user-secret/layer-pw-secret/1.ts',
|
||||
])
|
||||
);
|
||||
expect(output).not.toContain('layer-pass-secret');
|
||||
expect(output).not.toContain('layer-user-secret');
|
||||
expect(output).not.toContain('layer-pw-secret');
|
||||
expect(output).toContain('hd=1');
|
||||
});
|
||||
|
||||
it('redacts an Xtream account hidden behind encoded separators', () => {
|
||||
const output = serialized(
|
||||
redactSensitiveData([
|
||||
'https://example.com/live%2Fsep-user-secret%2Fsep-pass-secret%2F1.ts',
|
||||
'https://example.com/live%252Fsep2-user-secret/sep2-pass-secret/1.ts',
|
||||
'https://example.com/live/sep3-user-secret%2Fx/sep3-pass-secret/1.ts',
|
||||
])
|
||||
);
|
||||
for (const secret of [
|
||||
'sep-user-secret',
|
||||
'sep-pass-secret',
|
||||
'sep2-user-secret',
|
||||
'sep2-pass-secret',
|
||||
'sep3-user-secret',
|
||||
'sep3-pass-secret',
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it('decodes within a bound and leaves a stray percent sign alone', () => {
|
||||
expect(decodeFully('%25252570')).toBe('p');
|
||||
expect(decodeFully('100%')).toBe('100%');
|
||||
expect(decodeFully('plain')).toBe('plain');
|
||||
// `%70` encoded eight more times: past the bound, still encoded.
|
||||
let deep = '%70';
|
||||
for (let round = 0; round < 8; round += 1) {
|
||||
deep = deep.replace(/%/g, '%25');
|
||||
}
|
||||
expect(decodeFully(deep)).toContain('%');
|
||||
});
|
||||
|
||||
it('does not mutate input and safely bounds cycles, depth, arrays, objects, and strings', () => {
|
||||
const input: Record<string, unknown> = {
|
||||
status: 'ok',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { decodeFully, findXtreamAccount } from './url-credential-parts';
|
||||
|
||||
export const REDACTED_VALUE = '[Redacted]';
|
||||
|
||||
const CIRCULAR_VALUE = '[Circular]';
|
||||
@@ -54,13 +56,6 @@ const SENSITIVE_KEY_SUFFIXES = [
|
||||
'username',
|
||||
];
|
||||
|
||||
const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([
|
||||
'live',
|
||||
'movie',
|
||||
'series',
|
||||
'timeshift',
|
||||
]);
|
||||
|
||||
export interface RedactionOptions {
|
||||
maxDepth?: number;
|
||||
maxArrayItems?: number;
|
||||
@@ -76,10 +71,12 @@ interface ResolvedRedactionOptions {
|
||||
}
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
return key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return decodeFully(key)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
const normalized = normalizeKey(key);
|
||||
return (
|
||||
SENSITIVE_KEY_NAMES.has(normalized) ||
|
||||
@@ -144,11 +141,8 @@ function redactUrl(
|
||||
}
|
||||
|
||||
const pathSegments = redacted.pathname.split('/');
|
||||
for (let index = 0; index < pathSegments.length - 2; index += 1) {
|
||||
if (XTREAM_CREDENTIAL_PATH_SEGMENTS.has(pathSegments[index])) {
|
||||
pathSegments[index + 1] = REDACTED_VALUE;
|
||||
pathSegments[index + 2] = REDACTED_VALUE;
|
||||
}
|
||||
for (const index of findXtreamAccount(pathSegments).segments) {
|
||||
pathSegments[index] = REDACTED_VALUE;
|
||||
}
|
||||
redacted.pathname = pathSegments.join('/');
|
||||
|
||||
@@ -196,7 +190,7 @@ function redactEmbeddedSensitivePairs(value: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeSearchParams(value: string): boolean {
|
||||
export function looksLikeSearchParams(value: string): boolean {
|
||||
return /^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/u.test(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* How a URL's parts read once decoded, for every check that compares them
|
||||
* by name: the log redactor and the on-screen URL cleanup share these, so a
|
||||
* spelling one of them understands the other does too.
|
||||
*/
|
||||
|
||||
const XTREAM_CREDENTIAL_PATH_SEGMENTS: ReadonlySet<string> = new Set([
|
||||
'live',
|
||||
'movie',
|
||||
'series',
|
||||
'timeshift',
|
||||
]);
|
||||
/** Percent-decoding rounds before a name is compared. */
|
||||
const MAX_DECODE_ROUNDS = 4;
|
||||
|
||||
/**
|
||||
* A name as it reads once every percent-encoding layer is undone, within a
|
||||
* bound: `%2570assword` is `password`, and `%256Cive` is `live`. Every
|
||||
* check that compares a key or marker by name must see this form, or one
|
||||
* more encoding layer slips a secret past it. A malformed escape (`100%`)
|
||||
* ends the decoding as written.
|
||||
*/
|
||||
export function decodeFully(value: string): string {
|
||||
let current = value;
|
||||
for (
|
||||
let round = 0;
|
||||
round < MAX_DECODE_ROUNDS && current.includes('%');
|
||||
round += 1
|
||||
) {
|
||||
let next: string;
|
||||
try {
|
||||
next = decodeURIComponent(current);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (next === current) {
|
||||
break;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export interface XtreamAccountScan {
|
||||
/** Indexes of the raw path segments that hold an account part. */
|
||||
readonly segments: ReadonlySet<number>;
|
||||
/** Each account found, as `[user, password]`. */
|
||||
readonly accounts: readonly (readonly [string, string])[];
|
||||
}
|
||||
|
||||
/** A decoded path part and the raw segment it came from. */
|
||||
interface PathPart {
|
||||
readonly text: string;
|
||||
readonly owner: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the Xtream account in a URL path (`/live/<user>/<password>/…`,
|
||||
* markers in any case or encoding). The path is read two ways and both
|
||||
* count: each raw segment decoded whole, so a slash encoded inside a user
|
||||
* name stays part of it (`/live/joe%2Fx/hunter2/…`), and the decoded path
|
||||
* split on every separator, so one segment cannot carry a whole account
|
||||
* (`live%2Fjoe%2Fhunter2%2F1.ts`). With `streamFollows`, the two parts
|
||||
* after a marker count only when another part follows them, as a stream
|
||||
* does; a log line redacts without it.
|
||||
*/
|
||||
export function findXtreamAccount(
|
||||
rawSegments: readonly string[],
|
||||
options: { streamFollows?: boolean } = {}
|
||||
): XtreamAccountScan {
|
||||
const whole: PathPart[] = [];
|
||||
const split: PathPart[] = [];
|
||||
rawSegments.forEach((raw, owner) => {
|
||||
const decoded = decodeFully(raw);
|
||||
whole.push({ text: decoded, owner });
|
||||
for (const text of decoded.split('/')) {
|
||||
split.push({ text, owner });
|
||||
}
|
||||
});
|
||||
const segments = new Set<number>();
|
||||
const accounts: [string, string][] = [];
|
||||
const after = options.streamFollows ? 3 : 2;
|
||||
for (const parts of [whole, split]) {
|
||||
for (let index = 0; index + after < parts.length; index += 1) {
|
||||
if (
|
||||
XTREAM_CREDENTIAL_PATH_SEGMENTS.has(
|
||||
parts[index].text.toLowerCase()
|
||||
)
|
||||
) {
|
||||
const [user, password] = [parts[index + 1], parts[index + 2]];
|
||||
segments.add(user.owner).add(password.owner);
|
||||
accounts.push([user.text, password.text]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { segments, accounts };
|
||||
}
|
||||
@@ -205,15 +205,24 @@
|
||||
|
||||
// Play / Pause uses the unified app primary, not the station's dominant color
|
||||
// — same blue as every other CTA so radio doesn't feel like a different app.
|
||||
// An icon button at the fab's 56px, filled: the fab tokens were about 6 KB of
|
||||
// the initial stylesheet for this one control, and the fab also reset the
|
||||
// keyboard focus ring (`outline: none` on focus) that the icon button keeps.
|
||||
.play-btn {
|
||||
@include mat.fab-overrides(
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
container-color: var(--app-selection-color, var(--mat-sys-primary)),
|
||||
foreground-color: #fff,
|
||||
state-layer-size: 56px,
|
||||
icon-size: 32px,
|
||||
icon-color: #fff,
|
||||
container-shape: 50%,
|
||||
state-layer-color: #fff,
|
||||
ripple-color: color-mix(in srgb, #fff 12%, transparent),
|
||||
)
|
||||
);
|
||||
box-shadow: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--app-selection-color, var(--mat-sys-primary));
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -86,8 +86,8 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="play-btn"
|
||||
mat-fab
|
||||
[matTooltip]="
|
||||
(playState() === 'play'
|
||||
? 'AUDIO_PLAYER.PAUSE'
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// Buttons, menus and the segmented control of the IPTVnator UI kit, over
|
||||
// Angular Material: Material keeps the behaviour (focus, keyboard, overlay,
|
||||
// ARIA), these mixins set the look through `mat.*-overrides()`.
|
||||
//
|
||||
// Button variants are roles, not looks:
|
||||
// - `secondary-button` (`mat-flat-button`): every ordinary action and every
|
||||
// dropdown trigger (label + 12px chevron, opening a `mat-menu`);
|
||||
// - `ghost-button` (`mat-button`) and `ghost-icon-button`
|
||||
// (`mat-icon-button`): row and header actions.
|
||||
// No outline, no ripple: hover changes the fill only, and a disabled button
|
||||
// is the same button at 40% opacity. Focus keeps the app's ring
|
||||
// (`styles.scss` rings every `.mat-mdc-button-base`).
|
||||
@use 'sass:map';
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
$radius: 10px;
|
||||
$radius-sm: 8px;
|
||||
|
||||
$_sizes: (
|
||||
md: (
|
||||
height: 40px,
|
||||
radius: $radius,
|
||||
padding: 16px,
|
||||
icon-padding: 14px,
|
||||
text: 13.5px,
|
||||
icon: 16px,
|
||||
gap: 8px,
|
||||
),
|
||||
sm: (
|
||||
height: 32px,
|
||||
radius: $radius-sm,
|
||||
padding: 12px,
|
||||
icon-padding: 10px,
|
||||
text: 12.5px,
|
||||
icon: 14px,
|
||||
gap: 6px,
|
||||
),
|
||||
);
|
||||
|
||||
@function _size($size, $key) {
|
||||
@return map.get($_sizes, $size, $key);
|
||||
}
|
||||
|
||||
@mixin _icon-size($size) {
|
||||
.mat-icon {
|
||||
width: $size;
|
||||
height: $size;
|
||||
font-size: $size;
|
||||
line-height: $size;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin _disabled {
|
||||
&[disabled],
|
||||
&.mat-mdc-button-disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/// `$size`: `md` (40px) or `sm` (32px).
|
||||
@mixin secondary-button($size: md) {
|
||||
$fill: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
filled-container-color: $fill,
|
||||
filled-disabled-container-color: $fill,
|
||||
filled-label-text-color: var(--app-heading-color),
|
||||
filled-disabled-label-text-color: var(--app-heading-color),
|
||||
filled-container-height: _size($size, height),
|
||||
filled-container-shape: _size($size, radius),
|
||||
filled-horizontal-padding: _size($size, padding),
|
||||
filled-label-text-size: _size($size, text),
|
||||
filled-label-text-weight: 600,
|
||||
filled-label-text-tracking: 0,
|
||||
filled-icon-spacing: _size($size, gap),
|
||||
filled-state-layer-color: var(--app-on-surface),
|
||||
filled-hover-state-layer-opacity: 0.06,
|
||||
filled-focus-state-layer-opacity: 0.06,
|
||||
filled-pressed-state-layer-opacity: 0.1,
|
||||
filled-ripple-color: transparent,
|
||||
)
|
||||
);
|
||||
@include _icon-size(_size($size, icon));
|
||||
@include _disabled;
|
||||
}
|
||||
|
||||
/// A secondary button at another height (toolbars that stack on narrow
|
||||
/// windows use 36px).
|
||||
@mixin secondary-button-height($height) {
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
filled-container-height: $height,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// A text action: `--app-body-color`, the heading colour on hover.
|
||||
@mixin ghost-button($size: sm) {
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
text-label-text-color: var(--app-body-color),
|
||||
text-disabled-label-text-color: var(--app-body-color),
|
||||
text-container-height: _size($size, height),
|
||||
text-container-shape: _size($size, radius),
|
||||
text-horizontal-padding: _size($size, padding),
|
||||
text-with-icon-horizontal-padding: _size($size, icon-padding),
|
||||
text-label-text-size: _size($size, text),
|
||||
text-label-text-weight: 600,
|
||||
text-label-text-tracking: 0,
|
||||
text-icon-spacing: 6px,
|
||||
text-state-layer-color: var(--app-on-surface),
|
||||
text-hover-state-layer-opacity: 0.08,
|
||||
text-focus-state-layer-opacity: 0.08,
|
||||
text-pressed-state-layer-opacity: 0.12,
|
||||
text-ripple-color: transparent,
|
||||
)
|
||||
);
|
||||
@include _icon-size(14px);
|
||||
@include _disabled;
|
||||
|
||||
&:not([disabled]):hover {
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
text-label-text-color: var(--app-heading-color),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A square icon action with an 8px radius, not Material's circle.
|
||||
@mixin ghost-icon-button($size: 32px, $icon-size: 16px) {
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
state-layer-size: $size,
|
||||
container-shape: $radius-sm,
|
||||
icon-size: $icon-size,
|
||||
icon-color: var(--app-body-color),
|
||||
disabled-icon-color: var(--app-body-color),
|
||||
state-layer-color: var(--app-on-surface),
|
||||
hover-state-layer-opacity: 0.08,
|
||||
focus-state-layer-opacity: 0.08,
|
||||
pressed-state-layer-opacity: 0.12,
|
||||
ripple-color: transparent,
|
||||
touch-target-display: none,
|
||||
)
|
||||
);
|
||||
border-radius: $radius-sm;
|
||||
@include _icon-size($icon-size);
|
||||
@include _disabled;
|
||||
|
||||
&:not([disabled]):hover {
|
||||
@include mat.icon-button-overrides(
|
||||
(
|
||||
icon-color: var(--app-heading-color),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The trailing 12px chevron of a dropdown trigger.
|
||||
@mixin trigger-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
margin-inline-start: 2px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
/// A `mat-menu` panel; apply to the class given to `<mat-menu class>`.
|
||||
/// Items: 36px, 13px, 16px icons in the body colour; destructive items
|
||||
/// carry `app-menu-item--danger` and sit after a divider; a keyboard hint
|
||||
/// is a trailing `kbd.app-menu-item__kbd`.
|
||||
@mixin menu-panel {
|
||||
@include mat.menu-overrides(
|
||||
(
|
||||
container-color: var(--app-widget-bg),
|
||||
container-shape: $radius,
|
||||
item-label-text-color: var(--app-heading-color),
|
||||
item-label-text-size: 13px,
|
||||
item-label-text-weight: 400,
|
||||
item-icon-color: var(--app-body-color),
|
||||
item-icon-size: 16px,
|
||||
item-hover-state-layer-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 8%,
|
||||
transparent
|
||||
),
|
||||
item-focus-state-layer-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
transparent
|
||||
),
|
||||
item-leading-spacing: 10px,
|
||||
item-trailing-spacing: 10px,
|
||||
item-with-icon-leading-spacing: 10px,
|
||||
item-with-icon-trailing-spacing: 10px,
|
||||
item-spacing: 10px,
|
||||
divider-color: var(--app-separator),
|
||||
divider-top-spacing: 4px,
|
||||
divider-bottom-spacing: 4px,
|
||||
)
|
||||
);
|
||||
min-width: 240px;
|
||||
border: 1px solid var(--app-line-strong);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.mat-mdc-menu-item {
|
||||
min-height: 36px;
|
||||
border-radius: 6px;
|
||||
|
||||
.mat-icon {
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// The label span fills the row, so a trailing keyboard hint can sit at
|
||||
// its end.
|
||||
.mat-mdc-menu-item-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mat-mdc-menu-item[aria-checked='true'] {
|
||||
background: var(--app-list-selected-bg);
|
||||
color: var(--app-list-selected-fg);
|
||||
|
||||
.mat-icon {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.app-menu-item--danger,
|
||||
.app-menu-item--danger .mat-icon {
|
||||
color: var(--app-danger-text);
|
||||
}
|
||||
|
||||
.app-menu-item__kbd {
|
||||
margin-inline-start: auto;
|
||||
padding-inline-start: 16px;
|
||||
font-family: var(--app-font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
}
|
||||
|
||||
/// `mat-button-toggle-group` as a segmented control: a recessed track, no
|
||||
/// dividers, the chosen segment filled.
|
||||
@mixin segmented-control($height: 30px) {
|
||||
@include mat.button-toggle-overrides(
|
||||
(
|
||||
height: $height,
|
||||
shape: 10px,
|
||||
divider-color: transparent,
|
||||
background-color: transparent,
|
||||
text-color: var(--app-muted-color),
|
||||
selected-state-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
transparent
|
||||
),
|
||||
selected-state-text-color: var(--app-heading-color),
|
||||
state-layer-color: var(--app-on-surface),
|
||||
hover-state-layer-opacity: 0.06,
|
||||
focus-state-layer-opacity: 0.06,
|
||||
label-text-size: 12.5px,
|
||||
label-text-weight: 500,
|
||||
label-text-tracking: 0,
|
||||
)
|
||||
);
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--app-line-strong);
|
||||
background: var(--app-content-bg);
|
||||
|
||||
.mat-button-toggle {
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// The group already draws its frame; Material adds a left border per
|
||||
// toggle as the divider.
|
||||
.mat-button-toggle + .mat-button-toggle {
|
||||
border-inline-start: 0;
|
||||
}
|
||||
|
||||
.mat-button-toggle-label-content {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.mat-pseudo-checkbox {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,5 @@
|
||||
@forward 'detail-view-actions';
|
||||
@forward 'nav-list';
|
||||
@forward 'focus-ring';
|
||||
@forward 'app-controls' as controls-*;
|
||||
@forward 'skeleton-shimmer' as skeleton-*;
|
||||
@@ -0,0 +1,102 @@
|
||||
// The UI kit's side list: a panel header (title, subtitle, ghost icon
|
||||
// actions), uppercase group labels and 40px single-choice rows. The chosen
|
||||
// row takes the app's one selected-row look (`--app-list-selected-*`: the
|
||||
// Settings navigation's soft tint, outlined in light theme), so every side
|
||||
// list and the playlist switcher's current row agree.
|
||||
@use 'focus-ring';
|
||||
|
||||
@mixin header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 4px 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--app-widget-header-border);
|
||||
}
|
||||
|
||||
@mixin title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
line-height: 1.3;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
@mixin subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
@mixin label {
|
||||
margin: 0;
|
||||
padding: 8px 12px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
/// A row button: leading 16px icon (or dot), label, trailing count. Mark
|
||||
/// the chosen row with `aria-pressed="true"`.
|
||||
@mixin row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--app-body-color);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
|
||||
.mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
&[aria-pressed='true'] {
|
||||
background: var(--app-list-selected-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--app-list-selected-outline);
|
||||
color: var(--app-list-selected-fg);
|
||||
font-weight: 500;
|
||||
|
||||
.mat-icon {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring.focus-ring-declarations($offset: -2px);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin row-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@mixin row-count {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
<div class="search-panel">
|
||||
<mat-form-field class="search-bar">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
[placeholder]="
|
||||
'WORKSPACE.GLOBAL_FAVORITES.SEARCH_PLACEHOLDER' | translate
|
||||
"
|
||||
[value]="searchTerm()"
|
||||
(input)="searchTerm.set($any($event.target).value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="nav-list app-scrollbar" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@if (enrichedChannels().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">star_border</mat-icon>
|
||||
@if (searchTerm()) {
|
||||
<p class="empty-title">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="empty-title">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
|
||||
}}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
|
||||
<app-channel-list-item
|
||||
[isDraggable]="!searchTerm()"
|
||||
[logo]="ch.logo"
|
||||
[name]="ch.name"
|
||||
[selected]="activeUid() === ch.uid"
|
||||
[showEpg]="true"
|
||||
[epgProgram]="ch.currentEpgProgram"
|
||||
[progressPercentage]="ch.progressPercentage"
|
||||
[showFavoriteButton]="true"
|
||||
[isFavorite]="true"
|
||||
[showProgramInfoButton]="true"
|
||||
(clicked)="onChannelClick(ch)"
|
||||
(favoriteToggled)="onFavoriteToggled(ch)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user