Merge remote-tracking branch 'origin/master' into perf/zoneless-flip

# Conflicts:
#	tools/performance/journey-baselines.json
This commit is contained in:
4gray committed 2026-10-14 19:58:21 +02:00
commit 8e807242db
191 files changed
+15429 -6865

No files matched your search

-7
View File
@@ -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,
},
};
}
),
];
+1 -3
View File
@@ -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
-5
View File
@@ -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]);
+4
View File
@@ -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;
};
}
}
@@ -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>
@@ -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;
}
}
@@ -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']);
});
});
@@ -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;
}
}
}
@@ -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>
@@ -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;
}
}
@@ -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);
});
});
@@ -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();
}
}
}
@@ -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">
@@ -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;
@@ -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;
@@ -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; }
}
}
@@ -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>
@@ -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;
}
}
@@ -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
);
}
);
});
@@ -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);
}
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;
});
}
}
@@ -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);
}
}
@@ -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);
}
}
+5
View File
@@ -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);
}
@@ -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>
@@ -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;
@@ -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',
@@ -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 =
+109
View File
@@ -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,
});
});
});
+38 -7
View File
@@ -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;
+1
View File
@@ -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';
}
+4
View File
@@ -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'
+302
View File
@@ -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;
}
}
+1
View File
@@ -12,4 +12,5 @@
@forward 'detail-view-actions';
@forward 'nav-list';
@forward 'focus-ring';
@forward 'app-controls' as controls-*;
@forward 'skeleton-shimmer' as skeleton-*;
+102
View File
@@ -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);
}
@@ -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