feat: add loading state and spinner to source stats widget

- Introduced a loading state in the source stats widget with a spinner.
- Updated styles for the loading state in the SCSS file.
- Created unit tests for the source stats widget to verify loading behavior.
- Enhanced the source stats widget component to include a progress spinner.
- Added a new service for managing workspace startup preferences.
- Implemented logic to determine the initial workspace path based on user settings.
- Updated workspace shell header to include a refresh playlist button.
- Refactored workspace shell rail component to support dynamic brand link and tooltip.
- Enhanced workspace shell facade to manage playlist refresh actions.
- Improved loading overlay UI in the workspace shell component.

Entire-Checkpoint: 271bd0162027
This commit is contained in:
4gray committed 2026-04-04 10:10:52 +02:00
1 parent 26fa697afe
commit eaa3e0e2bd
37 files changed
+1178 -103

No files matched your search

@@ -57,6 +57,23 @@
>
<mat-icon>add</mat-icon>
</button>
@if (canRefreshPlaylist()) {
<button
type="button"
class="header-shortcut"
mat-icon-button
[disabled]="isRefreshingPlaylist()"
(click)="onRefreshPlaylistRequested()"
[matTooltip]="
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
"
>
<mat-icon>sync</mat-icon>
</button>
}
<span class="header-actions-divider" aria-hidden="true"></span>
<button
type="button"
@@ -36,6 +36,8 @@ export class WorkspaceShellHeaderComponent {
readonly searchStatusLabel = input('');
readonly isGlobalFavoritesActive = input(false);
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
readonly isDownloadsView = input(false);
@@ -45,6 +47,7 @@ export class WorkspaceShellHeaderComponent {
readonly addPlaylistRequested = output<void>();
readonly globalFavoritesRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
@@ -83,8 +86,11 @@ export class WorkspaceShellHeaderComponent {
this.headerShortcutRequested.emit();
}
onRefreshPlaylistRequested(): void {
this.refreshPlaylistRequested.emit();
}
onDownloadsRequested(): void {
this.downloadsRequested.emit();
}
}
@@ -1,10 +1,10 @@
<aside class="app-rail" [class.is-macos]="isMacOS()">
<a
class="brand"
routerLink="/workspace/dashboard"
[matTooltip]="'WORKSPACE.SHELL.RAIL_DASHBOARD' | translate"
[routerLink]="brandLink()"
[matTooltip]="brandTooltipKey() | translate"
[matTooltipPosition]="'right'"
[attr.aria-label]="'WORKSPACE.SHELL.OPEN_DASHBOARD' | translate"
[attr.aria-label]="brandAriaLabelKey() | translate"
>
<img
src="./assets/icons/icon-tv-256.png"
@@ -60,6 +60,11 @@ describe('WorkspaceShellRailComponent', () => {
});
it('renders provider context region and active settings shortcut state', () => {
fixture.componentRef.setInput('brandLink', '/workspace/sources');
fixture.componentRef.setInput(
'brandAriaLabelKey',
'WORKSPACE.SHELL.OPEN_SOURCES'
);
fixture.componentRef.setInput('primaryContextLinks', [
{
icon: 'movie',
@@ -81,5 +86,15 @@ describe('WorkspaceShellRailComponent', () => {
expect(
fixture.nativeElement.querySelector('.rail-shortcut.is-active')
).not.toBeNull();
expect(
fixture.nativeElement
.querySelector('.brand')
?.getAttribute('href')
).toContain('/workspace/sources');
expect(
fixture.nativeElement
.querySelector('.brand')
?.getAttribute('aria-label')
).toBe('WORKSPACE.SHELL.OPEN_SOURCES');
});
});
@@ -28,6 +28,9 @@ import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/
})
export class WorkspaceShellRailComponent {
readonly isMacOS = input(false);
readonly brandLink = input('/workspace/dashboard');
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = input<PortalRailLink[]>([]);
readonly primaryContextLinks = input<PortalRailLink[]>([]);
readonly secondaryContextLinks = input<PortalRailLink[]>([]);
@@ -5,6 +5,7 @@ import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -13,13 +14,20 @@ import {
SettingsStore,
} from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
WorkspaceStartupPreferencesService,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import { WorkspaceShellFacade } from './workspace-shell.facade';
class MockXtreamStore {
readonly recentItems = signal<unknown[]>([]);
readonly searchTerm = signal('');
readonly categorySearchTerm = signal('');
readonly isImporting = signal(false);
readonly activeImportSessionId = signal<string | null>(null);
readonly currentImportPhase = signal<string | null>(null);
readonly itemsToImport = signal(0);
readonly getSelectedCategory = signal<{
category_name?: string;
name?: string;
@@ -94,9 +102,16 @@ describe('WorkspaceShellFacade', () => {
let activePlaylistSignal: ReturnType<typeof signal<PlaylistSignalMeta>>;
let playlistsSignal: ReturnType<typeof signal<PlaylistSignalMeta[]>>;
let stalkerStore: MockStalkerStore;
let showDashboardSignal: ReturnType<typeof signal<boolean>>;
let startupPreferences: {
getFirstAvailableWorkspacePath: jest.Mock;
persistLastRestorablePath: jest.Mock;
showDashboard: jest.Mock;
};
beforeEach(() => {
window.electron = { platform: 'darwin' } as typeof window.electron;
showDashboardSignal = signal(true);
activePlaylistSignal = signal({
_id: 'pl-1',
@@ -138,14 +153,17 @@ describe('WorkspaceShellFacade', () => {
openGlobalRecent: jest.fn(),
openAccountInfo: jest.fn(),
};
startupPreferences = {
getFirstAvailableWorkspacePath: jest.fn((showDashboard: boolean) =>
showDashboard ? '/workspace/dashboard' : '/workspace/sources'
),
persistLastRestorablePath: jest.fn(),
showDashboard: jest.fn(() => showDashboardSignal()),
};
storeDispatch = jest.fn();
stalkerStore = new MockStalkerStore();
const selectSignal = jest
.fn()
.mockReturnValueOnce(signal('Playlist A'))
.mockReturnValueOnce(activePlaylistSignal)
.mockReturnValueOnce(playlistsSignal);
const selectSignal = jest.fn().mockReturnValue(playlistsSignal);
TestBed.configureTestingModule({
providers: [
@@ -177,6 +195,12 @@ describe('WorkspaceShellFacade', () => {
closeSession: jest.fn(),
},
},
{
provide: PlaylistContextFacade,
useValue: {
activePlaylist: activePlaylistSignal,
},
},
{
provide: SettingsStore,
useValue: {
@@ -199,6 +223,10 @@ describe('WorkspaceShellFacade', () => {
provide: WORKSPACE_SHELL_ACTIONS,
useValue: workspaceActions,
},
{
provide: WorkspaceStartupPreferencesService,
useValue: startupPreferences,
},
{
provide: TranslateService,
useValue: {
@@ -235,6 +263,42 @@ describe('WorkspaceShellFacade', () => {
);
});
it('uses a loading label for remote Xtream fetch phases', () => {
const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
xtreamStore.currentImportPhase.set('loading-categories');
expect(facade.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
xtreamStore.currentImportPhase.set('loading-live');
expect(facade.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
expect(facade.xtreamImportSourceLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
);
expect(facade.xtreamImportDetailLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
);
xtreamStore.currentImportPhase.set('saving-categories');
expect(facade.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
);
xtreamStore.currentImportPhase.set('saving-content');
expect(facade.xtreamImportPhaseLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
);
expect(facade.xtreamImportSourceLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE'
);
expect(facade.xtreamImportDetailLabel()).toBe(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_LOCAL'
);
});
it('keeps stalker local-filter routes on the same page and syncs q', () => {
router.navigate.mockClear();
router.navigateByUrl.mockClear();
@@ -326,6 +390,25 @@ describe('WorkspaceShellFacade', () => {
);
});
it('removes the dashboard rail link when dashboard is hidden', () => {
showDashboardSignal.set(false);
expect(facade.workspaceLinks()).toEqual([
{
icon: 'library_books',
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
path: ['/workspace/sources'],
},
]);
expect(facade.brandLink()).toBe('/workspace/sources');
});
it('persists the last restorable route from navigation events', () => {
expect(startupPreferences.persistLastRestorablePath).toHaveBeenCalledWith(
'/workspace/xtreams/pl-1/vod'
);
});
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();
@@ -13,7 +13,10 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { filter, firstValueFrom, startWith } from 'rxjs';
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PlaylistContextFacade,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/util';
import {
buildPortalRailLinks,
PORTAL_EXTERNAL_PLAYBACK,
@@ -29,6 +32,7 @@ import {
WorkspacePortalContext,
WorkspaceShellPageKind,
WorkspaceSearchCapability,
WorkspaceStartupPreferencesService,
} from '@iptvnator/workspace/shell/util';
import { PlaylistsService, SettingsStore } from 'services';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
@@ -67,6 +71,8 @@ interface WorkspaceCommandDefinition {
isEnabled: (state: WorkspaceCommandAvailability) => boolean;
}
type XtreamImportPhaseTone = 'remote' | 'local' | null;
const SEARCH_INPUT_DEBOUNCE_MS = 350;
const SEARCH_PLAYLIST_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
@@ -154,7 +160,13 @@ export class WorkspaceShellFacade {
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 playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
@@ -197,24 +209,47 @@ export class WorkspaceShellFacade {
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 workspaceLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
return [
{
const links: PortalRailLink[] = [];
if (this.showDashboard()) {
links.push({
icon: 'dashboard',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_DASHBOARD'),
path: ['/workspace/dashboard'],
exact: true,
},
{
icon: 'library_books',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_SOURCES'),
path: ['/workspace/sources'],
},
];
});
}
links.push({
icon: 'library_books',
tooltip: this.translateText('WORKSPACE.SHELL.RAIL_SOURCES'),
path: ['/workspace/sources'],
});
return links;
});
readonly isDashboardRoute = computed(
() => this.currentRoute().kind === 'dashboard'
@@ -292,9 +327,38 @@ export class WorkspaceShellFacade {
() =>
this.isElectron &&
this.xtreamStore.isImporting() &&
this.xtreamStore.activeImportOperationIds().length > 0 &&
Boolean(this.xtreamStore.activeImportSessionId()) &&
!this.xtreamStore.isCancellingImport()
);
readonly xtreamImportTitleLabel = computed(() =>
this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_TITLE')
);
readonly xtreamImportPhaseTone = computed<XtreamImportPhaseTone>(() => {
switch (this.xtreamStore.currentImportPhase()) {
case 'loading-categories':
case 'loading-live':
case 'loading-movies':
case 'loading-series':
return 'remote';
case 'preparing-content':
case 'saving-categories':
case 'saving-content':
case 'restoring-favorites':
case 'restoring-recently-viewed':
return 'local';
default:
return null;
}
});
readonly xtreamImportSourceLabel = computed(() => {
this.languageTick();
return this.xtreamImportPhaseTone() === 'remote'
? this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE')
: this.xtreamImportPhaseTone() === 'local'
? this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_LOCAL_BADGE')
: '';
});
readonly xtreamImportPhaseLabel = computed(() => {
this.languageTick();
@@ -303,6 +367,20 @@ export class WorkspaceShellFacade {
return this.translateText(
'WORKSPACE.SHELL.XTREAM_IMPORT_PREPARING'
);
case 'loading-categories':
return this.translateText(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
case 'saving-categories':
return this.translateText(
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
);
case 'loading-live':
case 'loading-movies':
case 'loading-series':
return this.translateText(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
case 'saving-content':
return this.translateText(
'WORKSPACE.SHELL.XTREAM_IMPORT_SAVING'
@@ -319,6 +397,15 @@ export class WorkspaceShellFacade {
return '';
}
});
readonly xtreamImportDetailLabel = computed(() => {
this.languageTick();
return this.xtreamImportPhaseTone() === 'remote'
? this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE')
: this.xtreamImportPhaseTone() === 'local'
? this.translateText('WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_LOCAL')
: '';
});
readonly showXtreamImportOverlay = computed(() => {
const context = this.currentContext();
const section = this.currentSection();
@@ -328,6 +415,7 @@ export class WorkspaceShellFacade {
}
return (
!this.xtreamStore.contentInitBlockReason() &&
this.xtreamStore.isImporting() &&
(section === 'vod' ||
section === 'live' ||
@@ -506,6 +594,10 @@ export class WorkspaceShellFacade {
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();
@@ -583,6 +675,9 @@ export class WorkspaceShellFacade {
)
.subscribe((event) => {
this.currentUrl.set(event.urlAfterRedirects);
this.startupPreferences.persistLastRestorablePath(
event.urlAfterRedirects
);
this.syncSearchFromRoute();
});
@@ -810,6 +905,16 @@ export class WorkspaceShellFacade {
this.workspaceActions.openAccountInfo(data);
}
refreshCurrentPlaylist(): void {
const playlist = this.activePlaylist();
if (!playlist || !this.canRefreshPlaylist()) {
return;
}
this.playlistRefreshAction.refresh(playlist);
}
private getCommandPaletteItems(): WorkspaceCommandItem[] {
const availability: WorkspaceCommandAvailability = {
context: this.currentContext(),
@@ -2,9 +2,31 @@
@if (facade.showXtreamImportOverlay()) {
<div class="workspace-loading-overlay">
<div class="workspace-loading-overlay__card">
<h3>Loading playlist...</h3>
<div class="workspace-loading-overlay__header">
@if (facade.xtreamImportSourceLabel()) {
<span
class="workspace-loading-overlay__badge"
[class.workspace-loading-overlay__badge--remote]="
facade.xtreamImportPhaseTone() === 'remote'
"
>
{{ facade.xtreamImportSourceLabel() }}
</span>
}
<h3>{{ facade.xtreamImportTitleLabel() }}</h3>
</div>
@if (facade.xtreamImportPhaseLabel()) {
<p>{{ facade.xtreamImportPhaseLabel() }}</p>
<p
class="workspace-loading-overlay__phase"
aria-live="polite"
>
{{ facade.xtreamImportPhaseLabel() }}
</p>
}
@if (facade.xtreamImportDetailLabel()) {
<p class="workspace-loading-overlay__detail">
{{ facade.xtreamImportDetailLabel() }}
</p>
}
@if (
facade.xtreamImportCount() !== 0 &&
@@ -18,7 +40,7 @@
100
"
/>
<p>
<p class="workspace-loading-overlay__progress-copy">
{{ facade.xtreamImportCount() }} /
{{ facade.xtreamItemsToImport() }}
</p>
@@ -28,6 +50,7 @@
@if (facade.canCancelXtreamImport()) {
<button
mat-stroked-button
class="workspace-loading-overlay__action"
type="button"
[disabled]="facade.isCancellingXtreamImport()"
(click)="facade.cancelXtreamImport()"
@@ -46,6 +69,9 @@
<app-workspace-shell-rail
[isMacOS]="facade.isMacOS"
[brandLink]="facade.brandLink()"
[brandTooltipKey]="facade.brandTooltipKey()"
[brandAriaLabelKey]="facade.brandAriaLabelKey()"
[workspaceLinks]="facade.workspaceLinks()"
[primaryContextLinks]="facade.primaryContextLinks()"
[secondaryContextLinks]="facade.secondaryContextLinks()"
@@ -68,8 +94,11 @@
facade.isGlobalFavoritesRoute() || facade.isPortalFavoritesAllScope()
"
[headerShortcut]="facade.headerShortcut()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
[isDownloadsView]="facade.isDownloadsView()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
(searchSubmitted)="facade.onSearchEnter($event)"
(commandPaletteRequested)="facade.openCommandPalette()"
@@ -69,19 +69,118 @@ app-workspace-shell-context-sidebar {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
padding: 24px;
background:
radial-gradient(
circle at top,
rgba(99, 120, 255, 0.12),
transparent 34%
),
rgba(8, 10, 16, 0.68);
backdrop-filter: blur(14px);
z-index: 1000;
}
.workspace-loading-overlay__card {
min-width: 300px;
padding: 2rem;
border-radius: 8px;
background: var(--mat-sys-surface-container-high);
width: min(440px, 100%);
display: grid;
gap: 14px;
padding: 24px 24px 20px;
border-radius: 24px;
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.12));
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0)
),
var(--mat-sys-surface-container-high);
box-shadow:
0 24px 64px rgba(0, 0, 0, 0.32),
0 1px 0 rgba(255, 255, 255, 0.05) inset;
text-align: center;
animation: workspace-loading-overlay-enter 180ms ease-out;
h3 {
margin-top: 0;
margin: 0;
font-size: 1.55rem;
font-weight: 650;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
}
.workspace-loading-overlay__header {
display: grid;
gap: 10px;
justify-items: center;
}
.workspace-loading-overlay__badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(120, 220, 255, 0.24);
background: rgba(120, 220, 255, 0.08);
color: rgba(164, 233, 255, 1);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workspace-loading-overlay__badge--remote {
border-color: rgba(105, 195, 255, 0.34);
background: rgba(83, 158, 255, 0.12);
color: rgba(202, 230, 255, 1);
}
.workspace-loading-overlay__phase,
.workspace-loading-overlay__detail,
.workspace-loading-overlay__progress-copy {
margin: 0;
}
.workspace-loading-overlay__phase {
font-size: 1rem;
font-weight: 560;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.workspace-loading-overlay__detail {
max-width: 32ch;
justify-self: center;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
line-height: 1.45;
}
.workspace-loading-overlay__progress-copy {
font-variant-numeric: tabular-nums;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
}
.workspace-loading-overlay__action {
justify-self: center;
min-width: 180px;
border-radius: 999px;
}
:host ::ng-deep .workspace-loading-overlay__card .mat-mdc-progress-bar {
overflow: hidden;
border-radius: 999px;
}
@keyframes workspace-loading-overlay-enter {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -23,6 +23,9 @@ import {
})
class MockWorkspaceShellRailComponent {
readonly isMacOS = input(false);
readonly brandLink = input('/workspace/dashboard');
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = input<unknown[]>([]);
readonly primaryContextLinks = input<unknown[]>([]);
readonly secondaryContextLinks = input<unknown[]>([]);
@@ -85,6 +88,9 @@ class MockExternalPlaybackDockComponent {
}
class MockWorkspaceShellFacade {
readonly brandLink = signal('/workspace/dashboard');
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = signal('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = signal([]);
readonly primaryContextLinks = signal([]);
readonly secondaryContextLinks = signal([]);
@@ -115,6 +121,23 @@ class MockWorkspaceShellFacade {
readonly showXtreamImportOverlay = signal(false);
readonly xtreamImportCount = signal(0);
readonly xtreamItemsToImport = signal(0);
readonly xtreamImportTitleLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_TITLE'
);
readonly xtreamImportSourceLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_REMOTE_BADGE'
);
readonly xtreamImportPhaseLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
readonly xtreamImportDetailLabel = signal(
'WORKSPACE.SHELL.XTREAM_IMPORT_DETAIL_REMOTE'
);
readonly xtreamImportPhaseTone = signal<'remote' | 'local' | null>(
'remote'
);
readonly canCancelXtreamImport = signal(false);
readonly isCancellingXtreamImport = signal(false);
readonly isMacOS = true;
readonly isElectron = true;
@@ -129,6 +152,7 @@ class MockWorkspaceShellFacade {
openPlaylistInfo = jest.fn();
openAccountInfo = jest.fn();
closeActiveExternalSession = jest.fn();
cancelXtreamImport = jest.fn();
}
describe('WorkspaceShellComponent', () => {
+1
View File
@@ -2,3 +2,4 @@ export * from './lib/settings-context.service';
export * from './lib/navigation/workspace-shell-route.utils';
export * from './lib/navigation/workspace-search-capability';
export * from './lib/workspace-shell-actions';
export * from './lib/workspace-startup-preferences.service';
@@ -0,0 +1,104 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { PlaylistsService, SettingsStore } from 'services';
import { StartupBehavior } from 'shared-interfaces';
import { WorkspaceStartupPreferencesService } from './workspace-startup-preferences.service';
describe('WorkspaceStartupPreferencesService', () => {
let service: WorkspaceStartupPreferencesService;
let playlistsService: { getAllPlaylists: jest.Mock };
let settingsStore: {
loadSettings: jest.Mock;
showDashboard: ReturnType<typeof signal<boolean>>;
startupBehavior: ReturnType<typeof signal<StartupBehavior>>;
};
beforeEach(() => {
localStorage.clear();
playlistsService = {
getAllPlaylists: jest.fn().mockReturnValue(
of([{ _id: 'playlist-1' }])
),
};
settingsStore = {
loadSettings: jest.fn().mockResolvedValue(undefined),
showDashboard: signal(true),
startupBehavior: signal(StartupBehavior.FirstView),
};
TestBed.configureTestingModule({
providers: [
WorkspaceStartupPreferencesService,
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: SettingsStore,
useValue: settingsStore,
},
],
});
service = TestBed.inject(WorkspaceStartupPreferencesService);
});
it('resolves the first view to dashboard when dashboard is enabled', async () => {
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/dashboard'
);
});
it('resolves the first view to sources when dashboard is hidden', async () => {
settingsStore.showDashboard.set(false);
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/sources'
);
});
it('restores the last route when restore-last-view is enabled', async () => {
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
service.persistLastRestorablePath('/workspace/global-recent?q=matrix');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/global-recent'
);
});
it('falls back to sources when the stored dashboard route is hidden', async () => {
settingsStore.showDashboard.set(false);
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
service.persistLastRestorablePath('/workspace/dashboard');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/sources'
);
});
it('canonicalizes detail routes to their section root', () => {
expect(
service.getRestorablePath(
'/workspace/xtreams/playlist-1/vod/123/456?q=matrix'
)
).toBe('/workspace/xtreams/playlist-1/vod');
});
it('ignores non-restorable routes', () => {
expect(service.getRestorablePath('/workspace/settings')).toBeNull();
expect(service.getRestorablePath('/workspace')).toBeNull();
expect(service.getRestorablePath('/unknown')).toBeNull();
});
it('falls back to the first available view when the stored playlist no longer exists', async () => {
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
playlistsService.getAllPlaylists.mockReturnValue(of([]));
service.persistLastRestorablePath('/workspace/xtreams/missing/live');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/dashboard'
);
});
});
@@ -0,0 +1,153 @@
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistsService, SettingsStore } from 'services';
import {
StartupBehavior,
} from 'shared-interfaces';
import { parseWorkspaceShellRoute } from './navigation/workspace-shell-route.utils';
const LAST_RESTORABLE_ROUTE_STORAGE_KEY = 'workspace-last-restorable-route-v1';
@Injectable({ providedIn: 'root' })
export class WorkspaceStartupPreferencesService {
private readonly settingsStore = inject(SettingsStore);
private readonly playlistsService = inject(PlaylistsService);
async resolveInitialWorkspacePath(): Promise<string> {
await this.settingsStore.loadSettings();
const showDashboard = this.showDashboard();
const firstViewPath = this.getFirstAvailableWorkspacePath(showDashboard);
if (this.startupBehavior() !== StartupBehavior.RestoreLastView) {
return firstViewPath;
}
return (
(await this.getValidatedLastRestorablePath(showDashboard)) ??
firstViewPath
);
}
async resolveDashboardPath(): Promise<string> {
await this.settingsStore.loadSettings();
return this.showDashboard()
? '/workspace/dashboard'
: '/workspace/sources';
}
getFirstAvailableWorkspacePath(showDashboard = this.showDashboard()): string {
return showDashboard ? '/workspace/dashboard' : '/workspace/sources';
}
showDashboard(): boolean {
return this.settingsStore.showDashboard?.() ?? true;
}
startupBehavior(): StartupBehavior {
return (
this.settingsStore.startupBehavior?.() ??
StartupBehavior.FirstView
);
}
persistLastRestorablePath(url: string): void {
const canonicalPath = this.getRestorablePath(url);
if (!canonicalPath) {
return;
}
try {
localStorage.setItem(
LAST_RESTORABLE_ROUTE_STORAGE_KEY,
canonicalPath
);
} catch {
// Ignore storage write failures.
}
}
getRestorablePath(url: string): string | null {
const [path] = url.split('?');
if (path === '/workspace' || path === '/workspace/') {
return null;
}
const route = parseWorkspaceShellRoute(url);
switch (route.kind) {
case 'dashboard':
return '/workspace/dashboard';
case 'downloads':
return '/workspace/downloads';
case 'global-favorites':
return '/workspace/global-favorites';
case 'global-recent':
return '/workspace/global-recent';
case 'sources':
return '/workspace/sources';
case 'portal':
if (!route.context || !route.section) {
return null;
}
return [
'/workspace',
route.context.provider,
route.context.playlistId,
route.section,
].join('/');
default:
return null;
}
}
async getValidatedLastRestorablePath(
showDashboard = this.showDashboard()
): Promise<string | null> {
const storedPath = this.readLastRestorablePath();
if (!storedPath) {
return null;
}
const canonicalPath = this.getRestorablePath(storedPath);
if (!canonicalPath) {
return null;
}
if (!showDashboard && canonicalPath === '/workspace/dashboard') {
return this.getFirstAvailableWorkspacePath(false);
}
const route = parseWorkspaceShellRoute(canonicalPath);
if (route.kind !== 'portal' || !route.context) {
return canonicalPath;
}
try {
const playlists = await firstValueFrom(
this.playlistsService.getAllPlaylists()
);
return playlists.some(
(playlist) => playlist._id === route.context?.playlistId
)
? canonicalPath
: this.getFirstAvailableWorkspacePath(showDashboard);
} catch {
return this.getFirstAvailableWorkspacePath(showDashboard);
}
}
private readLastRestorablePath(): string | null {
try {
const value = localStorage.getItem(
LAST_RESTORABLE_ROUTE_STORAGE_KEY
);
return value && value.trim().length > 0 ? value : null;
} catch {
return null;
}
}
}