mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
Merge remote-tracking branch 'origin/master' into perf/zoneless-flip
# Conflicts: # tools/performance/journey-baselines.json
This commit is contained in:
commit
8e807242db
191 files changed
+15429
-6865
No files matched your search
-59
@@ -1,59 +0,0 @@
|
||||
<div class="search-panel">
|
||||
<mat-form-field class="search-bar">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
[placeholder]="
|
||||
'WORKSPACE.GLOBAL_FAVORITES.SEARCH_PLACEHOLDER' | translate
|
||||
"
|
||||
[value]="searchTerm()"
|
||||
(input)="searchTerm.set($any($event.target).value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="nav-list app-scrollbar" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@if (enrichedChannels().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">star_border</mat-icon>
|
||||
@if (searchTerm()) {
|
||||
<p class="empty-title">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="empty-title">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
|
||||
}}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
|
||||
<app-channel-list-item
|
||||
[isDraggable]="!searchTerm()"
|
||||
[logo]="ch.logo"
|
||||
[name]="ch.name"
|
||||
[selected]="activeUid() === ch.uid"
|
||||
[showEpg]="true"
|
||||
[epgProgram]="ch.currentEpgProgram"
|
||||
[progressPercentage]="ch.progressPercentage"
|
||||
[showFavoriteButton]="true"
|
||||
[isFavorite]="true"
|
||||
[showProgramInfoButton]="true"
|
||||
(clicked)="onChannelClick(ch)"
|
||||
(favoriteToggled)="onFavoriteToggled(ch)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
-79
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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) {
|
||||
|
||||
-8
@@ -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()"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
-8
@@ -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', () => {
|
||||
|
||||
-19
@@ -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();
|
||||
}
|
||||
|
||||
-1
@@ -111,7 +111,6 @@ export class WorkspaceShellHeaderService {
|
||||
this.routeState.activePlaylist()
|
||||
)
|
||||
);
|
||||
readonly isRefreshingPlaylist = this.playlistRefreshAction.isRefreshing;
|
||||
readonly playlistSubtitle = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
|
||||
+39
@@ -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;
|
||||
|
||||
+19
-2
@@ -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);
|
||||
|
||||
+79
-25
@@ -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>
|
||||
+53
-67
@@ -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;
|
||||
}
|
||||
+160
@@ -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);
|
||||
});
|
||||
});
|
||||
+98
-68
@@ -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();
|
||||
}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
+87
-42
@@ -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)"
|
||||
|
||||
+80
-56
@@ -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;
|
||||
}
|
||||
+98
-46
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+152
-194
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user