diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 67d9fe3a8..05a7642e6 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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: diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts new file mode 100644 index 000000000..ca2655dd6 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts @@ -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(() => { + 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( + () => { + 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 { + 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 { + return this.translate.instant(key, params); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts new file mode 100644 index 000000000..318a3ce8a --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts @@ -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(() => { + 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(() => { + 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( + () => { + 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(() => { + 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(() => { + 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 { + return this.translate.instant(key, params); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts new file mode 100644 index 000000000..ced3009e6 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts @@ -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 | 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); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts new file mode 100644 index 000000000..e1d0b2308 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts @@ -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(() => { + 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 { + return this.translate.instant(key, params); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts index b8c63a7ed..7ffc7fe3b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts @@ -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()) { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 52ff2a1d5..018499f13 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -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([]); @@ -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'); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index ea1bb8433..870f85212 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -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 | 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( + () => { + 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( - () => { - this.languageTick(); - return this.commandPalette.buildPaletteCommands( - this.makeCommandBuilderContext() - ); - } - ); - readonly workspaceLinks = computed(() => { - 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(() => { - 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( - () => { - 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(() => { - 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(() => { - 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(() => { - 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(() => { - 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( - () => { - 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 { - 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 { + 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 diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts index e86b2714b..07abefca7 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts @@ -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,