refactor(workspace): split workspace shell facade

Split WorkspaceShellFacade into focused component-scoped services and keep the facade as the stable template-facing delegation layer.
This commit is contained in:
4gray authored and GitHub committed 2026-05-26 17:51:02 +02:00
1 parent 82c725ea5a
commit 1bbe8f794e
9 files changed
+1063 -779

No files matched your search

+32 -3
View File
@@ -22,9 +22,16 @@ Core implementation:
1. `apps/web/src/app/app.routes.ts`
2. `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts`
3. `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html`
4. `libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts`
5. `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts`
6. `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
4. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts`
5. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts`
6. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts`
7. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts`
8. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts`
9. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.ts`
10. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts`
11. `libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts`
12. `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts`
13. `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
## Route Contract
@@ -82,6 +89,28 @@ The shell is intentionally split into four persistent regions:
4. Optional footer:
1. External playback session bar when a docked session is visible.
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
services:
1. `WorkspaceShellRouteStateService` owns current route parsing, rail links,
context-panel state, dashboard startup preference, and playlist source
signals.
2. `WorkspaceShellSearchService` owns the route-aware header search capability
and public search actions.
3. `WorkspaceShellSearchSyncService` owns the search query signals, debounced
application, provider-store synchronization, and query-param sync.
4. `WorkspaceShellHeaderService` owns playlist title/subtitle, account/info
actions, refresh action state, and recent-items bulk cleanup.
5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle
and recent-command recording.
6. `WorkspaceShellXtreamImportService` owns Xtream import/refresh overlay
state and labels.
When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
for the template unless the template contract itself intentionally changes.
## Context Panel Rules
The shell decides which secondary panel to show from the current route:
@@ -0,0 +1,281 @@
import { computed, inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom, startWith } from 'rxjs';
import {
PlaylistInfoComponent,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/ui';
import {
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaylistsService } from '@iptvnator/services';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
WorkspaceAccountInfoData,
WorkspacePortalContext,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import {
CLEAR_RECENTLY_VIEWED_ARIA,
CLEAR_RECENTLY_VIEWED_TOOLTIP,
WorkspaceHeaderBulkAction,
} from './helpers/workspace-shell-constants';
import { bumpRefreshQueryParam } from './helpers/workspace-shell-route-utils';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
@Injectable()
export class WorkspaceShellHeaderService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly xtreamStore = inject(XtreamStore);
private readonly playlistsService = inject(PlaylistsService);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly translate = inject(TranslateService);
private readonly dialog = inject(MatDialog);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly playlistTitle = computed(() => {
const playlist = this.routeState.activePlaylist();
return (
playlist?.title ||
playlist?.filename ||
playlist?.url ||
playlist?.portalUrl ||
'Untitled playlist'
);
});
readonly headerShortcut = computed<WorkspaceHeaderAction | null>(() => {
const context = this.routeState.currentContext();
const action = this.headerContext.action();
if (!action || context?.provider !== 'playlists') {
return null;
}
return action;
});
readonly canOpenPlaylistInfo = computed(() =>
Boolean(this.routeState.activePlaylist())
);
readonly canOpenAccountInfo = computed(() =>
Boolean(this.routeState.activePlaylist()?.serverUrl)
);
readonly canRefreshPlaylist = computed(() =>
this.playlistRefreshAction.canRefresh(
this.routeState.activePlaylist()
)
);
readonly isRefreshingPlaylist = this.playlistRefreshAction.isRefreshing;
readonly headerBulkAction = computed<WorkspaceHeaderBulkAction | null>(
() => {
this.languageTick();
const context = this.routeState.currentContext();
const section = this.routeState.currentSection();
if (!context || section !== 'recent') {
return null;
}
if (
context.provider !== 'xtreams' &&
context.provider !== 'stalker' &&
context.provider !== 'playlists'
) {
return null;
}
return {
icon: 'delete_sweep',
tooltip: this.translateText(CLEAR_RECENTLY_VIEWED_TOOLTIP),
ariaLabel: this.translateText(CLEAR_RECENTLY_VIEWED_ARIA),
disabled: this.isRecentCleanupDisabled(context.provider),
};
}
);
readonly playlistSubtitle = computed(() => {
this.languageTick();
const active = this.routeState.activePlaylist();
if (active?.serverUrl) {
return this.translateText('WORKSPACE.SHELL.XTREAM_CODE');
}
if (active?.macAddress) {
return this.translateText('WORKSPACE.SHELL.STALKER_PORTAL');
}
if (active?.count) {
return this.translateText('WORKSPACE.SHELL.CHANNELS_COUNT', {
count: active.count,
});
}
const sourcesCount = this.routeState.playlists().length;
if (sourcesCount === 0) {
return this.translateText('WORKSPACE.SHELL.NO_SOURCES_AVAILABLE');
}
if (sourcesCount === 1) {
return this.translateText('WORKSPACE.SHELL.ONE_SOURCE_AVAILABLE');
}
return this.translateText('WORKSPACE.SHELL.SOURCES_AVAILABLE', {
count: sourcesCount,
});
});
openAddPlaylistDialog(kind?: 'url' | 'stalker' | 'xtream'): void {
if (kind) {
this.workspaceActions.openAddPlaylistDialog(kind);
return;
}
this.workspaceActions.openAddPlaylistDialog();
}
openGlobalSearch(initialQuery = ''): void {
this.workspaceActions.openGlobalSearch(initialQuery);
}
openGlobalRecent(): void {
this.workspaceActions.openGlobalRecent();
}
async runHeaderBulkAction(): Promise<void> {
const context = this.routeState.currentContext();
const section = this.routeState.currentSection();
if (!context || section !== 'recent') {
return;
}
if (context.provider === 'xtreams') {
this.xtreamStore.clearRecentItems({ id: context.playlistId });
return;
}
if (context.provider === 'stalker') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPortalRecentlyViewed(
context.playlistId
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: context.playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
})
);
bumpRefreshQueryParam(
this.router,
this.routeState.currentUrl()
);
return;
}
if (context.provider === 'playlists') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearM3uRecentlyViewed(
context.playlistId
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: context.playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
})
);
bumpRefreshQueryParam(
this.router,
this.routeState.currentUrl()
);
}
}
navigateToGlobalFavorites(): void {
void this.router.navigate(['/workspace/global-favorites']);
}
openDownloadsShortcut(): void {
void this.router.navigate(['/workspace/downloads']);
}
runHeaderShortcut(): void {
this.headerShortcut()?.run();
}
openPlaylistInfo(): void {
const playlist = this.routeState.activePlaylist();
if (!playlist) {
return;
}
this.dialog.open(PlaylistInfoComponent, {
data: playlist,
});
}
openAccountInfo(): void {
if (!this.canOpenAccountInfo()) {
return;
}
const data: WorkspaceAccountInfoData = {
vodStreamsCount: this.xtreamStore.vodStreams().length,
liveStreamsCount: this.xtreamStore.liveStreams().length,
seriesCount: this.xtreamStore.serialStreams().length,
};
this.workspaceActions.openAccountInfo(data);
}
refreshCurrentPlaylist(): void {
const playlist = this.routeState.activePlaylist();
if (!playlist || !this.canRefreshPlaylist()) {
return;
}
this.playlistRefreshAction.refresh(playlist);
}
private isRecentCleanupDisabled(
provider: WorkspacePortalContext['provider']
): boolean {
if (provider === 'xtreams') {
return this.xtreamStore.recentItems().length === 0;
}
if (provider === 'playlists') {
return (
this.routeState.activePlaylist()?.recentlyViewed?.length ?? 0
) === 0;
}
return false;
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}
@@ -0,0 +1,236 @@
import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { filter, startWith } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
buildPortalRailLinks,
PortalRailLink,
} from '@iptvnator/portal/shared/util';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
parseWorkspaceShellRoute,
WorkspacePortalContext,
WorkspaceStartupPreferencesService,
} from '@iptvnator/workspace/shell/util';
import { getProviderFromPlaylist } from './helpers/workspace-shell-route-utils';
import { translateRailLinks } from './helpers/workspace-shell-search-labels';
@Injectable()
export class WorkspaceShellRouteStateService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly startupPreferences = inject(
WorkspaceStartupPreferencesService
);
private readonly translate = inject(TranslateService);
private readonly destroyRef = inject(DestroyRef);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly activePlaylist = this.playlistContext.activePlaylist;
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
readonly currentUrl = signal(this.router.url);
readonly currentRoute = computed(() =>
parseWorkspaceShellRoute(this.currentUrl())
);
readonly currentContext = computed(() => this.currentRoute().context);
readonly currentSection = computed(() => this.currentRoute().section);
readonly showDashboard = computed(() =>
this.startupPreferences.showDashboard()
);
readonly brandLink = computed(() =>
this.startupPreferences.getFirstAvailableWorkspacePath(
this.showDashboard()
)
);
readonly brandTooltipKey = computed(() =>
this.showDashboard()
? 'WORKSPACE.SHELL.RAIL_DASHBOARD'
: 'WORKSPACE.SHELL.RAIL_SOURCES'
);
readonly brandAriaLabelKey = computed(() =>
this.showDashboard()
? 'WORKSPACE.SHELL.OPEN_DASHBOARD'
: 'WORKSPACE.SHELL.OPEN_SOURCES'
);
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
const links: PortalRailLink[] = [];
if (this.showDashboard()) {
links.push({
icon: 'dashboard',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_DASHBOARD'),
path: ['/workspace/dashboard'],
exact: true,
});
}
links.push({
icon: 'library_books',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_SOURCES'),
path: ['/workspace/sources'],
});
links.push({
icon: 'favorite',
tooltip: this.translateText('HOME.PLAYLISTS.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(
() => this.currentRoute().kind === 'dashboard'
);
readonly isSourcesRoute = computed(
() => this.currentRoute().kind === 'sources'
);
readonly isSettingsRoute = computed(
() => this.currentRoute().kind === 'settings'
);
readonly isGlobalDownloadsRoute = computed(
() => this.currentRoute().kind === 'downloads'
);
readonly railContext = computed<WorkspacePortalContext | null>(() => {
const routeContext = this.currentContext();
if (routeContext) {
return routeContext;
}
const currentRoute = this.currentRoute();
if (
currentRoute.kind !== 'dashboard' &&
currentRoute.kind !== 'sources' &&
currentRoute.kind !== 'settings' &&
currentRoute.kind !== 'global-favorites' &&
currentRoute.kind !== 'global-recent' &&
currentRoute.kind !== 'downloads'
) {
return null;
}
const activePlaylist = this.activePlaylist();
if (!activePlaylist?._id) {
return null;
}
return {
provider: getProviderFromPlaylist(activePlaylist),
playlistId: activePlaylist._id,
};
});
readonly dashboardXtreamContext = computed<WorkspacePortalContext | null>(
() => {
if (!this.isDashboardRoute()) {
return null;
}
const context = this.railContext();
if (!context || context.provider !== 'xtreams') {
return null;
}
return context;
}
);
readonly contextPanel = computed(() => this.currentRoute().contextPanel);
readonly showContextPanel = computed(
() => this.currentRoute().contextPanel !== 'none'
);
readonly railProviderClass = computed(() => {
const context = this.railContext();
if (!context) {
return 'rail-context-region';
}
return `rail-context-region rail-context-region--${context.provider}`;
});
readonly primaryContextLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
const context = this.railContext();
if (!context) {
return [];
}
return translateRailLinks(
buildPortalRailLinks({
provider: context.provider,
playlistId: context.playlistId,
supportsDownloads: this.runtime.supportsDownloads,
workspace: true,
}).primary,
context.provider,
(key, params) => this.translateText(key, params)
);
});
readonly secondaryContextLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
const context = this.railContext();
if (!context) {
return [];
}
return translateRailLinks(
buildPortalRailLinks({
provider: context.provider,
playlistId: context.playlistId,
supportsDownloads: this.runtime.supportsDownloads,
workspace: true,
}).secondary.filter((link) => link.section !== 'downloads'),
context.provider,
(key, params) => this.translateText(key, params)
);
});
readonly isDownloadsView = computed(
() =>
this.currentSection() === 'downloads' ||
this.isGlobalDownloadsRoute()
);
constructor() {
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((event) => {
this.currentUrl.set(event.urlAfterRedirects);
this.startupPreferences.persistLastRestorablePath(
event.urlAfterRedirects
);
});
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}
@@ -0,0 +1,148 @@
import { DestroyRef, effect, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
import {
getRouteQueryParam,
syncSearchQueryParam,
} from './helpers/workspace-shell-route-utils';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
@Injectable()
export class WorkspaceShellSearchSyncService {
private readonly router = inject(Router);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly destroyRef = inject(DestroyRef);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private searchDebounceTimeoutId: ReturnType<typeof setTimeout> | null =
null;
readonly searchQuery = signal('');
readonly appliedSearchQuery = signal('');
constructor() {
this.destroyRef.onDestroy(() => {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
this.searchDebounceTimeoutId = null;
}
});
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((event) =>
this.syncSearchFromUrl(event.urlAfterRedirects)
);
this.syncSearchFromRoute();
effect(() => {
const context = this.routeState.currentContext();
const section = this.routeState.currentSection();
const term = this.appliedSearchQuery();
if (!context || context.provider !== 'xtreams') {
return;
}
if (section === 'search') {
this.xtreamStore.setSearchTerm(term);
return;
}
if (
section === 'vod' ||
section === 'series' ||
section === 'live'
) {
this.xtreamStore.setCategorySearchTerm(term);
}
});
effect(() => {
const context = this.routeState.currentContext();
const section = this.routeState.currentSection();
const term = this.appliedSearchQuery();
if (
context?.provider !== 'stalker' ||
!section ||
(section !== 'vod' &&
section !== 'series' &&
section !== 'itv' &&
section !== 'radio')
) {
return;
}
this.stalkerStore.setSearchPhrase(term);
});
effect(() => {
if (!this.routeState.currentRoute().usesQuerySearch) {
return;
}
syncSearchQueryParam(
this.router,
this.routeState.currentUrl(),
this.appliedSearchQuery()
);
});
}
onSearchInput(value: string): void {
this.searchQuery.set(value);
this.scheduleSearchApply(value);
}
syncSearchFromRoute(): void {
this.syncSearchFromUrl(this.routeState.currentUrl());
}
setSearchState(value: string): void {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
this.searchDebounceTimeoutId = null;
}
this.searchQuery.set(value);
this.appliedSearchQuery.set(value);
}
applySearchQuery(value: string): void {
this.appliedSearchQuery.set(value);
}
private syncSearchFromUrl(url: string): void {
if (parseWorkspaceShellRoute(url).usesQuerySearch) {
this.setSearchState(getRouteQueryParam(this.router, url, 'q'));
return;
}
this.setSearchState('');
}
private scheduleSearchApply(value: string): void {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
}
this.searchDebounceTimeoutId = setTimeout(() => {
this.searchDebounceTimeoutId = null;
this.applySearchQuery(value);
}, SEARCH_INPUT_DEBOUNCE_MS);
}
}
@@ -0,0 +1,221 @@
import { computed, inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
import {
SEARCH_LOADED_ONLY_STATUS,
SEARCH_PLAYLIST_PLACEHOLDER,
} from './helpers/workspace-shell-constants';
import {
resolveSearchPlaceholderKey,
resolveSearchScopeLabel,
} from './helpers/workspace-shell-search-labels';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
@Injectable()
export class WorkspaceShellSearchService {
private readonly router = inject(Router);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly searchQuery = this.searchSync.searchQuery;
readonly appliedSearchQuery = this.searchSync.appliedSearchQuery;
readonly searchCapability = computed<WorkspaceSearchCapability>(() => {
this.languageTick();
const route = this.routeState.currentRoute();
const context = route.context;
const section = route.section;
const appliedQuery = this.appliedSearchQuery().trim();
if (route.kind === 'settings') {
return {
enabled: false,
behavior: 'disabled',
context: null,
section: null,
searchMode: 'none',
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
scopeLabel: '',
statusLabel: '',
minLength: 0,
advancedRouteTarget: null,
};
}
if (route.kind === 'dashboard') {
const dashboardContext = this.routeState.dashboardXtreamContext();
return {
enabled: Boolean(dashboardContext),
behavior: dashboardContext ? 'advanced-only' : 'disabled',
context: dashboardContext,
section: section,
searchMode: dashboardContext ? 'advanced-only' : 'none',
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
scopeLabel: dashboardContext
? this.translateText('WORKSPACE.SHELL.RAIL_SEARCH')
: '',
statusLabel: '',
minLength: dashboardContext ? 1 : 0,
advancedRouteTarget: dashboardContext
? [
'/workspace',
'xtreams',
dashboardContext.playlistId,
'search',
]
: null,
};
}
if (route.searchMode === 'none') {
return {
enabled: false,
behavior: 'disabled',
context,
section,
searchMode: route.searchMode,
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
scopeLabel: '',
statusLabel: '',
minLength: 0,
advancedRouteTarget: null,
};
}
const isDegradedStalkerItv =
context?.provider === 'stalker' &&
section === 'itv' &&
appliedQuery.length > 0;
const behavior = isDegradedStalkerItv
? 'degraded-loaded-only'
: route.searchMode;
return {
enabled: true,
behavior,
context,
section,
searchMode: route.searchMode,
placeholderKey: resolveSearchPlaceholderKey(
route.kind,
context,
section
),
scopeLabel: resolveSearchScopeLabel({
kind: route.kind,
context,
section,
translate: (key, params) => this.translateText(key, params),
xtreamCategory: this.xtreamStore.getSelectedCategory(),
stalkerCategoryName:
this.stalkerStore.getSelectedCategoryName(),
}),
statusLabel: isDegradedStalkerItv
? this.translateText(SEARCH_LOADED_ONLY_STATUS)
: '',
minLength: route.searchMode === 'remote-search' ? 3 : 1,
advancedRouteTarget: null,
};
});
readonly canUseSearch = computed(() => this.searchCapability().enabled);
readonly searchPlaceholder = computed(
() => this.searchCapability().placeholderKey
);
readonly searchScopeLabel = computed(
() => this.searchCapability().scopeLabel
);
readonly searchStatusLabel = computed(
() => this.searchCapability().statusLabel
);
onSearchInput(value: string): void {
this.searchSync.onSearchInput(value);
}
onSearchEnter(value: string): void {
const trimmedValue = value.trim();
this.searchQuery.set(trimmedValue);
if (this.searchCapability().behavior === 'advanced-only') {
const advancedRouteTarget =
this.searchCapability().advancedRouteTarget;
if (!advancedRouteTarget) {
this.searchSync.applySearchQuery(trimmedValue);
return;
}
this.xtreamStore.setSearchTerm(trimmedValue);
this.searchSync.applySearchQuery(trimmedValue);
void this.router.navigate(advancedRouteTarget, {
queryParams: trimmedValue ? { q: trimmedValue } : {},
});
return;
}
this.searchSync.applySearchQuery(trimmedValue);
}
openPlaylistSearchFromPalette(query: string): void {
const effectiveContext =
this.routeState.dashboardXtreamContext() ??
this.routeState.currentContext();
if (!effectiveContext) {
return;
}
this.searchSync.setSearchState(query);
if (effectiveContext.provider === 'xtreams') {
this.xtreamStore.setSearchTerm(query);
void this.router.navigate(
[
'/workspace',
'xtreams',
effectiveContext.playlistId,
'search',
],
{
queryParams: query ? { q: query } : {},
}
);
return;
}
if (effectiveContext.provider === 'stalker') {
void this.router.navigate(
[
'/workspace',
'stalker',
effectiveContext.playlistId,
'search',
],
{
queryParams: query ? { q: query } : {},
}
);
}
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}
@@ -17,6 +17,7 @@ import {
buildXtreamRefreshPreparationProgressLabel,
formatLocalizedNumber,
} from './helpers/workspace-shell-import-labels';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
@Injectable()
export class WorkspaceShellXtreamImportService {
@@ -26,6 +27,7 @@ export class WorkspaceShellXtreamImportService {
);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly translate = inject(TranslateService);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -69,6 +71,32 @@ export class WorkspaceShellXtreamImportService {
Boolean(this.xtreamStore.activeImportSessionId()) &&
!this.xtreamStore.isCancellingImport()
);
readonly showXtreamImportOverlay = computed(() => {
const route = this.routeState.currentRoute();
const context = this.routeState.currentContext();
const section = this.routeState.currentSection();
const hasRefreshPreparation = Boolean(this.refreshPreparation());
if (route.kind === 'dashboard') {
return hasRefreshPreparation;
}
if (context?.provider !== 'xtreams') {
return false;
}
const isPreparingCurrentPlaylist =
this.isRefreshPreparationRunningForPlaylist(context.playlistId);
return (
(this.isImportRunning() || isPreparingCurrentPlaylist) &&
(section === 'vod' ||
section === 'live' ||
section === 'series' ||
section === 'search' ||
section === 'recently-added')
);
});
readonly xtreamImportTitleLabel = computed(() => {
if (this.activeRefreshPreparation()) {
@@ -33,6 +33,10 @@ import { WorkspacePlayerCommandsContributor } from '../../workspace-player-comma
import { WorkspaceShellFacade } from './workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
import { WorkspaceShellHeaderService } from './workspace-shell-header.service';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
class MockXtreamStore {
readonly recentItems = signal<unknown[]>([]);
@@ -101,6 +105,7 @@ describe('WorkspaceShellFacade', () => {
};
let facade: WorkspaceShellFacade;
let searchSync: WorkspaceShellSearchSyncService;
let recentCommands: {
entries: jest.Mock;
record: jest.Mock;
@@ -219,6 +224,10 @@ describe('WorkspaceShellFacade', () => {
TestBed.configureTestingModule({
providers: [
WorkspaceShellFacade,
WorkspaceShellRouteStateService,
WorkspaceShellSearchSyncService,
WorkspaceShellSearchService,
WorkspaceShellHeaderService,
WorkspaceShellXtreamImportService,
WorkspaceShellCommandPaletteService,
{
@@ -346,6 +355,7 @@ describe('WorkspaceShellFacade', () => {
});
facade = TestBed.inject(WorkspaceShellFacade);
searchSync = TestBed.inject(WorkspaceShellSearchSyncService);
});
it('derives desktop shell flags from runtime capabilities', () => {
@@ -568,7 +578,7 @@ describe('WorkspaceShellFacade', () => {
it('exposes loaded-only status for stalker itv searches', () => {
facade.currentUrl.set('/workspace/stalker/pl-1/itv?q=cnn');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
searchSync.syncSearchFromRoute();
TestBed.flushEffects();
expect(stalkerStore.setSearchPhrase).toHaveBeenCalledWith('cnn');
@@ -580,7 +590,7 @@ describe('WorkspaceShellFacade', () => {
it('treats stalker radio search as a remote section search', () => {
facade.currentUrl.set('/workspace/stalker/pl-1/radio?q=jazz');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
searchSync.syncSearchFromRoute();
TestBed.flushEffects();
expect(stalkerStore.setSearchPhrase).toHaveBeenCalledWith('jazz');
@@ -597,7 +607,7 @@ describe('WorkspaceShellFacade', () => {
) as unknown as MockXtreamStore;
facade.currentUrl.set('/workspace/xtreams/pl-1/vod?q=neo');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
searchSync.syncSearchFromRoute();
TestBed.flushEffects();
expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith('neo');
@@ -610,7 +620,7 @@ describe('WorkspaceShellFacade', () => {
) as unknown as MockXtreamStore;
facade.currentUrl.set('/workspace/xtreams/pl-1/live?q=world');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
searchSync.syncSearchFromRoute();
TestBed.flushEffects();
expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith('world');
@@ -619,7 +629,7 @@ describe('WorkspaceShellFacade', () => {
it('enables local-filter search on playlist favorites routes', () => {
facade.currentUrl.set('/workspace/playlists/pl-1/favorites?q=news');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
searchSync.syncSearchFromRoute();
expect(facade.canUseSearch()).toBe(true);
expect(facade.searchQuery()).toBe('news');
@@ -627,7 +637,7 @@ describe('WorkspaceShellFacade', () => {
it('uses the translated global favorites scope label on the global favorites route', () => {
facade.currentUrl.set('/workspace/global-favorites?q=news');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
searchSync.syncSearchFromRoute();
expect(facade.searchScopeLabel()).toBe(
'HOME.PLAYLISTS.GLOBAL_FAVORITES'
@@ -667,7 +677,7 @@ describe('WorkspaceShellFacade', () => {
it('uses the translated recent scope label on the global recent route', () => {
facade.currentUrl.set('/workspace/global-recent?q=news');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
searchSync.syncSearchFromRoute();
expect(facade.searchScopeLabel()).toBe('PORTALS.RECENTLY_VIEWED');
});
@@ -1,114 +1,52 @@
import {
computed,
DestroyRef,
effect,
inject,
Injectable,
signal,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { computed, DestroyRef, inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { filter, firstValueFrom, startWith } from 'rxjs';
import { startWith } from 'rxjs';
import {
PlaylistInfoComponent,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/ui';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
buildPortalRailLinks,
PORTAL_EXTERNAL_PLAYBACK,
PortalRailLink,
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
WorkspaceResolvedCommandItem,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
parseWorkspaceShellRoute,
WorkspacePortalContext,
WorkspaceSearchCapability,
WorkspaceStartupPreferencesService,
} from '@iptvnator/workspace/shell/util';
import {
DownloadsService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
WorkspaceAccountInfoData,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import {
CLEAR_RECENTLY_VIEWED_ARIA,
CLEAR_RECENTLY_VIEWED_TOOLTIP,
SEARCH_INPUT_DEBOUNCE_MS,
SEARCH_LOADED_ONLY_STATUS,
SEARCH_PLAYLIST_PLACEHOLDER,
WorkspaceHeaderBulkAction,
} from './helpers/workspace-shell-constants';
import {
bumpRefreshQueryParam,
getProviderFromPlaylist,
getRouteQueryParam,
syncSearchQueryParam,
} from './helpers/workspace-shell-route-utils';
import {
resolveSearchPlaceholderKey,
resolveSearchScopeLabel,
translateRailLinks,
} from './helpers/workspace-shell-search-labels';
import {
CommandBuilderActions,
CommandBuilderContext,
} from './helpers/workspace-shell-command-builders';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
import { WorkspaceShellHeaderService } from './workspace-shell-header.service';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
export type { WorkspaceHeaderBulkAction } from './helpers/workspace-shell-constants';
@Injectable()
export class WorkspaceShellFacade {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly destroyRef = inject(DestroyRef);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly settingsStore = inject(SettingsStore);
private readonly playlistsService = inject(PlaylistsService);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly translate = inject(TranslateService);
private readonly dialog = inject(MatDialog);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly startupPreferences = inject(
WorkspaceStartupPreferencesService
);
readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly commandPalette = inject(
WorkspaceShellCommandPaletteService
);
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly downloadsService = inject(DownloadsService);
readonly hasActiveDownloads = computed(
() => this.supportsDownloads && this.downloadsService.activeCount() > 0
);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly search = inject(WorkspaceShellSearchService);
private readonly header = inject(WorkspaceShellHeaderService);
private readonly xtreamImport = inject(WorkspaceShellXtreamImportService);
readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private searchDebounceTimeoutId: ReturnType<typeof setTimeout> | null =
null;
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) {
return;
@@ -120,25 +58,71 @@ export class WorkspaceShellFacade {
}
};
readonly playlistTitle = computed(() => {
const playlist = this.playlistContext.activePlaylist();
return (
playlist?.title ||
playlist?.filename ||
playlist?.url ||
playlist?.portalUrl ||
'Untitled playlist'
);
});
private readonly activePlaylist = this.playlistContext.activePlaylist;
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
readonly activePlaylist = this.routeState.activePlaylist;
readonly playlists = this.routeState.playlists;
readonly playlistTitle = this.header.playlistTitle;
readonly hasNoPlaylists = this.routeState.hasNoPlaylists;
readonly searchQuery = this.search.searchQuery;
readonly appliedSearchQuery = this.search.appliedSearchQuery;
readonly currentUrl = this.routeState.currentUrl;
readonly currentRoute = this.routeState.currentRoute;
readonly showDashboard = this.routeState.showDashboard;
readonly brandLink = this.routeState.brandLink;
readonly brandTooltipKey = this.routeState.brandTooltipKey;
readonly brandAriaLabelKey = this.routeState.brandAriaLabelKey;
readonly currentContext = this.routeState.currentContext;
readonly currentSection = this.routeState.currentSection;
readonly commandPaletteCommands = computed<WorkspaceResolvedCommandItem[]>(
() => {
this.languageTick();
return this.commandPalette.buildPaletteCommands(
this.makeCommandBuilderContext()
);
}
);
readonly workspaceLinks = this.routeState.workspaceLinks;
readonly isDashboardRoute = this.routeState.isDashboardRoute;
readonly isSourcesRoute = this.routeState.isSourcesRoute;
readonly isSettingsRoute = this.routeState.isSettingsRoute;
readonly isGlobalDownloadsRoute = this.routeState.isGlobalDownloadsRoute;
readonly railContext = this.routeState.railContext;
readonly externalPlaybackSession = this.externalPlayback.visibleSession;
readonly showExternalPlaybackBar = computed(
() => this.settingsStore.showExternalPlaybackBar?.() ?? true
);
readonly dashboardXtreamContext = this.routeState.dashboardXtreamContext;
readonly contextPanel = this.routeState.contextPanel;
readonly showContextPanel = this.routeState.showContextPanel;
readonly showXtreamImportOverlay =
this.xtreamImport.showXtreamImportOverlay;
readonly searchCapability = this.search.searchCapability;
readonly canUseSearch = this.search.canUseSearch;
readonly searchPlaceholder = this.search.searchPlaceholder;
readonly searchScopeLabel = this.search.searchScopeLabel;
readonly searchStatusLabel = this.search.searchStatusLabel;
readonly railProviderClass = this.routeState.railProviderClass;
readonly primaryContextLinks = this.routeState.primaryContextLinks;
readonly secondaryContextLinks = this.routeState.secondaryContextLinks;
readonly isDownloadsView = this.routeState.isDownloadsView;
readonly headerShortcut = this.header.headerShortcut;
readonly canOpenPlaylistInfo = this.header.canOpenPlaylistInfo;
readonly canOpenAccountInfo = this.header.canOpenAccountInfo;
readonly canRefreshPlaylist = this.header.canRefreshPlaylist;
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
readonly headerBulkAction = this.header.headerBulkAction;
readonly playlistSubtitle = this.header.playlistSubtitle;
readonly hasActiveDownloads = computed(
() => this.supportsDownloads && this.downloadsService.activeCount() > 0
);
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
readonly searchQuery = signal('');
readonly appliedSearchQuery = signal('');
constructor() {
this.destroyRef.onDestroy(() => {
document.removeEventListener('keydown', this.onDocumentKeydown);
});
document.addEventListener('keydown', this.onDocumentKeydown);
}
get isElectron(): boolean {
return this.runtime.isElectron;
}
@@ -151,488 +135,6 @@ export class WorkspaceShellFacade {
return this.runtime.supportsDownloads;
}
readonly currentUrl = signal(this.router.url);
readonly currentRoute = computed(() =>
parseWorkspaceShellRoute(this.currentUrl())
);
readonly showDashboard = computed(() =>
this.startupPreferences.showDashboard()
);
readonly brandLink = computed(() =>
this.startupPreferences.getFirstAvailableWorkspacePath(
this.showDashboard()
)
);
readonly brandTooltipKey = computed(() =>
this.showDashboard()
? 'WORKSPACE.SHELL.RAIL_DASHBOARD'
: 'WORKSPACE.SHELL.RAIL_SOURCES'
);
readonly brandAriaLabelKey = computed(() =>
this.showDashboard()
? 'WORKSPACE.SHELL.OPEN_DASHBOARD'
: 'WORKSPACE.SHELL.OPEN_SOURCES'
);
readonly currentContext = computed(() => this.currentRoute().context);
readonly currentSection = computed(() => this.currentRoute().section);
readonly commandPaletteCommands = computed<WorkspaceResolvedCommandItem[]>(
() => {
this.languageTick();
return this.commandPalette.buildPaletteCommands(
this.makeCommandBuilderContext()
);
}
);
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
const links: PortalRailLink[] = [];
if (this.showDashboard()) {
links.push({
icon: 'dashboard',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_DASHBOARD'),
path: ['/workspace/dashboard'],
exact: true,
});
}
links.push({
icon: 'library_books',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_SOURCES'),
path: ['/workspace/sources'],
});
links.push({
icon: 'favorite',
tooltip: this.translateText('HOME.PLAYLISTS.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(
() => this.currentRoute().kind === 'dashboard'
);
readonly isSourcesRoute = computed(
() => this.currentRoute().kind === 'sources'
);
readonly isSettingsRoute = computed(
() => this.currentRoute().kind === 'settings'
);
readonly isGlobalDownloadsRoute = computed(
() => this.currentRoute().kind === 'downloads'
);
readonly railContext = computed<WorkspacePortalContext | null>(() => {
const routeContext = this.currentContext();
if (routeContext) {
return routeContext;
}
const currentRoute = this.currentRoute();
if (
currentRoute.kind !== 'dashboard' &&
currentRoute.kind !== 'sources' &&
currentRoute.kind !== 'settings' &&
currentRoute.kind !== 'global-favorites' &&
currentRoute.kind !== 'global-recent' &&
currentRoute.kind !== 'downloads'
) {
return null;
}
const activePlaylist = this.activePlaylist();
if (!activePlaylist?._id) {
return null;
}
return {
provider: getProviderFromPlaylist(activePlaylist),
playlistId: activePlaylist._id,
};
});
readonly externalPlaybackSession = this.externalPlayback.visibleSession;
readonly showExternalPlaybackBar = computed(
() => this.settingsStore.showExternalPlaybackBar?.() ?? true
);
readonly dashboardXtreamContext = computed<WorkspacePortalContext | null>(
() => {
if (!this.isDashboardRoute()) {
return null;
}
const context = this.railContext();
if (!context || context.provider !== 'xtreams') {
return null;
}
return context;
}
);
readonly contextPanel = computed(() => this.currentRoute().contextPanel);
readonly showContextPanel = computed(
() => this.currentRoute().contextPanel !== 'none'
);
private readonly xtreamImport = inject(WorkspaceShellXtreamImportService);
readonly showXtreamImportOverlay = computed(() => {
const route = this.currentRoute();
const context = this.currentContext();
const section = this.currentSection();
const hasRefreshPreparation = Boolean(
this.xtreamImport.refreshPreparation()
);
if (route.kind === 'dashboard') {
return hasRefreshPreparation;
}
if (context?.provider !== 'xtreams') {
return false;
}
const isPreparingCurrentPlaylist =
this.xtreamImport.isRefreshPreparationRunningForPlaylist(
context.playlistId
);
return (
(this.xtreamImport.isImportRunning() ||
isPreparingCurrentPlaylist) &&
(section === 'vod' ||
section === 'live' ||
section === 'series' ||
section === 'search' ||
section === 'recently-added')
);
});
readonly searchCapability = computed<WorkspaceSearchCapability>(() => {
this.languageTick();
const route = this.currentRoute();
const context = route.context;
const section = route.section;
const appliedQuery = this.appliedSearchQuery().trim();
if (route.kind === 'settings') {
return {
enabled: false,
behavior: 'disabled',
context: null,
section: null,
searchMode: 'none',
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
scopeLabel: '',
statusLabel: '',
minLength: 0,
advancedRouteTarget: null,
};
}
if (route.kind === 'dashboard') {
const dashboardContext = this.dashboardXtreamContext();
return {
enabled: Boolean(dashboardContext),
behavior: dashboardContext ? 'advanced-only' : 'disabled',
context: dashboardContext,
section: section,
searchMode: dashboardContext ? 'advanced-only' : 'none',
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
scopeLabel: dashboardContext
? this.translateText('WORKSPACE.SHELL.RAIL_SEARCH')
: '',
statusLabel: '',
minLength: dashboardContext ? 1 : 0,
advancedRouteTarget: dashboardContext
? [
'/workspace',
'xtreams',
dashboardContext.playlistId,
'search',
]
: null,
};
}
if (route.searchMode === 'none') {
return {
enabled: false,
behavior: 'disabled',
context,
section,
searchMode: route.searchMode,
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
scopeLabel: '',
statusLabel: '',
minLength: 0,
advancedRouteTarget: null,
};
}
const isDegradedStalkerItv =
context?.provider === 'stalker' &&
section === 'itv' &&
appliedQuery.length > 0;
const behavior = isDegradedStalkerItv
? 'degraded-loaded-only'
: route.searchMode;
return {
enabled: true,
behavior,
context,
section,
searchMode: route.searchMode,
placeholderKey: resolveSearchPlaceholderKey(
route.kind,
context,
section
),
scopeLabel: resolveSearchScopeLabel({
kind: route.kind,
context,
section,
translate: (key, params) => this.translateText(key, params),
xtreamCategory: this.xtreamStore.getSelectedCategory(),
stalkerCategoryName:
this.stalkerStore.getSelectedCategoryName(),
}),
statusLabel: isDegradedStalkerItv
? this.translateText(SEARCH_LOADED_ONLY_STATUS)
: '',
minLength: route.searchMode === 'remote-search' ? 3 : 1,
advancedRouteTarget: null,
};
});
readonly canUseSearch = computed(() => this.searchCapability().enabled);
readonly searchPlaceholder = computed(
() => this.searchCapability().placeholderKey
);
readonly searchScopeLabel = computed(
() => this.searchCapability().scopeLabel
);
readonly searchStatusLabel = computed(
() => this.searchCapability().statusLabel
);
readonly railProviderClass = computed(() => {
const context = this.railContext();
if (!context) {
return 'rail-context-region';
}
return `rail-context-region rail-context-region--${context.provider}`;
});
readonly primaryContextLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
const context = this.railContext();
if (!context) {
return [];
}
return translateRailLinks(
buildPortalRailLinks({
provider: context.provider,
playlistId: context.playlistId,
supportsDownloads: this.supportsDownloads,
workspace: true,
}).primary,
context.provider,
(key, params) => this.translateText(key, params)
);
});
readonly secondaryContextLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
const context = this.railContext();
if (!context) {
return [];
}
return translateRailLinks(
buildPortalRailLinks({
provider: context.provider,
playlistId: context.playlistId,
supportsDownloads: this.supportsDownloads,
workspace: true,
}).secondary.filter((link) => link.section !== 'downloads'),
context.provider,
(key, params) => this.translateText(key, params)
);
});
readonly isDownloadsView = computed(
() =>
this.currentSection() === 'downloads' ||
this.isGlobalDownloadsRoute()
);
readonly headerShortcut = computed<WorkspaceHeaderAction | null>(() => {
const context = this.currentContext();
const action = this.headerContext.action();
if (!action || context?.provider !== 'playlists') {
return null;
}
return action;
});
readonly canOpenPlaylistInfo = computed(() =>
Boolean(this.activePlaylist())
);
readonly canOpenAccountInfo = computed(() =>
Boolean(this.activePlaylist()?.serverUrl)
);
readonly canRefreshPlaylist = computed(() =>
this.playlistRefreshAction.canRefresh(this.activePlaylist())
);
readonly isRefreshingPlaylist = this.playlistRefreshAction.isRefreshing;
readonly headerBulkAction = computed<WorkspaceHeaderBulkAction | null>(
() => {
this.languageTick();
const context = this.currentContext();
const section = this.currentSection();
if (!context || section !== 'recent') {
return null;
}
if (
context.provider !== 'xtreams' &&
context.provider !== 'stalker' &&
context.provider !== 'playlists'
) {
return null;
}
return {
icon: 'delete_sweep',
tooltip: this.translateText(CLEAR_RECENTLY_VIEWED_TOOLTIP),
ariaLabel: this.translateText(CLEAR_RECENTLY_VIEWED_ARIA),
disabled: this.isRecentCleanupDisabled(context.provider),
};
}
);
readonly playlistSubtitle = computed(() => {
this.languageTick();
const active = this.activePlaylist();
if (active?.serverUrl) {
return this.translateText('WORKSPACE.SHELL.XTREAM_CODE');
}
if (active?.macAddress) {
return this.translateText('WORKSPACE.SHELL.STALKER_PORTAL');
}
if (active?.count) {
return this.translateText('WORKSPACE.SHELL.CHANNELS_COUNT', {
count: active.count,
});
}
const sourcesCount = this.playlists().length;
if (sourcesCount === 0) {
return this.translateText('WORKSPACE.SHELL.NO_SOURCES_AVAILABLE');
}
if (sourcesCount === 1) {
return this.translateText('WORKSPACE.SHELL.ONE_SOURCE_AVAILABLE');
}
return this.translateText('WORKSPACE.SHELL.SOURCES_AVAILABLE', {
count: sourcesCount,
});
});
constructor() {
this.destroyRef.onDestroy(() => {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
this.searchDebounceTimeoutId = null;
}
document.removeEventListener('keydown', this.onDocumentKeydown);
});
document.addEventListener('keydown', this.onDocumentKeydown);
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((event) => {
this.currentUrl.set(event.urlAfterRedirects);
this.startupPreferences.persistLastRestorablePath(
event.urlAfterRedirects
);
this.syncSearchFromRoute();
});
this.syncSearchFromRoute();
effect(() => {
const context = this.currentContext();
const section = this.currentSection();
const term = this.appliedSearchQuery();
if (!context || context.provider !== 'xtreams') {
return;
}
if (section === 'search') {
this.xtreamStore.setSearchTerm(term);
return;
}
if (
section === 'vod' ||
section === 'series' ||
section === 'live'
) {
this.xtreamStore.setCategorySearchTerm(term);
}
});
effect(() => {
const context = this.currentContext();
const section = this.currentSection();
const term = this.appliedSearchQuery();
if (
context?.provider !== 'stalker' ||
!section ||
(section !== 'vod' &&
section !== 'series' &&
section !== 'itv' &&
section !== 'radio')
) {
return;
}
this.stalkerStore.setSearchPhrase(term);
});
effect(() => {
if (!this.currentRoute().usesQuerySearch) {
return;
}
syncSearchQueryParam(
this.router,
this.currentUrl(),
this.appliedSearchQuery()
);
});
}
closeActiveExternalSession(): void {
void this.externalPlayback.closeSession(
this.externalPlayback.activeSession()
@@ -657,35 +159,15 @@ export class WorkspaceShellFacade {
}
onSearchInput(value: string): void {
this.searchQuery.set(value);
this.scheduleSearchApply(value);
this.search.onSearchInput(value);
}
onSearchEnter(value: string): void {
const trimmedValue = value.trim();
this.searchQuery.set(trimmedValue);
if (this.searchCapability().behavior === 'advanced-only') {
const advancedRouteTarget =
this.searchCapability().advancedRouteTarget;
if (!advancedRouteTarget) {
this.applySearchQuery(trimmedValue);
return;
}
this.xtreamStore.setSearchTerm(trimmedValue);
this.applySearchQuery(trimmedValue);
void this.router.navigate(advancedRouteTarget, {
queryParams: trimmedValue ? { q: trimmedValue } : {},
});
return;
}
this.applySearchQuery(trimmedValue);
this.search.onSearchEnter(value);
}
openAddPlaylistDialog(): void {
this.workspaceActions.openAddPlaylistDialog();
this.header.openAddPlaylistDialog();
}
openCommandPalette(): void {
@@ -695,105 +177,40 @@ export class WorkspaceShellFacade {
);
}
async runHeaderBulkAction(): Promise<void> {
const context = this.currentContext();
const section = this.currentSection();
if (!context || section !== 'recent') {
return;
}
if (context.provider === 'xtreams') {
this.xtreamStore.clearRecentItems({ id: context.playlistId });
return;
}
if (context.provider === 'stalker') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPortalRecentlyViewed(
context.playlistId
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: context.playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
})
);
bumpRefreshQueryParam(this.router, this.currentUrl());
return;
}
if (context.provider === 'playlists') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearM3uRecentlyViewed(context.playlistId)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: context.playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
})
);
bumpRefreshQueryParam(this.router, this.currentUrl());
}
runHeaderBulkAction(): Promise<void> {
return this.header.runHeaderBulkAction();
}
navigateToGlobalFavorites(): void {
void this.router.navigate(['/workspace/global-favorites']);
this.header.navigateToGlobalFavorites();
}
openDownloadsShortcut(): void {
void this.router.navigate(['/workspace/downloads']);
this.header.openDownloadsShortcut();
}
runHeaderShortcut(): void {
this.headerShortcut()?.run();
this.header.runHeaderShortcut();
}
openGlobalSearch(initialQuery = ''): void {
this.workspaceActions.openGlobalSearch(initialQuery);
this.header.openGlobalSearch(initialQuery);
}
openGlobalRecent(): void {
this.workspaceActions.openGlobalRecent();
this.header.openGlobalRecent();
}
openPlaylistInfo(): void {
const playlist = this.activePlaylist();
if (!playlist) {
return;
}
this.dialog.open(PlaylistInfoComponent, {
data: playlist,
});
this.header.openPlaylistInfo();
}
openAccountInfo(): void {
if (!this.canOpenAccountInfo()) {
return;
}
const data: WorkspaceAccountInfoData = {
vodStreamsCount: this.xtreamStore.vodStreams().length,
liveStreamsCount: this.xtreamStore.liveStreams().length,
seriesCount: this.xtreamStore.serialStreams().length,
};
this.workspaceActions.openAccountInfo(data);
this.header.openAccountInfo();
}
refreshCurrentPlaylist(): void {
const playlist = this.activePlaylist();
if (!playlist || !this.canRefreshPlaylist()) {
return;
}
this.playlistRefreshAction.refresh(playlist);
this.header.refreshCurrentPlaylist();
}
private makeCommandBuilderContext(): CommandBuilderContext {
@@ -816,7 +233,7 @@ export class WorkspaceShellFacade {
private readonly commandBuilderActions: CommandBuilderActions = {
openPlaylistSearch: (query) =>
this.openPlaylistSearchFromPalette(query),
this.search.openPlaylistSearchFromPalette(query),
refreshCurrentPlaylist: () => this.refreshCurrentPlaylist(),
openPlaylistInfo: () => this.openPlaylistInfo(),
openAccountInfo: () => this.openAccountInfo(),
@@ -825,103 +242,9 @@ export class WorkspaceShellFacade {
openGlobalRecent: () => this.openGlobalRecent(),
openDownloadsShortcut: () => this.openDownloadsShortcut(),
openAddPlaylistDialog: (kind) =>
kind
? this.workspaceActions.openAddPlaylistDialog(kind)
: this.workspaceActions.openAddPlaylistDialog(),
this.header.openAddPlaylistDialog(kind),
};
private openPlaylistSearchFromPalette(query: string): void {
const effectiveContext =
this.dashboardXtreamContext() ?? this.currentContext();
if (!effectiveContext) {
return;
}
this.searchQuery.set(query);
this.appliedSearchQuery.set(query);
if (effectiveContext.provider === 'xtreams') {
this.xtreamStore.setSearchTerm(query);
void this.router.navigate(
[
'/workspace',
'xtreams',
effectiveContext.playlistId,
'search',
],
{
queryParams: query ? { q: query } : {},
}
);
return;
}
if (effectiveContext.provider === 'stalker') {
void this.router.navigate(
[
'/workspace',
'stalker',
effectiveContext.playlistId,
'search',
],
{
queryParams: query ? { q: query } : {},
}
);
}
}
private syncSearchFromRoute(): void {
if (this.currentRoute().usesQuerySearch) {
this.setSearchState(
getRouteQueryParam(this.router, this.currentUrl(), 'q')
);
return;
}
this.setSearchState('');
}
private setSearchState(value: string): void {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
this.searchDebounceTimeoutId = null;
}
this.searchQuery.set(value);
this.appliedSearchQuery.set(value);
}
private scheduleSearchApply(value: string): void {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
}
this.searchDebounceTimeoutId = setTimeout(() => {
this.searchDebounceTimeoutId = null;
this.applySearchQuery(value);
}, SEARCH_INPUT_DEBOUNCE_MS);
}
private applySearchQuery(value: string): void {
this.appliedSearchQuery.set(value);
}
private isRecentCleanupDisabled(
provider: WorkspacePortalContext['provider']
): boolean {
if (provider === 'xtreams') {
return this.xtreamStore.recentItems().length === 0;
}
if (provider === 'playlists') {
return (this.activePlaylist()?.recentlyViewed?.length ?? 0) === 0;
}
return false;
}
private translateText(
key: string,
params?: Record<string, string | number>
@@ -12,6 +12,10 @@ import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/w
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';
import { WorkspaceShellHeaderService } from './services/workspace-shell-header.service';
import { WorkspaceShellRouteStateService } from './services/workspace-shell-route-state.service';
import { WorkspaceShellSearchSyncService } from './services/workspace-shell-search-sync.service';
import { WorkspaceShellSearchService } from './services/workspace-shell-search.service';
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
@Component({
@@ -30,6 +34,10 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu
styleUrl: './workspace-shell.component.scss',
providers: [
WorkspaceShellFacade,
WorkspaceShellRouteStateService,
WorkspaceShellSearchSyncService,
WorkspaceShellSearchService,
WorkspaceShellHeaderService,
WorkspaceShellXtreamImportService,
WorkspaceShellCommandPaletteService,
WorkspaceKeyboardShortcutsService,