refactor(workspace-shell): split 1666-line facade into helpers and sub-services

The WorkspaceShellFacade had grown to 1666 lines, well past the 300/400
LOC target in CLAUDE.md. The component template binds to 54 facade
members directly, so the split preserves the public API by reference
re-export and keeps the template untouched.

Phase 1 — extract pure helpers (no DI, no behavior change):
  - helpers/workspace-shell-constants.ts: i18n keys, RAIL_TOOLTIP_KEYS,
    XtreamImportPhaseTone, WorkspaceHeaderBulkAction
  - helpers/workspace-shell-route-utils.ts: toQueryString,
    getRouteQueryParam, syncSearchQueryParam, bumpRefreshQueryParam,
    getProviderFromPlaylist
  - helpers/workspace-shell-search-labels.ts: resolveSearchPlaceholderKey,
    resolveSearchScopeLabel, resolveActiveCategoryLabel, getRailTooltipKey,
    translateRailLinks (each takes TranslateFn + snapshots as params)
  - helpers/workspace-shell-import-labels.ts: build* helpers for the 14
    Xtream import overlay labels and formatLocalizedNumber
  - helpers/workspace-shell-command-builders.ts: buildCommandPaletteItems
    plus the three command-definition builders, navigation command
    factory, resolveCommand, comparePaletteCommands, getCommandGroupOrder

Phase 2 — extract the two component-scoped sub-services with the
cleanest boundaries:
  - WorkspaceShellXtreamImportService: read-only labels, cancel action,
    isImportRunning. Reads only XtreamStore + TranslateService.
  - WorkspaceShellCommandPaletteService: MatDialog lifecycle, recent
    commands integration, eager WorkspacePlayerCommandsContributor
    bootstrap. Receives a typed CommandBuilderContext via function
    argument from the parent — no circular DI.

The parent facade re-exposes every member by reference assignment so
signal identity is preserved for change detection. workspace-shell.
component.html is unchanged. workspace-shell.component.ts gains only
two provider entries for the new sub-services.

Verification:
  - workspace-shell.facade.spec.ts: 20/20 passing
  - pnpm nx lint workspace-shell-feature: clean
  - workspace-shell-feature suite: 53/60 passing — same 7 pre-existing
    failures (header & shell component specs hit unrelated NG0301
    on playlistDropZone export); no regressions introduced
  - CDP smoke: command palette opens with Recently Used + Global
    Actions groups, settings nav disables search bar correctly

Facade reduces from 1666 to 917 lines. Each new helper/service is
under 250 lines except command-builders.ts (462) which is mostly
declarative command definitions. The parent facade is still over the
400-line hard max because cross-cutting orchestration
(searchCapability, runHeaderBulkAction, rail link computeds) reads
from multiple clusters; further reduction would require moving logic
into child components, which is deferred per the plan.

Plan: ~/.claude/plans/workspace-shell-facade-ts-is-now-1668-crispy-planet.md
(also copied locally to .plans/2026-05-01-workspace-shell-facade-split.md;
the working-tree .gitignore change excludes .plans from tracking).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-01 18:48:53 +02:00
1 parent 2f47ed16cb
commit d1cd805622
10 files changed
+1194 -861

No files matched your search

