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

@@ -0,0 +1,21 @@
export default {
displayName: 'workspace-dashboard-ui',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/workspace/dashboard/ui',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+8
View File
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:ui"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/workspace/dashboard/ui/jest.config.ts",
"tsConfig": "libs/workspace/dashboard/ui/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -2,8 +2,16 @@
[eyebrow]="'WORKSPACE.DASHBOARD.FOCUS' | translate"
[title]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
>
@if (lastItem(); as item) {
<a class="hero" [routerLink]="getLink(item)">
@if (data.globalRecentLoading()) {
<div
class="continue-watching-widget__loading"
role="status"
[attr.aria-label]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
>
<mat-progress-spinner mode="indeterminate" diameter="36" />
</div>
} @else if (lastItem(); as item) {
<a class="hero" [routerLink]="getLink(item)" [state]="getState(item)">
<!-- Thumbnail -->
<span class="hero__thumb" [class]="'hero__thumb--' + item.type">
@if (hasImage(item)) {
@@ -13,6 +13,13 @@
--_series-accent: oklch(68% 0.16 55);
}
.continue-watching-widget__loading {
min-height: 220px;
display: grid;
place-items: center;
padding: 16px 0;
}
// ── Hero card ─────────────────────────────────────────────────────────────────
.hero {
@@ -0,0 +1,48 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { ContinueWatchingWidgetComponent } from './continue-watching-widget.component';
describe('ContinueWatchingWidgetComponent', () => {
let fixture: ComponentFixture<ContinueWatchingWidgetComponent>;
const dataServiceMock = {
globalRecentLoading: signal(true),
globalRecentItems: signal([]),
quickRecent: signal([]),
getRecentItemLink: jest.fn(() => ['/workspace', 'global-recent']),
getRecentItemNavigationState: jest.fn(() => undefined),
getPlaylistLink: jest.fn(() => ['/workspace', 'sources']),
getPlaylistProvider: jest.fn(() => 'M3U'),
};
beforeEach(async () => {
dataServiceMock.globalRecentLoading.set(true);
dataServiceMock.globalRecentItems.set([]);
dataServiceMock.quickRecent.set([]);
await TestBed.configureTestingModule({
imports: [ContinueWatchingWidgetComponent, TranslateModule.forRoot()],
providers: [
provideRouter([]),
{
provide: DashboardDataService,
useValue: dataServiceMock,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ContinueWatchingWidgetComponent);
});
it('shows a spinner while recent history is still loading', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('mat-progress-spinner')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.focus-empty')).toBeNull();
});
});
@@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { RouterLink } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
@@ -16,6 +17,7 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen
imports: [
MatButtonModule,
MatIcon,
MatProgressSpinnerModule,
RouterLink,
DashboardWidgetShellComponent,
TranslatePipe,
@@ -44,6 +46,10 @@ export class ContinueWatchingWidgetComponent {
return this.data.getRecentItemLink(item);
}
getState(item: GlobalRecentItem): Record<string, unknown> | undefined {
return this.data.getRecentItemNavigationState(item);
}
typeIcon(type: GlobalRecentItem['type']): string {
if (type === 'live') return 'live_tv';
if (type === 'movie') return 'movie';
@@ -80,15 +80,28 @@
</mat-button-toggle-group>
</ng-container>
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_FAVORITES' | translate"
emptyIcon="favorite_border"
[emptyHint]="'WORKSPACE.DASHBOARD.NO_FAVORITES_HINT' | translate"
[viewMode]="viewMode()"
[showRemoveAction]="true"
[removeActionLabel]="'WORKSPACE.DASHBOARD.REMOVE_FAVORITE' | translate"
(itemAction)="onRemoveItem($event)"
(scrolledToEnd)="onItemsScrolledToEnd()"
/>
@if (data.globalFavoritesLoading()) {
<div
class="global-favorites-widget__loading"
role="status"
[attr.aria-label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
>
<mat-progress-spinner
mode="indeterminate"
diameter="36"
/>
</div>
} @else {
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_FAVORITES' | translate"
emptyIcon="favorite_border"
[emptyHint]="'WORKSPACE.DASHBOARD.NO_FAVORITES_HINT' | translate"
[viewMode]="viewMode()"
[showRemoveAction]="true"
[removeActionLabel]="'WORKSPACE.DASHBOARD.REMOVE_FAVORITE' | translate"
(itemAction)="onRemoveItem($event)"
(scrolledToEnd)="onItemsScrolledToEnd()"
/>
}
</app-dashboard-widget-shell>
@@ -38,3 +38,10 @@
font-weight: 600;
color: var(--mat-sys-primary);
}
.global-favorites-widget__loading {
min-height: 220px;
display: grid;
place-items: center;
padding: 16px 0;
}
@@ -0,0 +1,88 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { GlobalFavoritesWidgetComponent } from './global-favorites-widget.component';
describe('GlobalFavoritesWidgetComponent', () => {
let fixture: ComponentFixture<GlobalFavoritesWidgetComponent>;
const globalFavoritesLoading = signal(true);
const globalFavoritesLoaded = signal(false);
const globalFavoriteItems = signal<any[]>([]);
const dataServiceMock = {
globalFavoriteItems,
globalFavoritesLoading,
globalFavoritesLoaded,
reloadGlobalFavorites: jest.fn().mockResolvedValue(undefined),
isTypeInKind: jest.fn(
(type: 'live' | 'movie' | 'series', kind: string) =>
kind === 'all' ||
(kind === 'channels' && type === 'live') ||
(kind === 'vod' && type === 'movie') ||
(kind === 'series' && type === 'series')
),
getFavoriteItemProviderLabel: jest.fn(() => 'Xtream'),
getFavoriteItemTypeLabel: jest.fn(() => 'Live TV'),
getGlobalFavoriteLink: jest.fn(() => ['/workspace', 'global-favorites']),
getGlobalFavoriteNavigationState: jest.fn(() => undefined),
removeGlobalFavorite: jest.fn(),
};
beforeEach(async () => {
globalFavoritesLoading.set(true);
globalFavoritesLoaded.set(false);
globalFavoriteItems.set([]);
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [
GlobalFavoritesWidgetComponent,
TranslateModule.forRoot(),
],
providers: [
provideRouter([]),
{
provide: DashboardDataService,
useValue: dataServiceMock,
},
],
}).compileComponents();
fixture = TestBed.createComponent(GlobalFavoritesWidgetComponent);
fixture.componentRef.setInput('widget', {
id: 'global-favorites',
type: 'global-favorites',
enabled: true,
order: 0,
size: 'medium',
} as any);
});
it('shows a spinner while global favorites are still loading', () => {
fixture.detectChanges();
expect(dataServiceMock.reloadGlobalFavorites).toHaveBeenCalled();
expect(
fixture.nativeElement.querySelector('mat-progress-spinner')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('.empty-state')
).toBeNull();
});
it('shows the empty state only after the initial load completes', () => {
globalFavoritesLoading.set(false);
globalFavoritesLoaded.set(true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('mat-progress-spinner')
).toBeNull();
expect(
fixture.nativeElement.querySelector('.empty-state')
).not.toBeNull();
});
});
@@ -1,4 +1,5 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
@@ -11,6 +12,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
@@ -35,11 +37,13 @@ const PAGE_SIZE = 20;
@Component({
selector: 'app-global-favorites-widget',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatButtonToggleModule,
MatMenuModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
@@ -97,10 +101,8 @@ export class GlobalFavoritesWidgetComponent implements OnInit {
}))
);
constructor() {}
ngOnInit(): void {
this.data.reloadGlobalFavorites().then(() => {
void this.data.reloadGlobalFavorites().then(() => {
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
});
}
@@ -61,11 +61,12 @@
</ng-container>
@if (loading()) {
<div class="feedback-state">
<mat-icon class="feedback-state__icon">schedule</mat-icon>
<p class="feedback-state__title">
{{ 'WORKSPACE.DASHBOARD.LOADING_RECENTLY_ADDED' | translate }}
</p>
<div
class="feedback-state feedback-state--loading"
role="status"
[attr.aria-label]="'WORKSPACE.DASHBOARD.LOADING_RECENTLY_ADDED' | translate"
>
<mat-progress-spinner mode="indeterminate" diameter="36" />
</div>
} @else if (loadFailed()) {
<div class="feedback-state">
@@ -59,6 +59,12 @@
color: var(--mat-sys-on-surface-variant);
}
.feedback-state--loading {
min-height: 220px;
border-style: solid;
background: transparent;
}
.feedback-state__icon {
font-size: 34px;
width: 34px;
@@ -0,0 +1,50 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { RecentlyAddedWidgetComponent } from './recently-added-widget.component';
describe('RecentlyAddedWidgetComponent', () => {
let fixture: ComponentFixture<RecentlyAddedWidgetComponent>;
const dataServiceMock = {
getGlobalRecentlyAddedItems: jest.fn(() => new Promise(() => {})),
getRecentlyAddedItemProviderLabel: jest.fn(() => 'Xtream'),
getRecentlyAddedItemTypeLabel: jest.fn(() => 'Movie'),
getRecentlyAddedLink: jest.fn(() => ['/workspace', 'global-favorites']),
getRecentlyAddedNavigationState: jest.fn(() => undefined),
formatTimestamp: jest.fn(() => 'recently'),
};
beforeEach(async () => {
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [RecentlyAddedWidgetComponent, TranslateModule.forRoot()],
providers: [
provideRouter([]),
{
provide: DashboardDataService,
useValue: dataServiceMock,
},
],
}).compileComponents();
fixture = TestBed.createComponent(RecentlyAddedWidgetComponent);
fixture.componentRef.setInput('widget', {
id: 'recently-added',
type: 'recently-added',
enabled: true,
order: 0,
size: 'medium',
} as any);
});
it('shows a spinner while recently added items are loading', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('mat-progress-spinner')
).not.toBeNull();
});
});
@@ -11,6 +11,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
@@ -41,6 +42,7 @@ const DISPLAY_LIMIT = 25;
MatButtonToggleModule,
MatMenuModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
@@ -80,19 +80,29 @@
</mat-button-toggle-group>
</ng-container>
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_HISTORY' | translate"
emptyIcon="history"
[emptyHint]="'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate"
[emptyActionLabel]="'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate"
[emptyActionLink]="['/workspace/sources']"
[viewMode]="viewMode()"
[showRemoveAction]="true"
[removeActionLabel]="
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
"
(itemAction)="onRemoveItem($event)"
(scrolledToEnd)="onItemsScrolledToEnd()"
/>
@if (data.globalRecentLoading()) {
<div
class="recently-watched-widget__loading"
role="status"
[attr.aria-label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
>
<mat-progress-spinner mode="indeterminate" diameter="36" />
</div>
} @else {
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_HISTORY' | translate"
emptyIcon="history"
[emptyHint]="'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate"
[emptyActionLabel]="'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate"
[emptyActionLink]="['/workspace/sources']"
[viewMode]="viewMode()"
[showRemoveAction]="true"
[removeActionLabel]="
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
"
(itemAction)="onRemoveItem($event)"
(scrolledToEnd)="onItemsScrolledToEnd()"
/>
}
</app-dashboard-widget-shell>
@@ -38,3 +38,10 @@
font-weight: 600;
color: var(--mat-sys-primary);
}
.recently-watched-widget__loading {
min-height: 220px;
display: grid;
place-items: center;
padding: 16px 0;
}
@@ -0,0 +1,58 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { RecentlyWatchedWidgetComponent } from './recently-watched-widget.component';
describe('RecentlyWatchedWidgetComponent', () => {
let fixture: ComponentFixture<RecentlyWatchedWidgetComponent>;
const dataServiceMock = {
globalRecentLoading: signal(true),
globalRecentItems: signal([]),
reloadGlobalRecentItems: jest.fn().mockResolvedValue(undefined),
isTypeInKind: jest.fn(() => true),
getRecentItemProviderLabel: jest.fn(() => 'Xtream'),
getRecentItemTypeLabel: jest.fn(() => 'Live TV'),
getRecentItemLink: jest.fn(() => ['/workspace', 'global-recent']),
getRecentItemNavigationState: jest.fn(() => undefined),
removeGlobalRecentItem: jest.fn(),
};
beforeEach(async () => {
dataServiceMock.globalRecentLoading.set(true);
dataServiceMock.globalRecentItems.set([]);
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [RecentlyWatchedWidgetComponent, TranslateModule.forRoot()],
providers: [
provideRouter([]),
{
provide: DashboardDataService,
useValue: dataServiceMock,
},
],
}).compileComponents();
fixture = TestBed.createComponent(RecentlyWatchedWidgetComponent);
fixture.componentRef.setInput('widget', {
id: 'recently-watched',
type: 'recently-watched',
enabled: true,
order: 0,
size: 'medium',
} as any);
});
it('shows a spinner while recent history is loading', () => {
fixture.detectChanges();
expect(dataServiceMock.reloadGlobalRecentItems).toHaveBeenCalled();
expect(
fixture.nativeElement.querySelector('mat-progress-spinner')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.empty-state')).toBeNull();
});
});
@@ -11,6 +11,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
@@ -40,6 +41,7 @@ const PAGE_SIZE = 20;
MatButtonToggleModule,
MatMenuModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
DashboardActivityItemsComponent,
DashboardWidgetShellComponent,
@@ -2,40 +2,52 @@
[eyebrow]="'WORKSPACE.DASHBOARD.OVERVIEW' | translate"
[title]="'WORKSPACE.DASHBOARD.SOURCE_STATS' | translate"
>
<div class="stats-grid">
<article class="stat-tile stat-tile--total">
<div class="stat-tile__header">
<mat-icon>dns</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.TOTAL' | translate }}
</p>
</div>
<p class="value">{{ data.stats().total }}</p>
</article>
<article class="stat-tile stat-tile--xtream">
<div class="stat-tile__header">
<mat-icon>tv</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.XTREAM' | translate }}
</p>
</div>
<p class="value">{{ data.stats().xtream }}</p>
</article>
<article class="stat-tile stat-tile--stalker">
<div class="stat-tile__header">
<mat-icon>router</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.STALKER' | translate }}
</p>
</div>
<p class="value">{{ data.stats().stalker }}</p>
</article>
<article class="stat-tile stat-tile--m3u">
<div class="stat-tile__header">
<mat-icon>list_alt</mat-icon>
<p class="label">{{ 'WORKSPACE.DASHBOARD.M3U' | translate }}</p>
</div>
<p class="value">{{ data.stats().m3u }}</p>
</article>
</div>
@if (!data.playlistsLoaded()) {
<div
class="source-stats-widget__loading"
role="status"
[attr.aria-label]="'WORKSPACE.DASHBOARD.SOURCE_STATS' | translate"
>
<mat-progress-spinner mode="indeterminate" diameter="36" />
</div>
} @else {
<div class="stats-grid">
<article class="stat-tile stat-tile--total">
<div class="stat-tile__header">
<mat-icon>dns</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.TOTAL' | translate }}
</p>
</div>
<p class="value">{{ data.stats().total }}</p>
</article>
<article class="stat-tile stat-tile--xtream">
<div class="stat-tile__header">
<mat-icon>tv</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.XTREAM' | translate }}
</p>
</div>
<p class="value">{{ data.stats().xtream }}</p>
</article>
<article class="stat-tile stat-tile--stalker">
<div class="stat-tile__header">
<mat-icon>router</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.STALKER' | translate }}
</p>
</div>
<p class="value">{{ data.stats().stalker }}</p>
</article>
<article class="stat-tile stat-tile--m3u">
<div class="stat-tile__header">
<mat-icon>list_alt</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.M3U' | translate }}
</p>
</div>
<p class="value">{{ data.stats().m3u }}</p>
</article>
</div>
}
</app-dashboard-widget-shell>
@@ -1,3 +1,10 @@
.source-stats-widget__loading {
min-height: 180px;
display: grid;
place-items: center;
padding: 16px 0;
}
.stats-grid {
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,44 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { SourceStatsWidgetComponent } from './source-stats-widget.component';
describe('SourceStatsWidgetComponent', () => {
let fixture: ComponentFixture<SourceStatsWidgetComponent>;
const dataServiceMock = {
playlistsLoaded: signal(false),
stats: signal({
total: 0,
xtream: 0,
stalker: 0,
m3u: 0,
}),
};
beforeEach(async () => {
dataServiceMock.playlistsLoaded.set(false);
await TestBed.configureTestingModule({
imports: [SourceStatsWidgetComponent, TranslateModule.forRoot()],
providers: [
{
provide: DashboardDataService,
useValue: dataServiceMock,
},
],
}).compileComponents();
fixture = TestBed.createComponent(SourceStatsWidgetComponent);
});
it('shows a spinner while playlists are still hydrating', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('mat-progress-spinner')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.stats-grid')).toBeNull();
});
});
@@ -1,12 +1,18 @@
import { Component, inject } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
@Component({
selector: 'app-source-stats-widget',
imports: [DashboardWidgetShellComponent, TranslatePipe, MatIcon],
imports: [
DashboardWidgetShellComponent,
TranslatePipe,
MatIcon,
MatProgressSpinnerModule,
],
templateUrl: './source-stats-widget.component.html',
styleUrl: './source-stats-widget.component.scss',
})
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
@@ -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;
}
}
}