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

@@ -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>
@@ -1,79 +0,0 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
// ─── Search ────────────────────────────────────────────────────────────────────
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -10px;
flex-shrink: 0;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
:host ::ng-deep .search-bar div {
border-radius: 20px;
}
:host ::ng-deep .search-bar div[matformfieldlineripple],
:host ::ng-deep .search-bar .mat-mdc-form-field-subscript-wrapper {
display: none;
}
// ─── List ──────────────────────────────────────────────────────────────────────
.nav-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
// ─── Empty state ───────────────────────────────────────────────────────────────
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 1.05rem;
font-weight: 500;
letter-spacing: -0.01em;
margin: 0 0 8px;
}
.empty-hint {
font-size: 0.85rem;
opacity: 0.65;
margin: 0;
max-width: 260px;
}
}
@@ -12,7 +12,11 @@
}
.context-panel {
border-right: 1px solid var(--mat-sys-outline-variant);
// No divider line: the panel and the content surface differ in colour,
// and the content's shadow and rounded corner mark the edge. The border
// stays transparent so the panel keeps its width and the resize handle
// its place.
border-right: 1px solid transparent;
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
display: flex;
flex-direction: column;
@@ -28,8 +32,7 @@
overflow: hidden;
transition:
width 180ms ease,
padding 180ms ease,
border-right-color 180ms ease;
padding 180ms ease;
}
.context-panel--collapsed {
@@ -40,7 +43,6 @@
// visible empty strip beside the channels rail while "collapsed".
padding-left: 0 !important;
padding-right: 0 !important;
border-right-color: transparent;
.resize-handle {
display: none;
@@ -168,7 +170,6 @@
width: 100% !important;
min-width: 0 !important;
padding: 12px 10px 10px 12px;
border-right-color: var(--mat-sys-outline-variant);
}
.context-panel :is(.resize-handle) {
@@ -59,18 +59,10 @@
class="playlist-switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
[canRefreshActivePlaylist]="canRefreshPlaylist()"
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(accountInfoForPlaylistRequested)="
onAccountInfoForPlaylistRequested($event)
"
(addPlaylistRequested)="onAddPlaylistRequested()"
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
/>
}
@@ -31,15 +31,7 @@ import { LayoutSlideDirective } from '../../layout-slide.directive';
class MockPlaylistSwitcherComponent {
readonly currentTitle = input.required<string>();
readonly subtitle = input('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly canRefreshActivePlaylist = input(false);
readonly isRefreshingActivePlaylist = input(false);
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
}
describe('WorkspaceShellHeaderComponent', () => {
@@ -52,8 +52,6 @@ export class WorkspaceShellHeaderComponent {
readonly backTarget = input<WorkspaceBackTarget | null>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
readonly canOpenAccountInfo = input(false);
readonly searchQuery = input('');
readonly canUseSearch = input(false);
readonly searchPlaceholder = input('');
@@ -63,8 +61,6 @@ export class WorkspaceShellHeaderComponent {
readonly headerSidebarToggle = input<WorkspaceHeaderSidebarToggle | null>(
null
);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
@@ -144,10 +140,7 @@ export class WorkspaceShellHeaderComponent {
readonly headerShortcutRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly parentalLockToggleRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
@@ -199,14 +192,6 @@ export class WorkspaceShellHeaderComponent {
this.backRequested.emit();
}
onPlaylistInfoRequested(): void {
this.playlistInfoRequested.emit();
}
onAccountInfoRequested(): void {
this.accountInfoRequested.emit();
}
onAccountInfoForPlaylistRequested(playlist: PlaylistMeta): void {
this.accountInfoForPlaylistRequested.emit(playlist);
}
@@ -231,10 +216,6 @@ export class WorkspaceShellHeaderComponent {
this.headerSidebarToggleRequested.emit();
}
onRefreshPlaylistRequested(): void {
this.refreshPlaylistRequested.emit();
}
onDownloadsRequested(): void {
this.downloadsRequested.emit();
}
@@ -111,7 +111,6 @@ export class WorkspaceShellHeaderService {
this.routeState.activePlaylist()
)
);
readonly isRefreshingPlaylist = this.playlistRefreshAction.isRefreshing;
readonly playlistSubtitle = computed(() => {
this.languageTick();
@@ -1,3 +1,4 @@
import { PlaylistSwitcherOpenService } from '@iptvnator/playlist/shared/ui';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
@@ -329,6 +330,7 @@ describe('WorkspaceShellFacade', () => {
{
provide: MatDialog,
useValue: {
openDialogs: [],
open: jest.fn().mockReturnValue({
afterClosed: () => of(undefined),
}),
@@ -1127,6 +1129,43 @@ describe('WorkspaceShellFacade', () => {
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('opens the playlist switcher on Ctrl/Cmd+P when one is on screen', () => {
const switcher = TestBed.inject(PlaylistSwitcherOpenService);
const opened = jest.fn();
const subscription = switcher.openRequests.subscribe(opened);
const press = () => {
const event = new KeyboardEvent('keydown', {
key: 'p',
ctrlKey: true,
cancelable: true,
});
document.dispatchEvent(event);
return event;
};
// Settings hides the switcher: the key keeps its default there.
expect(press().defaultPrevented).toBe(false);
expect(opened).not.toHaveBeenCalled();
const unregister = switcher.register();
expect(press().defaultPrevented).toBe(true);
expect(opened).toHaveBeenCalledTimes(1);
// An open dialog keeps the key and the focus.
const dialogs = (
TestBed.inject(MatDialog) as unknown as {
openDialogs: unknown[];
}
).openDialogs;
dialogs.push({});
expect(press().defaultPrevented).toBe(false);
expect(opened).toHaveBeenCalledTimes(1);
dialogs.length = 0;
unregister();
subscription.unsubscribe();
});
it('records the executed command id after the palette closes with a selection', () => {
const dialog = TestBed.inject(MatDialog) as unknown as {
open: jest.Mock;
@@ -1,4 +1,5 @@
import { computed, DestroyRef, inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
@@ -26,6 +27,7 @@ import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.s
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { PlaylistSwitcherOpenService } from '@iptvnator/playlist/shared/ui';
@Injectable()
export class WorkspaceShellFacade {
@@ -56,6 +58,8 @@ export class WorkspaceShellFacade {
optional: true,
}
);
private readonly playlistSwitcher = inject(PlaylistSwitcherOpenService);
private readonly dialog = inject(MatDialog);
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) {
return;
@@ -65,9 +69,23 @@ export class WorkspaceShellFacade {
return;
}
if (event.key.toLowerCase() === 'k') {
const key = event.key.toLowerCase();
if (key === 'k') {
event.preventDefault();
this.openCommandPalette();
} else if (
key === 'p' &&
!event.shiftKey &&
!event.altKey &&
this.playlistSwitcher.available &&
// An open dialog keeps the focus: the switcher would take it out
// of the dialog, and give it back to the toolbar behind it.
this.dialog.openDialogs.length === 0
) {
// ⌘K stays the command palette; ⌘P is the playlist switcher (and
// no longer prints the page in the browser build).
event.preventDefault();
this.playlistSwitcher.request();
}
};
@@ -127,7 +145,6 @@ export class WorkspaceShellFacade {
readonly canOpenPlaylistInfo = this.header.canOpenPlaylistInfo;
readonly canOpenAccountInfo = this.header.canOpenAccountInfo;
readonly canRefreshPlaylist = this.header.canRefreshPlaylist;
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
readonly headerSidebarToggle = this.header.headerSidebarToggle;
readonly playlistSubtitle = this.header.playlistSubtitle;
readonly activeDownloadsCount = computed(() =>
@@ -33,8 +33,6 @@
[backTarget]="facade.backTarget()"
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
[canOpenAccountInfo]="facade.canOpenAccountInfo()"
[searchQuery]="facade.searchQuery()"
[canUseSearch]="facade.canUseSearch()"
[searchPlaceholder]="facade.searchPlaceholder()"
@@ -42,8 +40,6 @@
[searchStatusLabel]="facade.searchStatusLabel()"
[headerShortcut]="facade.headerShortcut()"
[headerSidebarToggle]="facade.headerSidebarToggle()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
[hasNoPlaylists]="facade.hasNoPlaylists()"
[isDownloadsView]="facade.isDownloadsView()"
@@ -56,7 +52,6 @@
[contextDrawerTooltipKey]="facade.contextDrawerLabelKeys().tooltip"
(backRequested)="facade.goBack()"
(contextDrawerToggleRequested)="contextDrawer.toggle()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
(searchSubmitted)="facade.onSearchEnter($event)"
(commandPaletteRequested)="facade.openCommandPalette()"
@@ -66,8 +61,6 @@
(headerSidebarToggleRequested)="facade.toggleLiveSidebar()"
(parentalLockToggleRequested)="facade.toggleParentalLock()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
(accountInfoRequested)="facade.openAccountInfo()"
(accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)"
/>
@@ -50,16 +50,12 @@ class MockWorkspaceShellHeaderComponent {
readonly backTarget = input<unknown>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
readonly canOpenAccountInfo = input(false);
readonly searchQuery = input('');
readonly canUseSearch = input(false);
readonly searchPlaceholder = input('');
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly headerShortcut = input<unknown>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
@@ -78,12 +74,9 @@ class MockWorkspaceShellHeaderComponent {
readonly shortcutsRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly parentalLockToggleRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
focusSearchInput = jest.fn();
@@ -169,7 +162,6 @@ class MockWorkspaceShellFacade {
readonly searchStatusLabel = signal('');
readonly headerShortcut = signal(null);
readonly canRefreshPlaylist = signal(false);
readonly isRefreshingPlaylist = signal(false);
readonly hasNoPlaylists = signal(false);
readonly isDownloadsView = signal(false);
readonly activeDownloadsCount = signal(3);
@@ -1,38 +1,92 @@
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>{{ 'WORKSPACE.SOURCES.TITLE' | translate }}</h2>
<div class="side-list">
<header class="side-list__header">
<div class="side-list__heading">
<h2 class="side-list__title">
{{ 'WORKSPACE.SOURCES.TITLE' | translate }}
</h2>
<p class="side-list__subtitle">
@if (total() === 1) {
{{ 'WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE' | translate }}
} @else {
{{
'WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER'
| translate: { count: total() }
}}
}
</p>
</div>
<p>{{ 'WORKSPACE.SOURCES.SUBTITLE' | translate }}</p>
<button
mat-icon-button
type="button"
class="side-list__action"
data-test-id="sources-add-playlist"
(click)="addPlaylist()"
[matTooltip]="'WORKSPACE.SOURCES.ADD_SOURCE' | translate"
[attr.aria-label]="'WORKSPACE.SOURCES.ADD_SOURCE' | translate"
>
<mat-icon>add</mat-icon>
</button>
</header>
<div class="context-divider"></div>
<section class="panel-section">
<div class="nav-list option-list">
<h3 class="panel-section-title">
<div class="side-list__groups">
<section
class="side-list__group"
role="group"
aria-labelledby="sources-type-label"
>
<h3 id="sources-type-label" class="side-list__label">
{{ 'WORKSPACE.SOURCES.TYPE' | translate }}
</h3>
@for (option of typeOptions; track option.id) {
<button
type="button"
class="nav-item category-item option-row"
[class.selected]="isTypeActive(option.id)"
class="side-list__row option-row"
[attr.data-filter]="option.id"
[attr.aria-pressed]="filter().type === option.id"
(click)="selectType(option.id)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span class="nav-item-label option-label">
{{
option.translationKey
? (option.translationKey | translate)
: option.label
}}
</span>
<div class="nav-item-meta option-meta">
{{ getTypeCount(option.id) }}
</div>
<mat-icon aria-hidden="true">{{ option.icon }}</mat-icon>
<span class="side-list__row-label option-label">{{
option.labelKey | translate
}}</span>
<span class="side-list__row-count">{{
typeCounts()[option.id]
}}</span>
</button>
}
</div>
</section>
</section>
@if (showStatus) {
<section
class="side-list__group"
role="group"
aria-labelledby="sources-status-label"
>
<h3 id="sources-status-label" class="side-list__label">
{{ 'WORKSPACE.SOURCES.STATUS' | translate }}
</h3>
@for (option of statusOptions; track option.id) {
<button
type="button"
class="side-list__row option-row"
[attr.data-filter]="'status-' + option.id"
[attr.aria-pressed]="filter().status === option.id"
(click)="selectStatus(option.id)"
>
<span
class="side-list__dot"
[attr.data-state]="option.id"
aria-hidden="true"
></span>
<span class="side-list__row-label option-label">{{
option.labelKey | translate
}}</span>
<span class="side-list__row-count">{{
statusCounts()[option.id]
}}</span>
</button>
}
</section>
}
</div>
</div>
@@ -1,4 +1,5 @@
@use '../../nav-list.scss';
@use '../../../../../../ui/styles/app-controls' as controls;
@use '../../../../../../ui/styles/side-list';
:host {
display: flex;
@@ -7,7 +8,7 @@
height: 100%;
}
.context-column {
.side-list {
display: flex;
flex-direction: column;
min-height: 0;
@@ -15,88 +16,73 @@
overflow: hidden;
}
// ─── Header ───────────────────────────────────────────────────────────────────
.context-header {
padding: 4px 6px 10px;
.side-list__header {
@include side-list.header;
flex-shrink: 0;
}
.context-header__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
.side-list__heading {
flex: 1;
min-width: 0;
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
.side-list__title {
@include side-list.title;
}
// ─── Divider ──────────────────────────────────────────────────────────────────
.context-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
.side-list__subtitle {
@include side-list.subtitle;
}
.panel-section {
.side-list__action {
@include controls.ghost-icon-button(32px, 18px);
flex: none;
}
.side-list__groups {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.option-list {
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
}
.option-row {
border-radius: 12px;
transition:
box-shadow 0.18s ease,
transform 0.18s ease;
.side-list__group {
display: flex;
flex-direction: column;
gap: 1px;
}
.option-label,
.option-meta,
mat-icon {
transition: color 0.18s ease, opacity 0.18s ease;
.side-list__label {
@include side-list.label;
}
.side-list__row {
@include side-list.row;
}
.side-list__row-label {
@include side-list.row-label;
}
.side-list__row-count {
@include side-list.row-count;
}
// The status rows lead with a dot in the colour the source rows use.
.side-list__dot {
width: 8px;
height: 8px;
margin: 0 4px;
border-radius: 50%;
background: var(--app-muted-color);
&[data-state='ok'] {
background: var(--app-success-color);
}
&.selected {
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.option-label,
.option-meta,
mat-icon {
color: var(--app-selection-color) !important;
}
.option-meta {
opacity: 0.95;
}
&[data-state='bad'] {
background: var(--app-live-color);
}
}
.panel-section-title {
margin: 0;
padding: 10px 10px 6px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mat-sys-on-surface-variant);
opacity: 0.85;
}
@@ -0,0 +1,160 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
import { SourcesViewStateService } from '@iptvnator/playlist/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceShellContextDrawerService,
} from '@iptvnator/workspace/shell/util';
import { WorkspaceSourcesFiltersPanelComponent } from './workspace-sources-filters-panel.component';
const PLAYLISTS = [
{ _id: 'm3u', title: 'a', url: 'https://a.example/list.m3u' },
{ _id: 'file', title: 'b' },
{ _id: 'xtream', title: 'c', serverUrl: 'http://x.example' },
{ _id: 'stalker', title: 'd', macAddress: 'm', portalUrl: 'http://p' },
] as PlaylistMeta[];
const BUCKETS: Record<string, string> = {
m3u: 'ok',
xtream: 'bad',
stalker: 'ok',
};
describe('WorkspaceSourcesFiltersPanelComponent', () => {
let fixture: ComponentFixture<WorkspaceSourcesFiltersPanelComponent>;
let supportsStatusFilter: boolean;
let openAddPlaylistDialog: jest.Mock;
const render = () => {
fixture = TestBed.createComponent(
WorkspaceSourcesFiltersPanelComponent
);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
};
const rows = (element: HTMLElement) =>
Array.from(
element.querySelectorAll<HTMLButtonElement>('.side-list__row')
);
const counts = (element: HTMLElement) =>
rows(element).map(
(row) =>
`${row.dataset['filter']}:${row.querySelector('.side-list__row-count')?.textContent?.trim()}`
);
beforeEach(() => {
localStorage.clear();
supportsStatusFilter = true;
openAddPlaylistDialog = jest.fn();
TestBed.configureTestingModule({
imports: [
WorkspaceSourcesFiltersPanelComponent,
TranslateModule.forRoot(),
],
providers: [
provideMockStore({
selectors: [
{ selector: selectAllPlaylistsMeta, value: PLAYLISTS },
],
}),
{
provide: SourceStatusService,
useValue: {
get supportsStatusFilter() {
return supportsStatusFilter;
},
statusOf: (p: PlaylistMeta) =>
BUCKETS[p._id]
? {
reachability:
BUCKETS[p._id] === 'ok'
? 'ok'
: 'bad',
}
: null,
},
},
{
provide: SourceRemovalService,
useValue: { pendingIds: signal(new Set<string>()) },
},
{
provide: WORKSPACE_SHELL_ACTIONS,
useValue: { openAddPlaylistDialog },
},
],
});
});
it('counts each group within the other group’s choice', () => {
const element = render();
expect(counts(element)).toEqual([
'all:4',
'm3u:2',
'xtream:1',
'stalker:1',
'status-all:4',
'status-ok:2',
'status-bad:1',
]);
rows(element)[6].click();
fixture.detectChanges();
expect(counts(element).slice(0, 4)).toEqual([
'all:1',
'm3u:0',
'xtream:1',
'stalker:0',
]);
expect(TestBed.inject(SourcesViewStateService).filter()).toEqual({
type: 'all',
status: 'bad',
});
});
it('marks the chosen row of each group as pressed', () => {
const element = render();
rows(element)[2].click();
fixture.detectChanges();
expect(
rows(element).map((row) => row.getAttribute('aria-pressed'))
).toEqual([
'false',
'false',
'true',
'false',
'true',
'false',
'false',
]);
});
it('has no status group where source health is unknown', () => {
supportsStatusFilter = false;
const element = render();
expect(rows(element)).toHaveLength(4);
});
it('adds a playlist from the header, closing the phone drawer first', () => {
const drawer = TestBed.inject(WorkspaceShellContextDrawerService);
drawer.open();
let drawerOpenAtDialog: boolean | null = null;
openAddPlaylistDialog.mockImplementation(() => {
drawerOpenAtDialog = drawer.isOpen();
});
const element = render();
element
.querySelector<HTMLButtonElement>(
'[data-test-id="sources-add-playlist"]'
)
?.click();
expect(openAddPlaylistDialog).toHaveBeenCalled();
// Two focus-trapped modals must not stack.
expect(drawerOpenAtDialog).toBe(false);
});
});
@@ -4,112 +4,142 @@ import {
computed,
inject,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import {
FilterActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
SourceStatusService,
sourceStatusBucket,
} from '@iptvnator/portal/shared/data-access';
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
import {
countSourcesByStatus,
countSourcesByType,
SourceBucketOf,
SourcesStatusFilter,
SourcesTypeFilter,
SourcesViewStateService,
} from '@iptvnator/playlist/shared/util';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceShellContextDrawerService,
} from '@iptvnator/workspace/shell/util';
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
interface PlaylistFilterOption {
id: PlaylistFilterId;
interface TypeOption {
id: SourcesTypeFilter;
icon: string;
label?: string;
translationKey?: string;
labelKey: string;
}
const ALL_FILTERS = ['m3u', 'xtream', 'stalker'];
interface StatusOption {
id: SourcesStatusFilter;
labelKey: string;
}
/**
* The Sources side list: Type and, on desktop where source health is known,
* Status. Both are single-choice and combine; each group counts within the
* other group's choice.
*/
@Component({
selector: 'app-workspace-sources-filters-panel',
imports: [MatIcon, MatListModule, TranslatePipe],
imports: [MatIcon, MatIconButton, MatTooltip, TranslatePipe],
templateUrl: './workspace-sources-filters-panel.component.html',
styleUrl: './workspace-sources-filters-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceSourcesFiltersPanelComponent {
private readonly store = inject(Store);
// Root-provided; optional keeps standalone unit tests light. Picking a type
// filter is a single-select choice that never navigates, so the phone
// drawer closes here to reveal the filtered list.
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
private readonly statusService = inject(SourceStatusService);
private readonly removal = inject(SourceRemovalService);
private readonly viewState = inject(SourcesViewStateService);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS, {
optional: true,
});
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly typeOptions: PlaylistFilterOption[] = [
// Root-provided; optional keeps standalone unit tests light. Picking a
// filter never navigates, so the phone drawer closes to reveal the list.
private readonly contextDrawer = inject(
WorkspaceShellContextDrawerService,
{
id: 'all',
icon: 'layers',
translationKey: 'WORKSPACE.SOURCES.ALL',
},
optional: true,
}
);
readonly typeOptions: readonly TypeOption[] = [
{ id: 'all', icon: 'layers', labelKey: 'WORKSPACE.SOURCES.ALL' },
{
id: 'm3u',
icon: SOURCE_TYPE_ICONS.m3u,
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
labelKey: 'HOME.PLAYLIST_TYPES.M3U',
},
{
id: 'xtream',
icon: SOURCE_TYPE_ICONS.xtream,
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
labelKey: 'HOME.PLAYLIST_TYPES.XTREAM',
},
{
id: 'stalker',
icon: SOURCE_TYPE_ICONS.stalker,
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
labelKey: 'HOME.PLAYLIST_TYPES.STALKER',
},
];
readonly statusOptions: readonly StatusOption[] = [
{ id: 'all', labelKey: 'WORKSPACE.SOURCES.STATUS_ANY' },
{ id: 'ok', labelKey: 'WORKSPACE.SOURCES.STATUS_OK' },
{ id: 'bad', labelKey: 'WORKSPACE.SOURCES.STATUS_BAD' },
];
readonly typeCounts = computed(() => {
const items = this.playlists();
return {
all: items.length,
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
.length,
xtream: items.filter((item) => !!item.serverUrl).length,
stalker: items.filter((item) => !!item.macAddress).length,
};
});
readonly showStatus = this.statusService.supportsStatusFilter;
readonly filter = this.viewState.filter;
isTypeActive(filterId: PlaylistFilterId): boolean {
const selected = this.activeTypeFilters();
if (filterId === 'all') {
return (
selected.length === ALL_FILTERS.length &&
ALL_FILTERS.every((id) => selected.includes(id))
);
}
return selected.length === 1 && selected[0] === filterId;
}
selectType(filterId: PlaylistFilterId): void {
const selectedFilters =
filterId === 'all' ? ALL_FILTERS : [filterId];
this.store.dispatch(
FilterActions.setSelectedFilters({
selectedFilters,
})
private readonly allPlaylists = this.store.selectSignal(
selectAllPlaylistsMeta
);
private readonly playlists = computed(() => {
const pending = this.removal.pendingIds();
return this.allPlaylists().filter(
(playlist) => !pending.has(playlist._id)
);
});
private readonly bucketOf: SourceBucketOf = (playlist) => {
const status = this.statusService.statusOf(playlist);
return status ? sourceStatusBucket(status.reachability) : null;
};
readonly total = computed(() => this.playlists().length);
readonly typeCounts = computed(() =>
countSourcesByType(
this.playlists(),
this.showStatus ? this.filter().status : 'all',
this.bucketOf
)
);
readonly statusCounts = computed(() =>
countSourcesByStatus(
this.playlists(),
this.filter().type,
this.bucketOf
)
);
selectType(type: SourcesTypeFilter): void {
this.viewState.setType(type);
this.contextDrawer?.close();
}
getTypeCount(filterId: PlaylistFilterId): number {
const counts = this.typeCounts();
if (filterId === 'all') {
return counts.all;
}
return counts[filterId];
selectStatus(status: SourcesStatusFilter): void {
this.viewState.setStatus(status);
this.contextDrawer?.close();
}
addPlaylist(): void {
// On a phone the drawer is a focus-trapped modal of its own: it must
// not stay open under the dialog.
this.contextDrawer?.close();
this.workspaceActions?.openAddPlaylistDialog();
}
}
@@ -0,0 +1,53 @@
import { SortBy, SortOptions, SortOrder } from '@iptvnator/services';
export interface SourceSortOption {
by: SortBy;
order: SortOrder;
translationKey: string;
}
/** The Sources sort menu, in menu order. Custom order allows dragging. */
export const SOURCE_SORT_OPTIONS: readonly SourceSortOption[] = [
{
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
translationKey: 'HOME.SORT_OPTIONS.NEWEST',
},
{
by: SortBy.DATE_ADDED,
order: SortOrder.ASC,
translationKey: 'HOME.SORT_OPTIONS.OLDEST',
},
{
by: SortBy.UPDATED,
order: SortOrder.DESC,
translationKey: 'HOME.SORT_OPTIONS.UPDATED',
},
{
by: SortBy.NAME,
order: SortOrder.ASC,
translationKey: 'HOME.SORT_OPTIONS.NAME_ASC',
},
{
by: SortBy.NAME,
order: SortOrder.DESC,
translationKey: 'HOME.SORT_OPTIONS.NAME_DESC',
},
{
by: SortBy.CHANNELS,
order: SortOrder.DESC,
translationKey: 'HOME.SORT_OPTIONS.CHANNELS',
},
{
by: SortBy.CUSTOM,
order: SortOrder.ASC,
translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER',
},
];
export function isSortOptionActive(
option: SourceSortOption,
current: SortOptions
): boolean {
return option.by === current.by && option.order === current.order;
}
@@ -1,55 +1,100 @@
<section
class="sources-page"
[class.sources-page--empty]="visibleSourcesCount() === 0"
>
@if (visibleSourcesCount() > 0 || canCleanSources()) {
<section class="sources-page" [class.sources-page--empty]="!hasPlaylists()">
@if (hasPlaylists()) {
<header class="sources-header">
<div class="sources-header__meta">
<div class="sources-header__title">
<h2 class="sources-title">{{ title() }}</h2>
<span class="sources-subtitle">{{ subtitle() }}</span>
<span class="sources-count" data-test-id="sources-count">{{
visibleCount()
}}</span>
</div>
@if (canCleanSources()) {
<button
mat-stroked-button
type="button"
class="cleanup-trigger"
[attr.aria-label]="'SOURCE_CLEANUP.TITLE' | translate"
(click)="openCleanup()"
>
<mat-icon>cleaning_services</mat-icon>
<span>{{ 'SOURCE_CLEANUP.TITLE' | translate }}</span>
</button>
}
<button
type="button"
mat-stroked-button
class="sort-trigger"
[matMenuTriggerFor]="sortMenu"
>
<mat-icon>sort</mat-icon>
<span class="sort-trigger__label">{{ activeSortLabel() }}</span>
<mat-icon class="sort-trigger__caret">arrow_drop_down</mat-icon>
</button>
<mat-menu #sortMenu="matMenu" xPosition="before">
@for (option of sortOptions; track option.translationKey) {
<div class="sources-header__actions">
@if (canCleanSources()) {
<button
mat-menu-item
mat-flat-button
type="button"
[appMenuItemRadio]="isSortActive(option)"
(click)="setSortOption(option)"
class="sources-action cleanup-trigger"
[attr.aria-label]="'SOURCE_CLEANUP.TITLE' | translate"
(click)="openCleanup()"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
<mat-icon>cleaning_services</mat-icon>
<span>{{ 'SOURCE_CLEANUP.TITLE' | translate }}</span>
@if (failingCount() > 0) {
<span class="sources-action__count">{{
failingCount()
}}</span>
}
</button>
}
</mat-menu>
<button
mat-flat-button
type="button"
class="sources-action sort-trigger"
[matMenuTriggerFor]="sortMenu"
[attr.aria-label]="
('WORKSPACE.REFINE_SORT_SECTION' | translate) +
': ' +
activeSortLabel()
"
>
<mat-icon>sort</mat-icon>
<span class="sort-trigger__label">{{
activeSortLabel()
}}</span>
<mat-icon class="sort-trigger__chevron" iconPositionEnd
>expand_more</mat-icon
>
</button>
<mat-menu
#sortMenu="matMenu"
class="app-menu"
xPosition="before"
>
<div
role="group"
[attr.aria-label]="
'WORKSPACE.REFINE_SORT_SECTION' | translate
"
>
@for (option of sortOptions; track $index) {
<button
mat-menu-item
type="button"
[appMenuItemRadio]="isSortActive(option)"
(click)="setSortOption(option)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<span>{{
option.translationKey | translate
}}</span>
</button>
}
</div>
@if (showFailingFirst) {
<mat-divider role="separator" />
<button
mat-menu-item
type="button"
role="menuitemcheckbox"
class="sort-failing-first"
[attr.aria-checked]="failingFirst()"
(click)="toggleFailingFirst()"
>
<mat-icon
[class.sort-failing-first__check--off]="
!failingFirst()
"
>check</mat-icon
>
<span>{{
'HOME.SORT_OPTIONS.FAILING_FIRST' | translate
}}</span>
</button>
}
</mat-menu>
</div>
</header>
}
<div
class="sources-content"
[class.app-scrollbar]="visibleSourcesCount() > 0"
>
<div class="sources-content app-scrollbar">
<app-recent-playlists
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist($event)"
@@ -1,4 +1,4 @@
@use '../../../../../../ui/styles/panel-header' as panel;
@use '../../../../../../ui/styles/app-controls' as controls;
:host {
display: block;
@@ -6,103 +6,127 @@
min-height: 100%;
}
// The header stays and the list scrolls, as on every list page: Sort and
// Clean up stay in reach in a long library, and a row reached by keyboard
// never scrolls under the header.
.sources-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
min-width: 0;
overflow: hidden;
}
.sources-header {
@include panel.standard-panel-header($sticky: true);
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 24px 32px 14px;
}
.sources-header__meta {
@include panel.standard-panel-meta();
.sources-header__title {
display: flex;
align-items: baseline;
gap: 10px;
flex: 1;
min-width: 0;
}
.sources-title {
@include panel.standard-panel-title();
margin: 0;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.4px;
line-height: 1.2;
color: var(--app-heading-color);
// A long translated title wraps rather than cuts off (content headings
// never ellipsize).
overflow-wrap: anywhere;
}
.sources-subtitle {
@include panel.standard-panel-subtitle();
.sources-count {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color);
}
.sort-trigger {
flex-shrink: 0;
display: inline-flex;
.sources-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
gap: 8px;
}
.sources-action {
@include controls.secondary-button(md);
app-region: no-drag;
}
.sort-trigger__label {
font-size: 0.85rem;
font-weight: 500;
}
.sources-action__count {
margin-inline-start: 8px;
padding: 1px 7px;
border-radius: 999px;
background: var(--app-danger-surface);
color: var(--app-danger-text);
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sort-trigger__caret {
font-size: 18px;
width: 18px;
height: 18px;
margin-left: -2px;
}
.sort-trigger__chevron {
@include controls.trigger-chevron;
}
.sources-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 20px;
padding: 0 20px 32px;
}
.sources-content app-recent-playlists {
display: block;
height: 100%;
.sources-page--empty .sources-content {
padding: 0;
}
.sources-page--empty {
.sources-content {
padding: 0;
overflow: hidden;
display: flex;
}
.sources-content app-recent-playlists {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
}
}
@media (max-width: 768px) {
@media (max-width: 1100px) {
.sources-header {
flex-direction: column;
align-items: flex-start;
padding: 10px 16px;
min-height: unset;
}
.sources-header__meta {
flex-direction: column;
align-items: flex-start;
gap: 4px;
.sources-action {
@include controls.secondary-button-height(36px);
}
}
@media (max-width: 640px) {
.sources-header {
padding: 16px 16px 10px;
}
.sources-title {
font-size: 22px;
}
.sources-content {
padding: 12px 16px 16px;
padding: 0 8px 24px;
}
}
.cleanup-trigger {
flex-shrink: 0;
app-region: no-drag;
// The unchecked failing-first item keeps its check slot, so its label lines
// up with the radio items above. Menu items come from this template, so the
// component's scoping reaches them inside the overlay.
.sort-failing-first__check--off {
visibility: hidden;
}
@media (max-width: 1100px) {
.cleanup-trigger span {
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;
}
@@ -12,17 +12,24 @@ import {
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
import { SourcesViewStateService } from '@iptvnator/playlist/shared/util';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
RuntimeCapabilitiesService,
SortBy,
SortOrder,
SortService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
@@ -41,10 +48,24 @@ class MockRecentPlaylistsComponent {
readonly addPlaylistClicked = output<void>();
}
const PLAYLISTS = [
{ _id: 'playlist-1', title: 'Playlist 1', url: 'https://a.example/1.m3u' },
{
_id: 'playlist-2',
title: 'Playlist 2',
serverUrl: 'http://x.example',
username: 'u',
password: 'p',
},
] as PlaylistMeta[];
describe('WorkspaceSourcesComponent', () => {
let fixture: ComponentFixture<WorkspaceSourcesComponent>;
let setSortOptions: jest.Mock;
beforeEach(async () => {
localStorage.clear();
setSortOptions = jest.fn();
await TestBed.configureTestingModule({
imports: [WorkspaceSourcesComponent, NoopAnimationsModule],
providers: [
@@ -62,21 +83,29 @@ describe('WorkspaceSourcesComponent', () => {
},
provideMockStore({
selectors: [
{
selector: selectActiveTypeFilters,
value: ['m3u', 'xtream', 'stalker'],
},
{
selector: selectAllPlaylistsMeta,
value: [
{
_id: 'playlist-1',
title: 'Playlist 1',
},
],
},
{ selector: selectAllPlaylistsMeta, value: PLAYLISTS },
],
}),
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSourceHealth: true },
},
{
provide: SourceStatusService,
useValue: {
supportsStatusFilter: true,
statusOf: (p: PlaylistMeta) => ({
reachability: p._id === 'playlist-2' ? 'bad' : 'ok',
}),
},
},
{
provide: SourceRemovalService,
useValue: {
pendingIds: signal(new Set<string>()),
isPending: () => false,
},
},
{
provide: ActivatedRoute,
useValue: {
@@ -91,7 +120,8 @@ describe('WorkspaceSourcesComponent', () => {
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
}),
setSortOptions: jest.fn(),
setSortOptions: (options: unknown) =>
setSortOptions(options),
},
},
{
@@ -140,6 +170,7 @@ describe('WorkspaceSourcesComponent', () => {
set: {
imports: [
MatButtonModule,
MatDividerModule,
MatIconModule,
MatMenuModule,
MenuItemRadioCheckDirective,
@@ -199,35 +230,47 @@ describe('WorkspaceSourcesComponent', () => {
expect(context.protected('other')).toBe(false);
});
it('renders the shared panel header structure without paragraph subtitle margins', async () => {
it('puts a 28px title and the visible count on the page', async () => {
fixture.detectChanges();
await fixture.whenStable();
const header: HTMLElement =
fixture.nativeElement.querySelector('.sources-header');
const meta: HTMLElement = fixture.nativeElement.querySelector(
'.sources-header__meta'
);
const title: HTMLElement =
fixture.nativeElement.querySelector('.sources-title');
const subtitle: HTMLElement =
fixture.nativeElement.querySelector('.sources-subtitle');
expect(header).not.toBeNull();
expect(meta).not.toBeNull();
expect(title.textContent?.trim()).toBe('All Playlists');
expect(subtitle.textContent?.trim()).toBe('1 playlist');
expect(subtitle.tagName).toBe('SPAN');
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.sources-title')?.textContent?.trim()
).toBe('All Playlists');
expect(
element.querySelector('.sources-count')?.textContent?.trim()
).toBe('2');
// The header stays; the list below it scrolls.
const scroller = element.querySelector('.sources-content');
expect(scroller?.classList).toContain('app-scrollbar');
expect(
scroller?.contains(element.querySelector('.sources-header'))
).toBe(false);
});
it('uses the shared always-visible scrollbar utility for the playlist list', async () => {
it('titles the page by the status filter and counts what it shows', async () => {
TestBed.inject(SourcesViewStateService).setStatus('bad');
fixture.detectChanges();
await fixture.whenStable();
const content: HTMLElement =
fixture.nativeElement.querySelector('.sources-content');
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.sources-title')?.textContent?.trim()
).toBe('WORKSPACE.SOURCES.STATUS_BAD');
expect(
element.querySelector('.sources-count')?.textContent?.trim()
).toBe('1');
});
expect(content.classList.contains('app-scrollbar')).toBe(true);
it('counts sources that do not respond on Clean up', async () => {
fixture.detectChanges();
await fixture.whenStable();
const pill = (fixture.nativeElement as HTMLElement).querySelector(
'.cleanup-trigger .sources-action__count'
);
expect(pill?.textContent?.trim()).toBe('1');
});
it('offers the source sorts as radio rows with the active one checked', async () => {
@@ -246,20 +289,29 @@ describe('WorkspaceSourcesComponent', () => {
'.cdk-overlay-container [mat-menu-item]'
)
);
expect(rows.map((row) => row.getAttribute('role'))).toEqual(
Array(5).fill('menuitemradio')
);
expect(rows.map((row) => row.getAttribute('role'))).toEqual([
...Array(7).fill('menuitemradio'),
'menuitemcheckbox',
]);
// The radio group is named by its section, as every menu group is.
expect(
rows[0].closest('[role="group"]')?.getAttribute('aria-label')
).toBe('WORKSPACE.REFINE_SORT_SECTION');
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual([
'true',
'false',
'false',
'false',
'false',
...Array(7).fill('false'),
]);
for (const row of rows) {
for (const row of rows.slice(0, 7)) {
expect(row.firstElementChild?.classList).toContain(
'app-menu-item-radio-check'
);
}
rows[7].click();
expect(setSortOptions).toHaveBeenCalledWith({
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
failingFirst: true,
});
});
});
@@ -1,65 +1,83 @@
import { WorkspaceShellXtreamImportService } from '../workspace-shell/services/workspace-shell-xtream-import.service';
import { Injector, viewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
SourceCleanupDialogComponent,
ChangeDetectionStrategy,
Component,
computed,
inject,
Injector,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
SourceStatusService,
sourceStatusBucket,
} from '@iptvnator/portal/shared/data-access';
import {
isLiveExternalPlayerSession,
PORTAL_EXTERNAL_PLAYBACK,
} from '@iptvnator/portal/shared/util';
import {
PlaylistRefreshActionService,
RecentPlaylistsComponent,
SourceCleanupDialogComponent,
SourceRemovalService,
} from '@iptvnator/playlist/shared/ui';
import {
RuntimeCapabilitiesService,
SourceActivityService,
filterSources,
SourceBucketOf,
SourcesViewStateService,
} from '@iptvnator/playlist/shared/util';
import {
injectTranslationTick,
RuntimeCapabilitiesService,
SortService,
SourceActivityService,
} from '@iptvnator/services';
import {
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
} from '@iptvnator/portal/shared/util';
import { PlaylistActions } from '@iptvnator/m3u-state';
PlaylistUpdateState,
sourceHealthType,
} from '@iptvnator/shared/interfaces';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '@iptvnator/ui/components';
import {
foldSearchText,
sourceHealthType,
PlaylistUpdateState,
} from '@iptvnator/shared/interfaces';
import {
Component,
computed,
inject,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { RecentPlaylistsComponent } from '@iptvnator/playlist/shared/ui';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { map } from 'rxjs';
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspacePlaylistType,
} from '@iptvnator/workspace/shell/util';
import {
isSortOptionActive,
SOURCE_SORT_OPTIONS,
SourceSortOption,
} from './workspace-sources-sort-options';
interface SortOption {
by: SortBy;
order: SortOrder;
icon: string;
translationKey: string;
}
const TYPE_TITLE_KEYS = {
all: 'WORKSPACE.SOURCES.ALL_PLAYLISTS',
m3u: 'WORKSPACE.SOURCES.M3U_PLAYLISTS',
xtream: 'WORKSPACE.SOURCES.XTREAM_PLAYLISTS',
stalker: 'WORKSPACE.SOURCES.STALKER_PLAYLISTS',
} as const;
/**
* The Sources page: a 28px title with the visible count, Clean up (desktop,
* with a count of sources that do not respond) and the sort menu, above the
* list. The side list's filters live in `SourcesViewStateService`.
*/
@Component({
selector: 'app-workspace-sources',
imports: [
MatButtonModule,
MatDividerModule,
MatIconModule,
MatMenuModule,
MenuItemRadioCheckDirective,
@@ -68,12 +86,20 @@ interface SortOption {
TranslatePipe,
],
templateUrl: './workspace-sources.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './workspace-sources.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceSourcesComponent {
private readonly injector = inject(Injector);
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly sortService = inject(SortService);
private readonly statusService = inject(SourceStatusService);
private readonly removal = inject(SourceRemovalService);
private readonly viewState = inject(SourcesViewStateService);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly playback = inject(PORTAL_EXTERNAL_PLAYBACK, {
optional: true,
});
@@ -81,11 +107,95 @@ export class WorkspaceSourcesComponent {
optional: true,
});
private readonly sourceList = viewChild(RecentPlaylistsComponent);
private readonly languageTick = injectTranslationTick();
readonly sortOptions = SOURCE_SORT_OPTIONS;
readonly showFailingFirst = this.statusService.supportsStatusFilter;
private readonly currentSort = toSignal(this.sortService.getSortOptions(), {
requireSync: true,
});
readonly searchQuery = toSignal(
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
{ initialValue: '' }
);
private readonly allPlaylists = this.store.selectSignal(
selectAllPlaylistsMeta
);
private readonly playlists = computed(() => {
const pending = this.removal.pendingIds();
return this.allPlaylists().filter((p) => !pending.has(p._id));
});
private readonly bucketOf: SourceBucketOf = (playlist) => {
const status = this.statusService.statusOf(playlist);
return status ? sourceStatusBucket(status.reachability) : null;
};
readonly hasPlaylists = computed(() => this.playlists().length > 0);
readonly visibleCount = computed(() => {
const filter = this.viewState.filter();
return filterSources(
this.playlists(),
this.showFailingFirst ? filter : { ...filter, status: 'all' },
this.searchQuery(),
this.bucketOf
).length;
});
readonly title = computed(() => {
this.languageTick();
const filter = this.viewState.filter();
const key =
this.showFailingFirst && filter.status === 'bad'
? 'WORKSPACE.SOURCES.STATUS_BAD'
: TYPE_TITLE_KEYS[filter.type];
return this.translate.instant(key);
});
readonly canCleanSources = computed(
() =>
this.runtime.supportsSourceHealth &&
this.playlists().some((p) => sourceHealthType(p))
);
/** Sources last seen not responding, across every filter. */
readonly failingCount = computed(
() => this.playlists().filter((p) => this.bucketOf(p) === 'bad').length
);
readonly activeSortLabel = computed(() => {
this.languageTick();
const current = this.currentSort();
const match = this.sortOptions.find((option) =>
isSortOptionActive(option, current)
);
return this.translate.instant(
match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST'
);
});
readonly failingFirst = computed(() => !!this.currentSort().failingFirst);
isSortActive(option: SourceSortOption): boolean {
return isSortOptionActive(option, this.currentSort());
}
setSortOption(option: SourceSortOption): void {
this.sortService.setSortOptions({
...this.currentSort(),
by: option.by,
order: option.order,
});
}
toggleFailingFirst(): void {
this.sortService.setSortOptions({
...this.currentSort(),
failingFirst: !this.failingFirst(),
});
}
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.workspaceActions.openAddPlaylistDialog(type);
}
openCleanup(): void {
const dialogs = this.injector.get(MatDialog);
const refresh = this.injector.get(PlaylistRefreshActionService);
@@ -108,7 +218,7 @@ export class WorkspaceSourcesComponent {
activity.isBusy(id) ||
refresh.isSourceBusy(id) ||
!!this.imports?.isSourceBusy(id) ||
!!this.sourceList()?.isDeletePending(id) ||
this.removal.isPending(id) ||
!!this.sourceList()?.isRefreshPending(id) ||
this.playlists().find((p) => p._id === id)?.updateState ===
PlaylistUpdateState.IN_PROGRESS,
@@ -121,156 +231,4 @@ export class WorkspaceSourcesComponent {
},
});
}
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly translate = inject(TranslateService);
private readonly sortService = inject(SortService);
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
private readonly languageTick = injectTranslationTick();
readonly sortOptions: SortOption[] = [
{
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
icon: 'schedule',
translationKey: 'HOME.SORT_OPTIONS.NEWEST',
},
{
by: SortBy.DATE_ADDED,
order: SortOrder.ASC,
icon: 'history',
translationKey: 'HOME.SORT_OPTIONS.OLDEST',
},
{
by: SortBy.NAME,
order: SortOrder.ASC,
icon: 'sort_by_alpha',
translationKey: 'HOME.SORT_OPTIONS.NAME_ASC',
},
{
by: SortBy.NAME,
order: SortOrder.DESC,
icon: 'sort_by_alpha',
translationKey: 'HOME.SORT_OPTIONS.NAME_DESC',
},
{
by: SortBy.CUSTOM,
order: SortOrder.ASC,
icon: 'drag_indicator',
translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER',
},
];
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{ requireSync: true }
);
readonly searchQuery = toSignal(
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
{ initialValue: '' }
);
readonly title = computed(() => {
this.languageTick();
const filters = this.activeTypeFilters();
if (filters.length === 1) {
if (filters[0] === 'm3u') {
return this.translateText('WORKSPACE.SOURCES.M3U_PLAYLISTS');
}
if (filters[0] === 'xtream') {
return this.translateText('WORKSPACE.SOURCES.XTREAM_PLAYLISTS');
}
if (filters[0] === 'stalker') {
return this.translateText(
'WORKSPACE.SOURCES.STALKER_PLAYLISTS'
);
}
}
return this.translateText('WORKSPACE.SOURCES.ALL_PLAYLISTS');
});
readonly visibleSourcesCount = computed(() => {
const query = foldSearchText(this.searchQuery().trim());
const filters = this.activeTypeFilters();
const allPlaylists = this.playlists();
return allPlaylists
.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 || isXtreamFilter || isM3uFilter;
})
.filter((item) => foldSearchText(item.title || '').includes(query))
.length;
});
readonly subtitle = computed(() => {
this.languageTick();
const count = this.visibleSourcesCount();
if (count === 1) {
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE');
}
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER', {
count,
});
});
readonly activeSortLabel = computed(() => {
this.languageTick();
const current = this.currentSortOptions();
const match = this.sortOptions.find(
(option) =>
option.by === current.by && option.order === current.order
);
return this.translateText(
match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST'
);
});
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.workspaceActions.openAddPlaylistDialog(type);
}
isSortActive(option: SortOption): boolean {
const current = this.currentSortOptions();
return current.by === option.by && current.order === option.order;
}
setSortOption(option: SortOption): void {
this.sortService.setSortOptions({
by: option.by,
order: option.order,
});
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}