@@ -0,0 +1,462 @@
import { Router } from '@angular/router';
import {
PortalRailSection,
WorkspaceCommandContribution,
WorkspaceResolvedCommandItem,
} from '@iptvnator/portal/shared/util';
import {
WorkspacePortalContext,
WorkspaceShellRoute,
} from '@iptvnator/workspace/shell/util';
import { TranslateFn } from './workspace-shell-search-labels';
export interface CommandBuilderActions {
openPlaylistSearch: (query: string) => void;
refreshCurrentPlaylist: () => void;
openPlaylistInfo: () => void;
openAccountInfo: () => void;
openGlobalSearch: (query: string) => void;
navigateToGlobalFavorites: () => void;
openGlobalRecent: () => void;
openDownloadsShortcut: () => void;
openAddPlaylistDialog: (kind?: 'url' | 'xtream' | 'stalker') => void;
}
export interface CommandBuilderContext {
route: WorkspaceShellRoute;
context: WorkspacePortalContext | null;
section: PortalRailSection | null;
hasActivePlaylist: boolean;
hasXtreamPlaylists: boolean;
canRefreshPlaylist: boolean;
isElectron: boolean;
showDashboard: boolean;
translate: TranslateFn;
router: Router;
actions: CommandBuilderActions;
}
export function buildCommandPaletteItems(
ctx: CommandBuilderContext,
viewCommands: WorkspaceCommandContribution[]
): WorkspaceResolvedCommandItem[] {
return [
...getViewCommandDefinitions(ctx),
...getPlaylistCommandDefinitions(ctx),
...getGlobalCommandDefinitions(ctx),
...viewCommands,
]
.map((command) => resolveCommand(command, ctx.translate))
.filter((command) => command.visible)
.sort(comparePaletteCommands);
}
export function getViewCommandDefinitions(
ctx: CommandBuilderContext
): WorkspaceCommandContribution[] {
const { context, section } = ctx;
if (!context || !section) {
return [];
}
if (context.provider === 'playlists') {
return getM3uNavigationCommands(ctx, context, section);
}
if (context.provider === 'xtreams' || context.provider === 'stalker') {
return getPortalNavigationCommands(ctx, context, section);
}
return [];
}
export function getPlaylistCommandDefinitions(
ctx: CommandBuilderContext
): WorkspaceCommandContribution[] {
const { context } = ctx.route;
if (
!context ||
(context.provider !== 'xtreams' &&
context.provider !== 'stalker' &&
context.provider !== 'playlists')
) {
return [];
}
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 }) => ctx.actions.openPlaylistSearch(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: ctx.canRefreshPlaylist,
run: () => ctx.actions.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: ctx.hasActivePlaylist,
run: () => ctx.actions.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: () => ctx.actions.openAccountInfo(),
},
];
}
export function getGlobalCommandDefinitions(
ctx: CommandBuilderContext
): WorkspaceCommandContribution[] {
const { route, hasXtreamPlaylists, isElectron, showDashboard, actions } =
ctx;
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 }) => actions.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: () => actions.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: () => actions.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: isElectron && route.kind !== 'downloads',
run: () => actions.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 ctx.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 ctx.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: showDashboard && route.kind !== 'dashboard',
run: () => {
void ctx.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: () => actions.openAddPlaylistDialog(),
},
{
id: 'add-playlist-m3u',
group: 'global',
icon: 'folder_open',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_DESCRIPTION',
keywords: ['m3u', 'm3u8', 'file', 'url', 'add', 'import'],
priority: 79,
run: () => actions.openAddPlaylistDialog('url'),
},
{
id: 'add-playlist-xtream',
group: 'global',
icon: 'cloud',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_DESCRIPTION',
keywords: ['xtream', 'codes', 'iptv', 'add', 'import'],
priority: 78,
run: () => actions.openAddPlaylistDialog('xtream'),
},
{
id: 'add-playlist-stalker',
group: 'global',
icon: 'cast',
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_DESCRIPTION',
keywords: ['stalker', 'portal', 'mac', 'ministra', 'add'],
priority: 77,
run: () => actions.openAddPlaylistDialog('stalker'),
},
];
}
function getPortalNavigationCommands(
ctx: CommandBuilderContext,
context: WorkspacePortalContext,
section: PortalRailSection
): WorkspaceCommandContribution[] {
const liveSection = context.provider === 'stalker' ? 'itv' : 'live';
return [
createNavigationCommand(ctx, {
id: 'go-to-vod',
context,
targetSection: 'vod',
currentSection: section,
icon: 'movie',
labelKey: 'WORKSPACE.SHELL.RAIL_MOVIES',
priority: 100,
}),
createNavigationCommand(ctx, {
id: 'go-to-live',
context,
targetSection: liveSection,
currentSection: section,
icon: 'live_tv',
labelKey: 'WORKSPACE.SHELL.RAIL_LIVE',
priority: 110,
}),
createNavigationCommand(ctx, {
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
);
}
function getM3uNavigationCommands(
ctx: CommandBuilderContext,
context: WorkspacePortalContext,
section: PortalRailSection
): WorkspaceCommandContribution[] {
return [
createNavigationCommand(ctx, {
id: 'go-to-all',
context,
targetSection: 'all',
currentSection: section,
icon: 'format_list_bulleted',
labelKey: 'WORKSPACE.SHELL.RAIL_ALL_CHANNELS',
priority: 100,
}),
createNavigationCommand(ctx, {
id: 'go-to-groups',
context,
targetSection: 'groups',
currentSection: section,
icon: 'folder_open',
labelKey: 'WORKSPACE.SHELL.RAIL_GROUPS',
priority: 110,
}),
createNavigationCommand(ctx, {
id: 'go-to-favorites',
context,
targetSection: 'favorites',
currentSection: section,
icon: 'star',
labelKey: 'WORKSPACE.SHELL.RAIL_FAVORITES',
priority: 120,
}),
createNavigationCommand(ctx, {
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
);
}
function createNavigationCommand(
ctx: CommandBuilderContext,
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: ctx.translate(config.labelKey),
}),
priority: config.priority,
run: () => {
void ctx.router.navigate([
'/workspace',
config.context.provider,
config.context.playlistId,
config.targetSection,
]);
},
};
}
export function resolveCommand(
command: WorkspaceCommandContribution,
translate: TranslateFn
): WorkspaceResolvedCommandItem {
const labelParams = resolveCommandValue(command.labelParams);
const descriptionParams = resolveCommandValue(command.descriptionParams);
return {
id: command.id,
group: command.group,
icon: command.icon,
label: translate(command.labelKey, labelParams),
description: command.descriptionKey
? translate(command.descriptionKey, descriptionParams)
: '',
keywords: resolveCommandValue(command.keywords) ?? [],
priority: command.priority ?? 100,
visible: resolveCommandValue(command.visible) ?? true,
enabled: resolveCommandValue(command.enabled) ?? true,
run: command.run,
};
}
export function comparePaletteCommands(
left: WorkspaceResolvedCommandItem,
right: WorkspaceResolvedCommandItem
): number {
const groupOrder =
getCommandGroupOrder(left.group) - getCommandGroupOrder(right.group);
if (groupOrder !== 0) {
return groupOrder;
}
if (left.priority !== right.priority) {
return left.priority - right.priority;
}
return left.label.localeCompare(right.label);
}
export function getCommandGroupOrder(
group: WorkspaceResolvedCommandItem['group']
): number {
switch (group) {
case 'view':
return 0;
case 'playlist':
return 1;
default:
return 2;
}
}
export function resolveCommandValue<T>(
value: T | (() => T | undefined) | undefined
): T | undefined {
if (typeof value === 'function') {
return (value as () => T | undefined)();
}
return value;
}
@@ -0,0 +1,44 @@
import { PortalRailSection } from '@iptvnator/portal/shared/util';
export const SEARCH_INPUT_DEBOUNCE_MS = 350;
export const SEARCH_PLAYLIST_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
export const SEARCH_SECTION_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_SECTION_PLACEHOLDER';
export const FILTER_SECTION_PLACEHOLDER =
'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
export const SEARCH_SOURCES_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_SOURCES_PLACEHOLDER';
export const SEARCH_LOADED_ONLY_STATUS =
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY';
export const CLEAR_RECENTLY_VIEWED_TOOLTIP =
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION';
export const CLEAR_RECENTLY_VIEWED_ARIA =
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA';
export const RAIL_TOOLTIP_KEYS: Readonly<
Partial<Record<PortalRailSection, string>>
> = {
vod: 'WORKSPACE.SHELL.RAIL_MOVIES',
live: 'WORKSPACE.SHELL.RAIL_LIVE',
itv: 'WORKSPACE.SHELL.RAIL_LIVE',
series: 'WORKSPACE.SHELL.RAIL_SERIES',
'recently-added': 'WORKSPACE.SHELL.RAIL_RECENTLY_ADDED',
search: 'WORKSPACE.SHELL.RAIL_SEARCH',
recent: 'WORKSPACE.SHELL.RAIL_RECENT',
favorites: 'WORKSPACE.SHELL.RAIL_FAVORITES',
downloads: 'WORKSPACE.SHELL.RAIL_DOWNLOADS',
all: 'WORKSPACE.SHELL.RAIL_ALL_CHANNELS',
groups: 'WORKSPACE.SHELL.RAIL_GROUPS',
};
export type XtreamImportPhaseTone = 'remote' | 'local' | null;
export interface WorkspaceHeaderBulkAction {
icon: string;
tooltip: string;
ariaLabel: string;
disabled: boolean;
}
@@ -0,0 +1,119 @@
import { XtreamImportPhaseTone } from './workspace-shell-constants';
import { TranslateFn } from './workspace-shell-search-labels';
export type XtreamImportContentType = 'live' | 'vod' | 'series' | null;
export function buildXtreamImportTypeLabel(
contentType: XtreamImportContentType,
translate: TranslateFn
): string {
switch (contentType) {
case 'live':
return translate('WORKSPACE.SHELL.RAIL_LIVE');
case 'vod':
return translate('WORKSPACE.SHELL.RAIL_MOVIES');
case 'series':
return translate('WORKSPACE.SHELL.RAIL_SERIES');
default:
return '';
}
}
export function buildXtreamImportPhaseTone(
phase: string | null | undefined
): XtreamImportPhaseTone {
switch (phase) {
case 'loading-categories':
case 'loading-live':
case 'loading-movies':
case 'loading-series':
return 'remote';
case 'preparing-content':
case 'saving-categories':
case 'saving-content':
case 'restoring-favorites':
case 'restoring-recently-viewed':
return 'local';
default:
return null;
}
}
export function buildXtreamImportSourceLabel(
tone: XtreamImportPhaseTone,
translate: TranslateFn
): string {
if (tone === 'remote') {
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE');
}
if (tone === 'local') {
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE');
}
return '';
}
export function buildXtreamImportPhaseLabel(
phase: string | null | undefined,
translate: TranslateFn
): string {
switch (phase) {
case 'preparing-content':
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_PREPARING');
case 'loading-categories':
case 'loading-live':
case 'loading-movies':
case 'loading-series':
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_LOADING');
case 'saving-categories':
case 'saving-content':
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_SAVING');
case 'restoring-favorites':
return translate(
'WORKSPACE.SHELL.XTREAM_IMPORT_RESTORING_FAVORITES'
);
case 'restoring-recently-viewed':
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_RESTORING_RECENT');
default:
return '';
}
}
export function buildXtreamImportDetailLabel(
tone: XtreamImportPhaseTone,
translate: TranslateFn
): string {
if (tone === 'remote') {
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE');
}
if (tone === 'local') {
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_LOCAL');
}
return '';
}
export function buildXtreamImportProgressLabel(
typeLabel: string,
currentCount: number,
totalCount: number,
translate: TranslateFn,
formatNumber: (value: number) => string
): string {
if (!typeLabel || totalCount === 0) {
return '';
}
return translate('WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS', {
type: typeLabel,
current: formatNumber(currentCount),
total: formatNumber(totalCount),
});
}
export function formatLocalizedNumber(
value: number,
currentLang: string | null | undefined,
defaultLang: string | null | undefined
): string {
const locale = currentLang || defaultLang || 'en';
return new Intl.NumberFormat(locale).format(value);
}
@@ -0,0 +1,83 @@
import { Router } from '@angular/router';
import { WorkspacePortalContext } from '@iptvnator/workspace/shell/util';
export function toQueryString(queryParams: Record<string, unknown>): string {
const urlSearchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value == null) {
return;
}
if (Array.isArray(value)) {
value.forEach((item) => urlSearchParams.append(key, String(item)));
return;
}
urlSearchParams.set(key, String(value));
});
return urlSearchParams.toString();
}
export function getRouteQueryParam(
router: Router,
currentUrl: string,
name: string
): string {
const value = router.parseUrl(currentUrl).queryParams[name];
return typeof value === 'string' ? value : '';
}
export function syncSearchQueryParam(
router: Router,
currentUrl: string,
term: string
): boolean {
const nextTerm = term.trim();
const currentTerm = getRouteQueryParam(router, currentUrl, 'q');
if (nextTerm === currentTerm) {
return false;
}
const routePath = currentUrl.split('?')[0];
const queryParams = {
...router.parseUrl(currentUrl).queryParams,
};
if (nextTerm.length > 0) {
queryParams['q'] = nextTerm;
} else {
delete queryParams['q'];
}
const queryString = toQueryString(queryParams);
const nextUrl = queryString ? `${routePath}?${queryString}` : routePath;
void router.navigateByUrl(nextUrl, { replaceUrl: true });
return true;
}
export function bumpRefreshQueryParam(router: Router, currentUrl: string): void {
const routePath = currentUrl.split('?')[0];
const queryParams = {
...router.parseUrl(currentUrl).queryParams,
refresh: Date.now().toString(),
};
const queryString = toQueryString(queryParams);
const nextUrl = queryString ? `${routePath}?${queryString}` : routePath;
void router.navigateByUrl(nextUrl, { replaceUrl: true });
}
export function getProviderFromPlaylist(playlist: {
serverUrl?: string;
macAddress?: string;
}): WorkspacePortalContext['provider'] {
if (playlist.serverUrl) {
return 'xtreams';
}
if (playlist.macAddress) {
return 'stalker';
}
return 'playlists';
}
@@ -0,0 +1,159 @@
import {
PortalRailLink,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
import {
WorkspacePortalContext,
WorkspaceShellPageKind,
} from '@iptvnator/workspace/shell/util';
import {
FILTER_SECTION_PLACEHOLDER,
RAIL_TOOLTIP_KEYS,
SEARCH_PLAYLIST_PLACEHOLDER,
SEARCH_SECTION_PLACEHOLDER,
SEARCH_SOURCES_PLACEHOLDER,
} from './workspace-shell-constants';
export type TranslateFn = (
key: string,
params?: Record<string, string | number>
) => string;
export interface SearchScopeContext {
kind: WorkspaceShellPageKind;
context: WorkspacePortalContext | null;
section: PortalRailSection | null;
translate: TranslateFn;
xtreamCategory:
| { category_name?: string; name?: string }
| null
| undefined;
stalkerCategoryName: string;
}
export function getRailTooltipKey(
provider: WorkspacePortalContext['provider'],
section?: PortalRailSection
): string {
if (provider === 'xtreams' && section === 'library') {
return 'WORKSPACE.SHELL.RAIL_LIBRARY';
}
return (
(section ? RAIL_TOOLTIP_KEYS[section] : null) ??
'WORKSPACE.SHELL.RAIL_CONTEXT_ACTIONS'
);
}
export function translateRailSection(
section: PortalRailSection,
translate: TranslateFn
): string {
return translate(getRailTooltipKey('playlists', section));
}
export function translateRailLinks(
links: PortalRailLink[],
provider: WorkspacePortalContext['provider'],
translate: TranslateFn
): PortalRailLink[] {
return links.map((link) => ({
...link,
tooltip: translate(getRailTooltipKey(provider, link.section)),
}));
}
export function resolveSearchPlaceholderKey(
kind: WorkspaceShellPageKind,
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): string {
if (kind === 'sources') {
return SEARCH_SOURCES_PLACEHOLDER;
}
if (kind === 'dashboard' || section === 'search') {
return SEARCH_PLAYLIST_PLACEHOLDER;
}
if (
context &&
(section === 'vod' ||
section === 'series' ||
section === 'live' ||
section === 'itv')
) {
return SEARCH_SECTION_PLACEHOLDER;
}
return FILTER_SECTION_PLACEHOLDER;
}
export function resolveActiveCategoryLabel(ctx: SearchScopeContext): string {
const { context, section, translate, xtreamCategory, stalkerCategoryName } =
ctx;
if (!context || !section) {
return '';
}
if (context.provider === 'xtreams') {
return (
xtreamCategory?.category_name ??
xtreamCategory?.name ??
translateRailSection(section, translate)
);
}
if (context.provider === 'stalker') {
return (
stalkerCategoryName.trim() ||
translateRailSection(section, translate)
);
}
return translateRailSection(section, translate);
}
export function resolveSearchScopeLabel(ctx: SearchScopeContext): string {
const { kind, context, section, translate } = ctx;
if (kind === 'sources') {
return translate('WORKSPACE.SHELL.RAIL_SOURCES');
}
if (kind === 'global-favorites') {
return translate('HOME.PLAYLISTS.GLOBAL_FAVORITES');
}
if (kind === 'global-recent') {
return translate('PORTALS.RECENTLY_VIEWED');
}
if (kind === 'downloads') {
return translate('WORKSPACE.SHELL.RAIL_DOWNLOADS');
}
if (kind === 'dashboard' || section === 'search') {
return translate('WORKSPACE.SHELL.RAIL_SEARCH');
}
if (!context || !section) {
return '';
}
if (
section === 'vod' ||
section === 'series' ||
section === 'live' ||
section === 'itv'
) {
const categoryLabel = resolveActiveCategoryLabel(ctx);
const sectionLabel = translateRailSection(section, translate);
return categoryLabel
? `${sectionLabel} / ${categoryLabel}`
: sectionLabel;
}
return translateRailSection(section, translate);
}
@@ -0,0 +1,91 @@
import { DestroyRef, inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import {
WorkspaceCommandSelection,
WorkspaceResolvedCommandItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component';
import { RecentCommandsService } from '../../recent-commands';
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
import {
buildCommandPaletteItems,
CommandBuilderContext,
} from './helpers/workspace-shell-command-builders';
@Injectable()
export class WorkspaceShellCommandPaletteService {
private readonly dialog = inject(MatDialog);
private readonly viewCommands = inject(WorkspaceViewCommandService);
private readonly recentCommands = inject(RecentCommandsService);
private readonly destroyRef = inject(DestroyRef);
// Eager construction registers the player-switch commands via WorkspaceViewCommandService.
private readonly _playerCommandsBootstrap = inject(
WorkspacePlayerCommandsContributor
);
private commandPaletteRef: MatDialogRef<
WorkspaceCommandPaletteComponent,
WorkspaceCommandSelection | undefined
> | null = null;
buildPaletteCommands(
ctx: CommandBuilderContext
): WorkspaceResolvedCommandItem[] {
return buildCommandPaletteItems(ctx, this.viewCommands.commands());
}
openCommandPalette(ctx: CommandBuilderContext, initialQuery: string): void {
if (this.commandPaletteRef) {
this.commandPaletteRef.close();
return;
}
const commands = this.buildPaletteCommands(ctx);
const recentIds = this.recentCommands.entries().map((entry) => entry.id);
const dialogRef = this.dialog.open<
WorkspaceCommandPaletteComponent,
{
commands: WorkspaceResolvedCommandItem[];
query: string;
recentIds: readonly string[];
},
WorkspaceCommandSelection | undefined
>(WorkspaceCommandPaletteComponent, {
width: 'min(760px, 92vw)',
maxWidth: '92vw',
panelClass: 'workspace-command-palette-overlay',
autoFocus: false,
data: {
commands,
query: initialQuery,
recentIds,
},
});
this.commandPaletteRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((selection) => {
this.commandPaletteRef = null;
if (!selection) {
return;
}
const command = commands.find(
(item) =>
item.id === selection.commandId &&
item.visible &&
item.enabled
);
if (!command) {
return;
}
command.run({ query: selection.query.trim() });
this.recentCommands.record(command.id);
});
}
}
@@ -0,0 +1,114 @@
import { computed, inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { XtreamImportPhaseTone } from './helpers/workspace-shell-constants';
import {
buildXtreamImportDetailLabel,
buildXtreamImportPhaseLabel,
buildXtreamImportPhaseTone,
buildXtreamImportProgressLabel,
buildXtreamImportSourceLabel,
buildXtreamImportTypeLabel,
formatLocalizedNumber,
} from './helpers/workspace-shell-import-labels';
@Injectable()
export class WorkspaceShellXtreamImportService {
private readonly xtreamStore = inject(XtreamStore);
private readonly translate = inject(TranslateService);
private readonly isElectron = !!window.electron;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly translateText = (
key: string,
params?: Record<string, string | number>
): string => this.translate.instant(key, params);
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(
() =>
this.isElectron &&
this.xtreamStore.isImporting() &&
Boolean(this.xtreamStore.activeImportSessionId()) &&
!this.xtreamStore.isCancellingImport()
);
readonly xtreamImportTitleLabel = computed(() =>
this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_TITLE')
);
readonly xtreamImportTypeLabel = computed(() => {
this.languageTick();
return buildXtreamImportTypeLabel(
this.xtreamStore.activeImportContentType(),
this.translateText
);
});
readonly xtreamImportProgressLabel = computed(() =>
buildXtreamImportProgressLabel(
this.xtreamImportTypeLabel(),
this.xtreamActiveImportCount(),
this.xtreamActiveItemsToImport(),
this.translateText,
(value) =>
formatLocalizedNumber(
value,
this.translate.currentLang,
this.translate.defaultLang
)
)
);
readonly xtreamImportPhaseTone = computed<XtreamImportPhaseTone>(() =>
buildXtreamImportPhaseTone(this.xtreamStore.currentImportPhase())
);
readonly xtreamImportSourceLabel = computed(() => {
this.languageTick();
return buildXtreamImportSourceLabel(
this.xtreamImportPhaseTone(),
this.translateText
);
});
readonly xtreamImportPhaseLabel = computed(() => {
this.languageTick();
return buildXtreamImportPhaseLabel(
this.xtreamStore.currentImportPhase(),
this.translateText
);
});
readonly xtreamImportDetailLabel = computed(() => {
this.languageTick();
return buildXtreamImportDetailLabel(
this.xtreamImportPhaseTone(),
this.translateText
);
});
readonly isImportRunning = computed(
() =>
!this.xtreamStore.contentInitBlockReason() &&
this.xtreamStore.isImporting()
);
cancelXtreamImport(): void {
void this.xtreamStore.cancelImport();
}
}
@@ -25,6 +25,8 @@ import {
import { RecentCommandsService } from '../../recent-commands';
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
import { WorkspaceShellFacade } from './workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
class MockXtreamStore {
readonly recentItems = signal<unknown[]>([]);
@@ -193,6 +195,8 @@ describe('WorkspaceShellFacade', () => {
TestBed.configureTestingModule({
providers: [
WorkspaceShellFacade,
WorkspaceShellXtreamImportService,
WorkspaceShellCommandPaletteService,
{
provide: Router,
useValue: router,
File diff suppressed because it is too large. Load diff
@@ -15,6 +15,8 @@ import { WorkspaceShellContextSidebarComponent } from './components/workspace-sh
import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component';
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './services/workspace-shell-command-palette.service';
@Component({
selector: 'app-workspace-shell',
@@ -32,7 +34,11 @@ import { WorkspaceShellFacade } from './services/workspace-shell.facade';
],
templateUrl: './workspace-shell.component.html',
styleUrl: './workspace-shell.component.scss',
providers: [WorkspaceShellFacade],
providers: [
WorkspaceShellFacade,
WorkspaceShellXtreamImportService,
WorkspaceShellCommandPaletteService,
],
})
export class WorkspaceShellComponent {
readonly facade = inject(WorkspaceShellFacade);