refactor: rework dashboard view

- Removed global favorites related properties and methods from WorkspaceShellHeaderComponent.
- Updated WorkspaceShellFacade to streamline command palette commands and navigation.
- Refactored command palette to include type-aware xtream import progress labels.
- Enhanced WorkspaceShellComponent to display import progress with improved styling.
- Updated tests to reflect changes in command palette and import progress handling.
This commit is contained in:
4gray committed 2026-04-22 22:19:42 +02:00
1 parent fc3db71e85
commit 0e77f7b98a
69 files changed
+2603 -4939

No files matched your search

@@ -22,10 +22,10 @@
<div class="palette-divider"></div>
<div class="palette-results">
@for (group of commandGroups(); track group.scope) {
@for (group of commandGroups(); track group.group) {
<section class="palette-group">
<h3 class="palette-group__title">
{{ getScopeTitleKey(group.scope) | translate }}
{{ getGroupTitleKey(group.group) | translate }}
</h3>
@for (command of group.items; track command.id) {
<button
@@ -37,16 +37,16 @@
(click)="onCommandClick(command)"
>
<span class="palette-command__icon">
<mat-icon>{{
getCommandIcon(command)
}}</mat-icon>
<mat-icon>{{ command.icon }}</mat-icon>
</span>
<span class="palette-command__label">{{
command.label
}}</span>
<span class="palette-command__desc">{{
command.description
}}</span>
@if (command.description) {
<span class="palette-command__desc">{{
command.description
}}</span>
}
</button>
}
</section>
@@ -2,17 +2,69 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
import { WorkspaceCommandPaletteComponent } from './workspace-command-palette.component';
describe('WorkspaceCommandPaletteComponent', () => {
let fixture: ComponentFixture<WorkspaceCommandPaletteComponent>;
let component: WorkspaceCommandPaletteComponent;
let dialogRef: { close: jest.Mock };
let commands: WorkspaceResolvedCommandItem[];
beforeEach(async () => {
dialogRef = {
close: jest.fn(),
};
commands = [
{
id: 'global-search',
label: 'Search all Xtream playlists',
description: 'Open global search overlay',
group: 'global',
icon: 'search',
keywords: ['xtream', 'global'],
priority: 100,
visible: true,
enabled: false,
run: () => undefined,
},
{
id: 'playlist-search',
label: 'Search this playlist',
description: 'Open playlist search route',
group: 'playlist',
icon: 'playlist_play',
keywords: ['playlist'],
priority: 10,
visible: true,
enabled: true,
run: () => undefined,
},
{
id: 'multi-epg',
label: 'Open Multi-EPG',
description: '',
group: 'view',
icon: 'view_list',
keywords: ['epg', 'guide'],
priority: 0,
visible: true,
enabled: true,
run: () => undefined,
},
{
id: 'hidden-command',
label: 'Hidden command',
description: 'Should not appear',
group: 'global',
icon: 'visibility_off',
keywords: [],
priority: 200,
visible: false,
enabled: true,
run: () => undefined,
},
];
await TestBed.configureTestingModule({
imports: [WorkspaceCommandPaletteComponent],
@@ -25,22 +77,7 @@ describe('WorkspaceCommandPaletteComponent', () => {
provide: MAT_DIALOG_DATA,
useValue: {
query: 'search',
commands: [
{
id: 'global-search',
label: 'Search all Xtream playlists',
description: 'Open global search overlay',
scope: 'global',
enabled: true,
},
{
id: 'playlist-search',
label: 'Search this playlist',
description: 'Open playlist search route',
scope: 'playlist',
enabled: true,
},
],
commands,
},
},
{
@@ -69,13 +106,60 @@ describe('WorkspaceCommandPaletteComponent', () => {
expect(component.query()).toBe('search');
});
it('selects the first enabled command by default', () => {
expect(component.selectedIndex()).toBe(0);
expect(component.flatCommands()[0].id).toBe('playlist-search');
});
it('hides non-visible commands and omits empty groups', () => {
expect(component.flatCommands().map((command) => command.id)).toEqual([
'playlist-search',
'global-search',
]);
expect(component.commandGroups().map((group) => group.group)).toEqual([
'playlist',
'global',
]);
});
it('filters commands by keywords', () => {
component.query.set('guide');
expect(component.flatCommands().map((command) => command.id)).toEqual([
'multi-epg',
]);
});
it('skips disabled commands during keyboard navigation', () => {
component.query.set('');
fixture.detectChanges();
expect(component.flatCommands().map((command) => command.id)).toEqual([
'multi-epg',
'playlist-search',
'global-search',
]);
expect(component.selectedIndex()).toBe(0);
component.onInputKeydown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
);
expect(component.selectedIndex()).toBe(1);
component.onInputKeydown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
);
expect(component.selectedIndex()).toBe(0);
});
it('closes with selected command and query on click', () => {
component.query.set('');
const command = component.flatCommands()[0];
component.onCommandClick(command);
expect(dialogRef.close).toHaveBeenCalledWith({
commandId: 'global-search',
query: 'search',
commandId: 'multi-epg',
query: '',
});
});
});
@@ -15,47 +15,20 @@ import {
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
export type WorkspaceCommandScope = 'global' | 'playlist' | 'section';
export type WorkspaceCommandId =
| 'global-search'
| 'playlist-search'
| 'open-global-favorites'
| 'open-downloads'
| 'open-global-recent'
| 'open-settings'
| 'open-sources'
| 'open-dashboard'
| 'add-playlist'
| 'refresh-playlist'
| 'playlist-info'
| 'account-info'
| 'go-to-vod'
| 'go-to-live'
| 'go-to-series';
export interface WorkspaceCommandItem {
id: WorkspaceCommandId;
label: string;
description: string;
scope: WorkspaceCommandScope;
enabled: boolean;
}
export interface WorkspaceCommandSelection {
commandId: WorkspaceCommandId;
query: string;
}
import {
WorkspaceCommandGroup,
WorkspaceCommandSelection,
WorkspaceResolvedCommandItem,
} from '@iptvnator/portal/shared/util';
interface WorkspaceCommandPaletteData {
commands: WorkspaceCommandItem[];
commands: WorkspaceResolvedCommandItem[];
query?: string;
}
interface WorkspaceCommandGroup {
scope: WorkspaceCommandScope;
items: WorkspaceCommandItem[];
interface WorkspaceCommandGroupSection {
group: WorkspaceCommandGroup;
items: WorkspaceResolvedCommandItem[];
}
@Component({
@@ -80,50 +53,72 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
readonly query = signal(this.data?.query ?? '');
readonly selectedIndex = signal(0);
readonly visibleCommands = computed(() =>
this.data.commands.filter((command) => command.visible)
);
readonly filteredCommands = computed(() => {
const term = this.query().trim().toLowerCase();
const commands = this.visibleCommands();
if (!term) {
return this.data.commands;
return commands;
}
return this.data.commands.filter((command) => {
const haystack =
`${command.label} ${command.description}`.toLowerCase();
return commands.filter((command) => {
const haystack = [
command.label,
command.description,
...(command.keywords ?? []),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
});
readonly commandGroups = computed<WorkspaceCommandGroup[]>(() => {
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
const commands = this.filteredCommands();
const grouped: WorkspaceCommandGroup[] = [];
const sections: WorkspaceCommandGroupSection[] = [];
const buildSection = (
group: WorkspaceCommandGroup
): WorkspaceCommandGroupSection | null => {
const items = commands.filter((command) => command.group === group);
const buildGroup = (
scope: WorkspaceCommandScope
): WorkspaceCommandGroup | null => {
const items = commands.filter((command) => command.scope === scope);
if (items.length === 0) {
return null;
}
return { scope, items };
return { group, items };
};
const groups = [
buildGroup('global'),
buildGroup('playlist'),
buildGroup('section'),
].filter((group): group is WorkspaceCommandGroup => group !== null);
buildSection('view'),
buildSection('playlist'),
buildSection('global'),
].filter(
(group): group is WorkspaceCommandGroupSection => group !== null
);
grouped.push(...groups);
return grouped;
sections.push(...groups);
return sections;
});
readonly flatCommands = computed(() =>
this.commandGroups().reduce<WorkspaceCommandItem[]>(
this.commandGroups().reduce<WorkspaceResolvedCommandItem[]>(
(items, group) => items.concat(group.items),
[]
)
);
readonly selectableIndices = computed(() =>
this.flatCommands()
.map((command, index) => (command.enabled ? index : -1))
.filter((index) => index >= 0)
);
constructor() {
effect(() => {
const items = this.flatCommands();
@@ -132,9 +127,15 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
return;
}
const selectableIndices = this.selectableIndices();
if (selectableIndices.length === 0) {
this.selectedIndex.set(-1);
return;
}
const currentIndex = this.selectedIndex();
if (currentIndex < 0 || currentIndex >= items.length) {
this.selectedIndex.set(0);
if (!selectableIndices.includes(currentIndex)) {
this.selectedIndex.set(selectableIndices[0]);
}
});
}
@@ -176,7 +177,11 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
}
}
onCommandHover(command: WorkspaceCommandItem): void {
onCommandHover(command: WorkspaceResolvedCommandItem): void {
if (!command.enabled) {
return;
}
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
@@ -185,7 +190,7 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
}
}
onCommandClick(command: WorkspaceCommandItem): void {
onCommandClick(command: WorkspaceResolvedCommandItem): void {
if (!command.enabled) {
return;
}
@@ -196,23 +201,39 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
});
}
isCommandSelected(command: WorkspaceCommandItem): boolean {
isCommandSelected(command: WorkspaceResolvedCommandItem): boolean {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
return index >= 0 && this.selectedIndex() === index;
}
getGroupTitleKey(group: WorkspaceCommandGroup): string {
if (group === 'view') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_VIEW';
}
if (group === 'playlist') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
}
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
}
private moveSelection(direction: 1 | -1): void {
const items = this.flatCommands();
if (items.length === 0) {
const selectableIndices = this.selectableIndices();
if (selectableIndices.length === 0) {
return;
}
const currentIndex = this.selectedIndex();
const safeIndex = currentIndex < 0 ? 0 : currentIndex;
const nextIndex = (safeIndex + direction + items.length) % items.length;
this.selectedIndex.set(nextIndex);
const currentPosition = selectableIndices.indexOf(this.selectedIndex());
const nextPosition =
currentPosition === -1
? direction === 1
? 0
: selectableIndices.length - 1
: (currentPosition + direction + selectableIndices.length) %
selectableIndices.length;
this.selectedIndex.set(selectableIndices[nextPosition]);
}
private selectCurrent(): void {
@@ -228,51 +249,4 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
this.onCommandClick(items[index]);
}
getScopeTitleKey(scope: WorkspaceCommandScope): string {
if (scope === 'global') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
}
if (scope === 'playlist') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
}
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SECTION';
}
getCommandIcon(command: WorkspaceCommandItem): string {
switch (command.id) {
case 'global-search':
return 'search';
case 'playlist-search':
return 'playlist_play';
case 'open-global-favorites':
return 'star';
case 'open-downloads':
return 'download';
case 'open-global-recent':
return 'history';
case 'open-settings':
return 'settings';
case 'open-sources':
return 'library_books';
case 'open-dashboard':
return 'dashboard';
case 'add-playlist':
return 'add_circle_outline';
case 'refresh-playlist':
return 'sync';
case 'playlist-info':
return 'info';
case 'account-info':
return 'account_circle';
case 'go-to-vod':
return 'movie';
case 'go-to-live':
return 'live_tv';
case 'go-to-series':
return 'video_library';
default:
return 'bolt';
}
}
}
@@ -75,22 +75,9 @@
<mat-icon>sync</mat-icon>
</button>
}
<span class="header-actions-divider" aria-hidden="true"></span>
<button
type="button"
class="header-shortcut"
mat-icon-button
[class.is-active]="isGlobalFavoritesActive()"
(click)="onGlobalFavoritesRequested()"
[matTooltip]="
'WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES' | translate
"
>
<mat-icon>star</mat-icon>
</button>
@if (headerShortcut() || isElectron()) {
<span class="header-actions-divider" aria-hidden="true"></span>
}
@if (headerShortcut(); as action) {
<button
type="button"
@@ -23,8 +23,10 @@ class MockPlaylistSwitcherComponent {
readonly subtitle = input('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
}
describe('WorkspaceShellHeaderComponent', () => {
@@ -88,19 +90,40 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(emitted).toEqual(['matrix']);
});
it('emits header bulk action requests when the action button is clicked', () => {
it('emits add playlist requests when the toolbar add button is clicked', () => {
const requested = jest.fn();
component.headerBulkActionRequested.subscribe(requested);
fixture.componentRef.setInput('headerBulkAction', {
icon: 'delete_sweep',
tooltip: 'clear',
ariaLabel: 'clear recent',
disabled: false,
component.addPlaylistRequested.subscribe(requested);
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'button[aria-label="WORKSPACE.SHELL.ADD_PLAYLIST"]'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
});
it('does not render the removed global favorites shortcut', () => {
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'button[aria-label="WORKSPACE.SHELL.OPEN_GLOBAL_FAVORITES"]'
);
expect(button).toBeNull();
});
it('emits contextual header shortcut requests when configured', () => {
const requested = jest.fn();
component.headerShortcutRequested.subscribe(requested);
fixture.componentRef.setInput('headerShortcut', {
icon: 'tune',
tooltipKey: 'shortcut.tooltip',
ariaLabelKey: 'shortcut.aria',
run: () => undefined,
});
fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'button[aria-label="clear recent"]'
'button[aria-label="shortcut.aria"]'
);
button.click();
@@ -39,7 +39,6 @@ export class WorkspaceShellHeaderComponent {
readonly searchPlaceholder = input('');
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly isGlobalFavoritesActive = input(false);
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
@@ -51,7 +50,6 @@ export class WorkspaceShellHeaderComponent {
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly globalFavoritesRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
@@ -84,10 +82,6 @@ export class WorkspaceShellHeaderComponent {
this.commandPaletteRequested.emit();
}
onGlobalFavoritesRequested(): void {
this.globalFavoritesRequested.emit();
}
onHeaderShortcutRequested(): void {
this.headerShortcutRequested.emit();
}
@@ -6,7 +6,11 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
@@ -25,8 +29,15 @@ class MockXtreamStore {
readonly searchTerm = signal('');
readonly categorySearchTerm = signal('');
readonly isImporting = signal(false);
readonly isCancellingImport = signal(false);
readonly activeImportSessionId = signal<string | null>(null);
readonly currentImportPhase = signal<string | null>(null);
readonly activeImportContentType = signal<'live' | 'vod' | 'series' | null>(
null
);
readonly activeImportCurrentCount = signal(0);
readonly activeImportTotalCount = signal(0);
readonly getImportCount = signal(0);
readonly itemsToImport = signal(0);
readonly getSelectedCategory = signal<{
category_name?: string;
@@ -99,7 +110,9 @@ describe('WorkspaceShellFacade', () => {
openAccountInfo: jest.Mock;
};
let storeDispatch: jest.Mock;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistSignalMeta>>;
let activePlaylistSignal: ReturnType<
typeof signal<PlaylistSignalMeta | null>
>;
let playlistsSignal: ReturnType<typeof signal<PlaylistSignalMeta[]>>;
let stalkerStore: MockStalkerStore;
let showDashboardSignal: ReturnType<typeof signal<boolean>>;
@@ -230,7 +243,19 @@ describe('WorkspaceShellFacade', () => {
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
instant: (
key: string,
params?: Record<string, string | number>
) => {
if (
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS' &&
params
) {
return `${params.type} imported: ${params.current} / ${params.total}`;
}
return key;
},
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
@@ -299,6 +324,21 @@ describe('WorkspaceShellFacade', () => {
);
});
it('builds a type-aware xtream import progress label', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
xtreamStore.activeImportContentType.set('vod');
xtreamStore.activeImportCurrentCount.set(20);
xtreamStore.activeImportTotalCount.set(12323);
expect(facade.xtreamImportTypeLabel()).toBe(
'WORKSPACE.SHELL.RAIL_MOVIES'
);
expect(facade.xtreamImportProgressLabel()).toBe(
'WORKSPACE.SHELL.RAIL_MOVIES imported: 20 / 12,323'
);
});
it('keeps stalker local-filter routes on the same page and syncs q', () => {
router.navigate.mockClear();
router.navigateByUrl.mockClear();
@@ -329,18 +369,15 @@ describe('WorkspaceShellFacade', () => {
});
});
it('prefers provider-scoped global favorites when rail context exists', () => {
it('navigates to the global favorites route', () => {
router.navigate.mockClear();
facade.currentUrl.set('/workspace/xtreams/pl-1/vod');
facade.navigateToGlobalFavorites();
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', 'pl-1', 'favorites'],
{
queryParams: { scope: 'all' },
}
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace/global-favorites',
]);
});
it('clears stalker recent items and refreshes the route', async () => {
@@ -410,6 +447,18 @@ describe('WorkspaceShellFacade', () => {
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
path: ['/workspace/sources'],
},
{
icon: 'favorite',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES',
path: ['/workspace/global-favorites'],
exact: true,
},
{
icon: 'history',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_RECENT',
path: ['/workspace/global-recent'],
exact: true,
},
]);
expect(facade.brandLink()).toBe('/workspace/sources');
});
@@ -426,4 +475,99 @@ describe('WorkspaceShellFacade', () => {
expect(facade.searchScopeLabel()).toBe('PORTALS.RECENTLY_VIEWED');
});
it('shows only actionable global commands on an empty dashboard', () => {
activePlaylistSignal.set(null);
playlistsSignal.set([]);
facade.currentUrl.set('/workspace/dashboard');
const commands = facade.commandPaletteCommands();
expect(commands.map((command) => command.id)).toEqual([
'open-global-favorites',
'open-global-recent',
'open-downloads',
'open-settings',
'open-sources',
'add-playlist',
]);
expect(commands.every((command) => command.group === 'global')).toBe(
true
);
expect(commands.every((command) => command.enabled)).toBe(true);
});
it('includes M3U navigation, playlist actions, and Multi-EPG on playlist routes', () => {
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
activePlaylistSignal.set({
_id: 'pl-m3u',
title: 'Playlist M3U',
count: 10,
importDate: '2026-04-22T10:00:00.000Z',
autoRefresh: false,
});
facade.currentUrl.set('/workspace/playlists/pl-m3u/groups');
headerContext.setAction({
id: 'm3u-multi-epg',
icon: 'view_list',
tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
palette: {
labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
keywords: ['epg', 'guide', 'schedule'],
priority: 10,
},
run: jest.fn(),
});
const commands = facade.commandPaletteCommands();
expect(commands.map((command) => command.id)).toEqual(
expect.arrayContaining([
'm3u-multi-epg',
'go-to-all',
'go-to-favorites',
'go-to-recent',
'playlist-info',
])
);
expect(
commands.find((command) => command.id === 'm3u-multi-epg')?.group
).toBe('view');
expect(commands.some((command) => command.id === 'account-info')).toBe(
false
);
});
it('places registered current-view commands ahead of globals on global favorites routes', () => {
const viewCommands = TestBed.inject(WorkspaceViewCommandService);
const clearCurrent = jest.fn();
const unregister = viewCommands.registerCommand({
id: 'clear-current-favorites',
group: 'view',
icon: 'delete_sweep',
labelKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE',
labelParams: () => ({ type: 'Live TV' }),
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION',
descriptionParams: () => ({ type: 'Live TV' }),
priority: 10,
run: clearCurrent,
});
facade.currentUrl.set('/workspace/global-favorites');
const commands = facade.commandPaletteCommands();
expect(commands[0]?.id).toBe('clear-current-favorites');
expect(commands[0]?.group).toBe('view');
expect(commands.some((command) => command.id === 'playlist-search')).toBe(
false
);
unregister();
});
});
@@ -22,8 +22,12 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PortalRailLink,
PortalRailSection,
WorkspaceCommandContribution,
WorkspaceCommandSelection,
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
WorkspaceResolvedCommandItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@@ -41,13 +45,7 @@ import {
WorkspaceAccountInfoData,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import {
WorkspaceCommandId,
WorkspaceCommandItem,
WorkspaceCommandPaletteComponent,
WorkspaceCommandScope,
WorkspaceCommandSelection,
} from '../../workspace-command-palette/workspace-command-palette.component';
import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component';
export interface WorkspaceHeaderBulkAction {
icon: string;
@@ -56,25 +54,6 @@ export interface WorkspaceHeaderBulkAction {
disabled: boolean;
}
interface WorkspaceCommandAvailability {
context: WorkspacePortalContext | null;
dashboardXtreamContext: WorkspacePortalContext | null;
hasXtreamPlaylists: boolean;
isElectron: boolean;
canRefreshPlaylist: boolean;
hasActivePlaylist: boolean;
isXtreamPlaylist: boolean;
isPortalPlaylist: boolean;
}
interface WorkspaceCommandDefinition {
id: WorkspaceCommandId;
labelKey: string;
descriptionKey: string;
scope: WorkspaceCommandScope;
isEnabled: (state: WorkspaceCommandAvailability) => boolean;
}
type XtreamImportPhaseTone = 'remote' | 'local' | null;
const SEARCH_INPUT_DEBOUNCE_MS = 350;
@@ -104,123 +83,6 @@ const RAIL_TOOLTIP_KEYS: Readonly<Partial<Record<PortalRailSection, string>>> =
groups: 'WORKSPACE.SHELL.RAIL_GROUPS',
};
const WORKSPACE_COMMAND_DEFINITIONS: readonly WorkspaceCommandDefinition[] = [
{
id: 'global-search',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION',
scope: 'global',
isEnabled: (state) => state.hasXtreamPlaylists,
},
{
id: 'playlist-search',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) =>
Boolean(
state.dashboardXtreamContext ||
(state.context &&
(state.context.provider === 'xtreams' ||
state.context.provider === 'stalker'))
),
},
{
id: 'open-global-favorites',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-downloads',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_DESCRIPTION',
scope: 'global',
isEnabled: (state) => state.isElectron,
},
{
id: 'open-global-recent',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-settings',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-sources',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'open-dashboard',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'add-playlist',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_DESCRIPTION',
scope: 'global',
isEnabled: () => true,
},
{
id: 'refresh-playlist',
labelKey: 'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.canRefreshPlaylist,
},
{
id: 'playlist-info',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.hasActivePlaylist,
},
{
id: 'account-info',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION',
scope: 'playlist',
isEnabled: (state) => state.isXtreamPlaylist,
},
{
id: 'go-to-vod',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_VOD_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_VOD_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
{
id: 'go-to-live',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_LIVE_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_LIVE_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
{
id: 'go-to-series',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_SERIES_LABEL',
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GO_TO_SERIES_DESCRIPTION',
scope: 'section',
isEnabled: (state) => state.isPortalPlaylist,
},
];
@Injectable()
export class WorkspaceShellFacade {
private readonly router = inject(Router);
@@ -239,6 +101,7 @@ export class WorkspaceShellFacade {
WorkspaceStartupPreferencesService
);
readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly viewCommands = inject(WorkspaceViewCommandService);
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
@@ -308,6 +171,9 @@ export class WorkspaceShellFacade {
);
readonly currentContext = computed(() => this.currentRoute().context);
readonly currentSection = computed(() => this.currentRoute().section);
readonly commandPaletteCommands = computed<WorkspaceResolvedCommandItem[]>(
() => this.getCommandPaletteItems()
);
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
@@ -328,6 +194,22 @@ export class WorkspaceShellFacade {
path: ['/workspace/sources'],
});
links.push({
icon: 'favorite',
tooltip: this.translateText(
'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES'
),
path: ['/workspace/global-favorites'],
exact: true,
});
links.push({
icon: 'history',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_GLOBAL_RECENT'),
path: ['/workspace/global-recent'],
exact: true,
});
return links;
});
readonly isDashboardRoute = computed(
@@ -339,12 +221,6 @@ export class WorkspaceShellFacade {
readonly isSettingsRoute = computed(
() => this.currentRoute().kind === 'settings'
);
readonly isGlobalFavoritesRoute = computed(
() => this.currentRoute().kind === 'global-favorites'
);
readonly isPortalFavoritesAllScope = computed(
() => this.currentRoute().isPortalFavoritesAllScope
);
readonly isGlobalDownloadsRoute = computed(
() => this.currentRoute().kind === 'downloads'
);
@@ -400,6 +276,9 @@ export class WorkspaceShellFacade {
);
readonly xtreamImportCount = this.xtreamStore.getImportCount;
readonly xtreamItemsToImport = this.xtreamStore.itemsToImport;
readonly xtreamActiveImportCount = this.xtreamStore.activeImportCurrentCount;
readonly xtreamActiveItemsToImport =
this.xtreamStore.activeImportTotalCount;
readonly xtreamImportPhase = this.xtreamStore.currentImportPhase;
readonly isCancellingXtreamImport = this.xtreamStore.isCancellingImport;
readonly canCancelXtreamImport = computed(
@@ -412,6 +291,34 @@ export class WorkspaceShellFacade {
readonly xtreamImportTitleLabel = computed(() =>
this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_TITLE')
);
readonly xtreamImportTypeLabel = computed(() => {
this.languageTick();
switch (this.xtreamStore.activeImportContentType()) {
case 'live':
return this.translateText('WORKSPACE.SHELL.RAIL_LIVE');
case 'vod':
return this.translateText('WORKSPACE.SHELL.RAIL_MOVIES');
case 'series':
return this.translateText('WORKSPACE.SHELL.RAIL_SERIES');
default:
return '';
}
});
readonly xtreamImportProgressLabel = computed(() => {
const type = this.xtreamImportTypeLabel();
const total = this.xtreamActiveItemsToImport();
if (!type || total === 0) {
return '';
}
return this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS', {
type,
current: this.formatLocalizedNumber(this.xtreamActiveImportCount()),
total: this.formatLocalizedNumber(total),
});
});
readonly xtreamImportPhaseTone = computed<XtreamImportPhaseTone>(() => {
switch (this.xtreamStore.currentImportPhase()) {
case 'loading-categories':
@@ -845,9 +752,10 @@ export class WorkspaceShellFacade {
}
openCommandPalette(): void {
const commands = this.commandPaletteCommands();
const dialogRef = this.dialog.open<
WorkspaceCommandPaletteComponent,
{ commands: WorkspaceCommandItem[]; query: string },
{ commands: WorkspaceResolvedCommandItem[]; query: string },
WorkspaceCommandSelection | undefined
>(WorkspaceCommandPaletteComponent, {
width: 'min(760px, 92vw)',
@@ -855,7 +763,7 @@ export class WorkspaceShellFacade {
panelClass: 'workspace-command-palette-overlay',
autoFocus: false,
data: {
commands: this.getCommandPaletteItems(),
commands,
query: this.searchQuery(),
},
});
@@ -868,7 +776,13 @@ export class WorkspaceShellFacade {
return;
}
this.runCommandPaletteAction(selection);
const command = commands.find(
(item) =>
item.id === selection.commandId &&
item.visible &&
item.enabled
);
command?.run({ query: selection.query.trim() });
});
}
@@ -920,23 +834,6 @@ export class WorkspaceShellFacade {
}
navigateToGlobalFavorites(): void {
const context = this.railContext();
if (
context &&
(context.provider === 'xtreams' || context.provider === 'stalker')
) {
void this.router.navigate(
[
'/workspace',
context.provider,
context.playlistId,
'favorites',
],
{ queryParams: { scope: 'all' } }
);
return;
}
void this.router.navigate(['/workspace/global-favorites']);
}
@@ -994,129 +891,390 @@ export class WorkspaceShellFacade {
this.playlistRefreshAction.refresh(playlist);
}
private getCommandPaletteItems(): WorkspaceCommandItem[] {
private getCommandPaletteItems(): WorkspaceResolvedCommandItem[] {
this.languageTick();
return [
...this.getViewCommandDefinitions(),
...this.getPlaylistCommandDefinitions(),
...this.getGlobalCommandDefinitions(),
...this.viewCommands.commands(),
]
.map((command) => this.resolveCommand(command))
.filter((command) => command.visible)
.sort((left, right) => this.comparePaletteCommands(left, right));
}
private getViewCommandDefinitions(): WorkspaceCommandContribution[] {
const context = this.currentContext();
const provider = context?.provider;
const availability: WorkspaceCommandAvailability = {
context,
dashboardXtreamContext: this.dashboardXtreamContext(),
hasXtreamPlaylists: this.playlists().some(
(playlist) => !!playlist.serverUrl
),
isElectron: this.isElectron,
canRefreshPlaylist: this.canRefreshPlaylist(),
hasActivePlaylist: !!this.activePlaylist(),
isXtreamPlaylist: provider === 'xtreams',
isPortalPlaylist:
provider === 'xtreams' || provider === 'stalker',
const section = this.currentSection();
if (!context || !section) {
return [];
}
if (context.provider === 'playlists') {
return this.getM3uNavigationCommands(context, section);
}
if (context.provider === 'xtreams' || context.provider === 'stalker') {
return this.getPortalNavigationCommands(context, section);
}
return [];
}
private getPlaylistCommandDefinitions(): WorkspaceCommandContribution[] {
const route = this.currentRoute();
const context = route.context;
if (
!context ||
(context.provider !== 'xtreams' &&
context.provider !== 'stalker' &&
context.provider !== 'playlists')
) {
return [];
}
const hasActivePlaylist = !!this.activePlaylist();
const canOpenPlaylistSearch =
context.provider === 'xtreams' || context.provider === 'stalker';
return [
{
id: 'playlist-search',
group: 'playlist',
icon: 'playlist_play',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.PLAYLIST_SEARCH_DESCRIPTION',
priority: 10,
visible: canOpenPlaylistSearch,
run: ({ query }) => this.openPlaylistSearchFromPalette(query),
},
{
id: 'refresh-playlist',
group: 'playlist',
icon: 'sync',
labelKey: 'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.REFRESH_PLAYLIST_DESCRIPTION',
priority: 20,
visible: this.canRefreshPlaylist(),
run: () => this.refreshCurrentPlaylist(),
},
{
id: 'playlist-info',
group: 'playlist',
icon: 'info',
labelKey: 'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.PLAYLIST_INFO_DESCRIPTION',
priority: 30,
visible: hasActivePlaylist,
run: () => this.openPlaylistInfo(),
},
{
id: 'account-info',
group: 'playlist',
icon: 'account_circle',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION',
priority: 40,
visible: context.provider === 'xtreams',
run: () => this.openAccountInfo(),
},
];
}
private getGlobalCommandDefinitions(): WorkspaceCommandContribution[] {
const route = this.currentRoute();
const hasXtreamPlaylists = this.playlists().some(
(playlist) => !!playlist.serverUrl
);
return [
{
id: 'global-search',
group: 'global',
icon: 'search',
labelKey: 'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION',
priority: 10,
visible: hasXtreamPlaylists,
keywords: ['xtream'],
run: ({ query }) => this.openGlobalSearch(query),
},
{
id: 'open-global-favorites',
group: 'global',
icon: 'star',
labelKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_FAVORITES_DESCRIPTION',
priority: 20,
visible: route.kind !== 'global-favorites',
run: () => this.navigateToGlobalFavorites(),
},
{
id: 'open-global-recent',
group: 'global',
icon: 'history',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_GLOBAL_RECENT_DESCRIPTION',
priority: 30,
visible: route.kind !== 'global-recent',
run: () => this.openGlobalRecent(),
},
{
id: 'open-downloads',
group: 'global',
icon: 'download',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_DESCRIPTION',
priority: 40,
visible: this.isElectron && route.kind !== 'downloads',
run: () => this.openDownloadsShortcut(),
},
{
id: 'open-settings',
group: 'global',
icon: 'settings',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_SETTINGS_DESCRIPTION',
priority: 50,
visible: route.kind !== 'settings',
run: () => {
void this.router.navigate(['/workspace', 'settings']);
},
},
{
id: 'open-sources',
group: 'global',
icon: 'library_books',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_DESCRIPTION',
priority: 60,
visible: route.kind !== 'sources',
run: () => {
void this.router.navigate(['/workspace', 'sources']);
},
},
{
id: 'open-dashboard',
group: 'global',
icon: 'dashboard',
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_DASHBOARD_DESCRIPTION',
priority: 70,
visible: this.showDashboard() && route.kind !== 'dashboard',
run: () => {
void this.router.navigate(['/workspace', 'dashboard']);
},
},
{
id: 'add-playlist',
group: 'global',
icon: 'add_circle_outline',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_DESCRIPTION',
priority: 80,
run: () => this.openAddPlaylistDialog(),
},
];
}
private getPortalNavigationCommands(
context: WorkspacePortalContext,
section: PortalRailSection
): WorkspaceCommandContribution[] {
const liveSection = context.provider === 'stalker' ? 'itv' : 'live';
return [
this.createNavigationCommand({
id: 'go-to-vod',
context,
targetSection: 'vod',
currentSection: section,
icon: 'movie',
labelKey: 'WORKSPACE.SHELL.RAIL_MOVIES',
priority: 100,
}),
this.createNavigationCommand({
id: 'go-to-live',
context,
targetSection: liveSection,
currentSection: section,
icon: 'live_tv',
labelKey: 'WORKSPACE.SHELL.RAIL_LIVE',
priority: 110,
}),
this.createNavigationCommand({
id: 'go-to-series',
context,
targetSection: 'series',
currentSection: section,
icon: 'video_library',
labelKey: 'WORKSPACE.SHELL.RAIL_SERIES',
priority: 120,
}),
].filter(
(command): command is WorkspaceCommandContribution => command !== null
);
}
private getM3uNavigationCommands(
context: WorkspacePortalContext,
section: PortalRailSection
): WorkspaceCommandContribution[] {
return [
this.createNavigationCommand({
id: 'go-to-all',
context,
targetSection: 'all',
currentSection: section,
icon: 'format_list_bulleted',
labelKey: 'WORKSPACE.SHELL.RAIL_ALL_CHANNELS',
priority: 100,
}),
this.createNavigationCommand({
id: 'go-to-groups',
context,
targetSection: 'groups',
currentSection: section,
icon: 'folder_open',
labelKey: 'WORKSPACE.SHELL.RAIL_GROUPS',
priority: 110,
}),
this.createNavigationCommand({
id: 'go-to-favorites',
context,
targetSection: 'favorites',
currentSection: section,
icon: 'star',
labelKey: 'WORKSPACE.SHELL.RAIL_FAVORITES',
priority: 120,
}),
this.createNavigationCommand({
id: 'go-to-recent',
context,
targetSection: 'recent',
currentSection: section,
icon: 'history',
labelKey: 'WORKSPACE.SHELL.RAIL_RECENT',
priority: 130,
}),
].filter(
(command): command is WorkspaceCommandContribution => command !== null
);
}
private createNavigationCommand(config: {
id: string;
context: WorkspacePortalContext;
targetSection: string;
currentSection: PortalRailSection;
icon: string;
labelKey: string;
priority: number;
}): WorkspaceCommandContribution | null {
if (config.currentSection === config.targetSection) {
return null;
}
return {
id: config.id,
group: 'view',
icon: config.icon,
labelKey: config.labelKey,
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_VIEW_DESCRIPTION',
descriptionParams: () => ({
view: this.translateText(config.labelKey),
}),
priority: config.priority,
run: () => {
void this.router.navigate([
'/workspace',
config.context.provider,
config.context.playlistId,
config.targetSection,
]);
},
};
return WORKSPACE_COMMAND_DEFINITIONS.map((definition) => ({
id: definition.id,
label: this.translateText(definition.labelKey),
description: this.translateText(definition.descriptionKey),
scope: definition.scope,
enabled: definition.isEnabled(availability),
}));
}
private runCommandPaletteAction(
selection: WorkspaceCommandSelection
): void {
const query = selection.query.trim();
private resolveCommand(
command: WorkspaceCommandContribution
): WorkspaceResolvedCommandItem {
const labelParams = this.resolveCommandValue(command.labelParams);
const descriptionParams = this.resolveCommandValue(
command.descriptionParams
);
if (selection.commandId === 'global-search') {
this.openGlobalSearch(query);
return;
}
if (selection.commandId === 'playlist-search') {
this.openPlaylistSearchFromPalette(query);
return;
}
if (selection.commandId === 'open-global-favorites') {
this.navigateToGlobalFavorites();
return;
}
if (selection.commandId === 'open-downloads') {
this.openDownloadsShortcut();
return;
}
if (selection.commandId === 'open-global-recent') {
this.openGlobalRecent();
return;
}
if (selection.commandId === 'open-settings') {
void this.router.navigate(['/workspace', 'settings']);
return;
}
if (selection.commandId === 'open-sources') {
void this.router.navigate(['/workspace', 'sources']);
return;
}
if (selection.commandId === 'open-dashboard') {
void this.router.navigate(['/workspace', 'dashboard']);
return;
}
if (selection.commandId === 'add-playlist') {
this.openAddPlaylistDialog();
return;
}
if (selection.commandId === 'refresh-playlist') {
this.refreshCurrentPlaylist();
return;
}
if (selection.commandId === 'playlist-info') {
this.openPlaylistInfo();
return;
}
if (selection.commandId === 'account-info') {
this.openAccountInfo();
return;
}
this.navigateToPortalSection(selection.commandId);
return {
id: command.id,
group: command.group,
icon: command.icon,
label: this.translateText(command.labelKey, labelParams),
description: command.descriptionKey
? this.translateText(command.descriptionKey, descriptionParams)
: '',
keywords: this.resolveCommandValue(command.keywords) ?? [],
priority: command.priority ?? 100,
visible: this.resolveCommandValue(command.visible) ?? true,
enabled: this.resolveCommandValue(command.enabled) ?? true,
run: command.run,
};
}
private navigateToPortalSection(commandId: WorkspaceCommandId): void {
const context = this.currentContext();
if (!context) {
return;
private comparePaletteCommands(
left: WorkspaceResolvedCommandItem,
right: WorkspaceResolvedCommandItem
): number {
const groupOrder =
this.getCommandGroupOrder(left.group) -
this.getCommandGroupOrder(right.group);
if (groupOrder !== 0) {
return groupOrder;
}
const isStalker = context.provider === 'stalker';
let section: string;
if (left.priority !== right.priority) {
return left.priority - right.priority;
}
switch (commandId) {
case 'go-to-vod':
section = 'vod';
break;
case 'go-to-live':
section = isStalker ? 'itv' : 'live';
break;
case 'go-to-series':
section = 'series';
break;
return left.label.localeCompare(right.label);
}
private getCommandGroupOrder(group: WorkspaceResolvedCommandItem['group']) {
switch (group) {
case 'view':
return 0;
case 'playlist':
return 1;
default:
return;
return 2;
}
}
private resolveCommandValue<T>(
value: T | (() => T | undefined) | undefined
): T | undefined {
if (typeof value === 'function') {
return (value as () => T | undefined)();
}
void this.router.navigate([
'/workspace',
context.provider,
context.playlistId,
section,
]);
return value;
}
private openPlaylistSearchFromPalette(query: string): void {
@@ -1385,6 +1543,12 @@ export class WorkspaceShellFacade {
return this.translateText(this.getRailTooltipKey('playlists', section));
}
private formatLocalizedNumber(value: number): string {
const locale =
this.translate.currentLang || this.translate.defaultLang || 'en';
return new Intl.NumberFormat(locale).format(value);
}
private translateText(
key: string,
params?: Record<string, string | number>
@@ -29,24 +29,25 @@
</p>
}
@if (
facade.xtreamImportCount() !== 0 &&
facade.xtreamItemsToImport() !== 0
facade.xtreamActiveImportCount() !== 0 &&
facade.xtreamActiveItemsToImport() !== 0
) {
<mat-progress-bar
mode="determinate"
[value]="
(facade.xtreamImportCount() /
facade.xtreamItemsToImport()) *
(facade.xtreamActiveImportCount() /
facade.xtreamActiveItemsToImport()) *
100
"
/>
<p class="workspace-loading-overlay__progress-copy">
{{ facade.xtreamImportCount() }} /
{{ facade.xtreamItemsToImport() }}
</p>
} @else {
<mat-progress-bar mode="indeterminate" />
}
@if (facade.xtreamImportProgressLabel()) {
<p class="workspace-loading-overlay__progress-copy">
{{ facade.xtreamImportProgressLabel() }}
</p>
}
@if (facade.canCancelXtreamImport()) {
<button
mat-stroked-button
@@ -90,9 +91,6 @@
[searchPlaceholder]="facade.searchPlaceholder()"
[searchScopeLabel]="facade.searchScopeLabel()"
[searchStatusLabel]="facade.searchStatusLabel()"
[isGlobalFavoritesActive]="
facade.isGlobalFavoritesRoute() || facade.isPortalFavoritesAllScope()
"
[headerShortcut]="facade.headerShortcut()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
@@ -104,7 +102,6 @@
(searchSubmitted)="facade.onSearchEnter($event)"
(commandPaletteRequested)="facade.openCommandPalette()"
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(globalFavoritesRequested)="facade.navigateToGlobalFavorites()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
@@ -157,8 +157,12 @@ app-workspace-shell-context-sidebar {
}
.workspace-loading-overlay__progress-copy {
max-width: 30ch;
justify-self: center;
line-height: 1.45;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
text-wrap: balance;
}
.workspace-loading-overlay__action {
@@ -5,6 +5,7 @@ import {
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterOutlet, provideRouter } from '@angular/router';
import {
WorkspacePortalContext,
@@ -49,17 +50,19 @@ class MockWorkspaceShellHeaderComponent {
readonly searchPlaceholder = input('');
readonly searchScopeLabel = input('');
readonly searchStatusLabel = input('');
readonly isGlobalFavoritesActive = input(false);
readonly headerShortcut = input<unknown>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly globalFavoritesRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly playlistInfoRequested = output<void>();
@@ -108,10 +111,11 @@ class MockWorkspaceShellFacade {
);
readonly searchScopeLabel = signal('Movies / All Items');
readonly searchStatusLabel = signal('');
readonly isGlobalFavoritesRoute = signal(false);
readonly isPortalFavoritesAllScope = signal(false);
readonly headerShortcut = signal(null);
readonly canRefreshPlaylist = signal(false);
readonly isRefreshingPlaylist = signal(false);
readonly isDownloadsView = signal(false);
readonly hasActiveDownloads = signal(false);
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
readonly showContextPanel = signal(true);
readonly contextPanel = signal<WorkspaceShellContextPanel>('settings');
@@ -121,6 +125,8 @@ class MockWorkspaceShellFacade {
readonly showXtreamImportOverlay = signal(false);
readonly xtreamImportCount = signal(0);
readonly xtreamItemsToImport = signal(0);
readonly xtreamActiveImportCount = signal(0);
readonly xtreamActiveItemsToImport = signal(0);
readonly xtreamImportTitleLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_TITLE'
);
@@ -133,6 +139,7 @@ class MockWorkspaceShellFacade {
readonly xtreamImportDetailLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
);
readonly xtreamImportProgressLabel = signal('');
readonly xtreamImportPhaseTone = signal<'remote' | 'local' | null>(
'remote'
);
@@ -145,8 +152,8 @@ class MockWorkspaceShellFacade {
onSearchEnter = jest.fn();
openCommandPalette = jest.fn();
openAddPlaylistDialog = jest.fn();
navigateToGlobalFavorites = jest.fn();
runHeaderShortcut = jest.fn();
refreshCurrentPlaylist = jest.fn();
openDownloadsShortcut = jest.fn();
runHeaderBulkAction = jest.fn();
openPlaylistInfo = jest.fn();
@@ -166,6 +173,7 @@ describe('WorkspaceShellComponent', () => {
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
MatProgressBarModule,
RouterOutlet,
MockExternalPlaybackDockComponent,
MockWorkspaceShellContextSidebarComponent,
@@ -201,4 +209,46 @@ describe('WorkspaceShellComponent', () => {
fixture.nativeElement.querySelector('app-external-playback-dock')
).not.toBeNull();
});
it('renders type-aware xtream import progress copy in the overlay', async () => {
const facade = new MockWorkspaceShellFacade();
await TestBed.configureTestingModule({
imports: [WorkspaceShellComponent],
providers: [provideRouter([])],
})
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
MatProgressBarModule,
RouterOutlet,
MockExternalPlaybackDockComponent,
MockWorkspaceShellContextSidebarComponent,
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellRailComponent,
],
providers: [
{
provide: WorkspaceShellFacade,
useValue: facade,
},
],
},
})
.compileComponents();
facade.showXtreamImportOverlay.set(true);
facade.xtreamActiveImportCount.set(20);
facade.xtreamActiveItemsToImport.set(12323);
facade.xtreamImportProgressLabel.set('Movies imported: 20 / 12,323');
const fixture = TestBed.createComponent(WorkspaceShellComponent);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'.workspace-loading-overlay__progress-copy'
)?.textContent
).toContain('Movies imported: 20 / 12,323');
});
});