mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
82c725ea5a
commit
1bbe8f794e
9 files changed
+1063
-779
No files matched your search
@@ -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:
|
||||
|
||||
+281
@@ -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);
|
||||
}
|
||||
}
|
||||
+236
@@ -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);
|
||||
}
|
||||
}
|
||||
+148
@@ -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);
|
||||
}
|
||||
}
|
||||
+221
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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()) {
|
||||
|
||||
+17
-7
@@ -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');
|
||||
});
|
||||
|
||||
+92
-769
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user