feat: remove home component, and update workspace UI and routing

This commit is contained in:
4gray committed 2026-03-02 22:31:10 +01:00
1 parent 20485972c3
commit 129848c4d4
21 files changed
+362 -287

No files matched your search

+8 -40
View File
@@ -72,6 +72,14 @@ export const routes: Routes = [
(c) => c.VideoPlayerComponent
),
},
{
path: 'downloads',
data: { layout: 'workspace' },
loadComponent: () =>
import('./xtream-electron/downloads/downloads.component').then(
(c) => c.DownloadsComponent
),
},
...withWorkspaceLayout(xtreamRoutes),
...withWorkspaceLayout(stalkerRoutes),
{
@@ -84,51 +92,11 @@ export const routes: Routes = [
},
],
},
{
path: 'home',
loadComponent: () =>
import('./home/home.component').then((c) => c.HomeComponent),
},
{
path: 'playlists',
loadComponent: () =>
import('./home/video-player/video-player.component').then(
(c) => c.VideoPlayerComponent
),
},
{
path: 'iptv',
loadComponent: () =>
import('./home/video-player/video-player.component').then(
(c) => c.VideoPlayerComponent
),
},
{
path: 'playlists/:id',
pathMatch: 'full',
redirectTo: 'playlists/:id/all',
},
{
path: 'playlists/:id/:view',
loadComponent: () =>
import('./home/video-player/video-player.component').then(
(c) => c.VideoPlayerComponent
),
},
{
path: 'settings',
redirectTo: '/workspace/settings',
pathMatch: 'full',
},
...xtreamRoutes,
{
path: 'portals/:id',
loadComponent: () =>
import('./stalker/stalker-main-container.component').then(
(c) => c.StalkerMainContainerComponent
),
},
...stalkerRoutes,
{
path: '**',
redirectTo: '',
-12
View File
@@ -1,12 +0,0 @@
<app-header
[title]="'HOME.TITLE' | translate"
[subtitle]="'HOME.SUBTITLE' | translate"
(searchQuery)="onSearchQueryChange($event)"
(globalSearchClicked)="openGlobalSearch()"
(globalRecentClicked)="openGlobalRecent()"
/>
<app-recent-playlists
class="recent-playlists"
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
@@ -1,4 +0,0 @@
.recent-playlists {
height: calc(100vh - 71px);
overflow: auto;
}
@@ -1,77 +0,0 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogModule } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent, MockModule, MockProvider } from 'ng-mocks';
import { Observable } from 'rxjs';
import { DataService } from '../../../../../libs/services/src/lib/data.service';
import { RecentPlaylistsComponent } from '../../../../../libs/ui/components/src/lib/recent-playlists/recent-playlists.component';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { HomeComponent } from './home.component';
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
let electronService: DataService;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HomeComponent,
CommonModule,
MockComponent(RecentPlaylistsComponent),
MockModule(MatProgressBarModule),
MockModule(MatSnackBarModule),
TranslateModule.forRoot(),
MockModule(RouterModule),
MockModule(MatDialogModule),
],
providers: [
MockProvider(ActivatedRoute, {
snapshot: { component: '' } as any,
}),
MockProvider(MatSnackBar),
{ provide: DataService, useClass: ElectronServiceStub },
provideMockStore(),
provideMockActions(actions$),
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(DataService);
mockStore = TestBed.inject(MockStore);
mockStore.setState({});
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should set IPC event listeners', () => {
jest.spyOn(electronService, 'listenOn');
component.setRendererListeners();
expect(electronService.listenOn).toHaveBeenCalledTimes(
component.commandsList.length
);
});
it('should remove all ipc listeners on destroy', () => {
jest.spyOn(electronService, 'removeAllListeners');
component.ngOnDestroy();
expect(electronService.removeAllListeners).toHaveBeenCalledTimes(
component.commandsList.length
);
});
});
-56
View File
@@ -1,56 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistType, RecentPlaylistsComponent } from 'components';
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
import { HeaderComponent } from '../shared/components/header/header.component';
import { GlobalRecentlyViewedComponent } from '../xtream-electron/recently-viewed/global-recently-viewed.component';
import { GlobalSearchResultsComponent } from '../xtream-electron/search-results/global-search-results.component';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
imports: [HeaderComponent, RecentPlaylistsComponent, TranslatePipe],
})
export class HomeComponent {
private readonly dialog = inject(MatDialog);
searchQuery = signal<string>('');
onSearchQueryChange(query: string): void {
this.searchQuery.set(query);
}
onAddPlaylist(playlistType: PlaylistType): void {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type: playlistType },
}
);
}
openGlobalSearch(): void {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobalSearch: true },
});
}
openGlobalRecent(): void {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
}
}
@@ -48,7 +48,6 @@ import {
VideoPlayer,
} from 'shared-interfaces';
import { SettingsStore } from '../services/settings-store.service';
import { HeaderComponent } from '../shared/components/header/header.component';
import { SettingsContextService } from '../workspace/settings-context.service';
import { SettingsService } from './../services/settings.service';
@@ -65,7 +64,6 @@ interface SettingsSection {
imports: [
CommonModule,
FormsModule,
HeaderComponent,
MatButtonModule,
MatCheckboxModule,
MatDividerModule,
@@ -1,6 +1,5 @@
import {
Component,
HostBinding,
OnInit,
inject,
input,
@@ -20,7 +19,6 @@ import { TranslateModule } from '@ngx-translate/core';
import { DataService } from 'services';
//import { shell } from 'electron';
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
import { HomeComponent } from '../../../home/home.component';
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component';
import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.component';
@@ -44,10 +42,6 @@ import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.co
],
})
export class HeaderComponent implements OnInit {
@HostBinding('class.home-header') get isHomeHeader() {
return this.isHome;
}
private activatedRoute = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private dataService = inject(DataService);
@@ -66,11 +60,10 @@ export class HeaderComponent implements OnInit {
readonly globalSearchClicked = output<void>();
readonly globalRecentClicked = output<void>();
isHome = true;
readonly isHome = false;
ngOnInit() {
this.isHome =
this.activatedRoute.snapshot.component.name === HomeComponent.name;
// Initialization if needed
}
/**
@@ -1,4 +1,4 @@
@import '../../nav-list.scss';
@use '../../nav-list.scss';
:host {
display: flex;
@@ -106,7 +106,7 @@
<mat-icon>person</mat-icon>
</button>
}
@if (downloadsContext()) {
@if (isElectron) {
<button
type="button"
class="header-shortcut"
@@ -17,7 +17,6 @@ import {
NavigationEnd,
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { Store } from '@ngrx/store';
@@ -79,7 +78,6 @@ const SEARCH_INPUT_DEBOUNCE_MS = 350;
PlaylistSwitcherComponent,
TranslatePipe,
RouterLink,
RouterLinkActive,
RouterOutlet,
WorkspaceContextPanelComponent,
WorkspaceFavoritesContextPanelComponent,
@@ -180,6 +178,9 @@ export class WorkspaceShellComponent {
this.currentUrl()
)
);
readonly isGlobalDownloadsRoute = computed(() =>
/^\/workspace\/downloads(?:\/)?(?:\?.*)?$/.test(this.currentUrl())
);
readonly dashboardXtreamContext = computed<WorkspaceContext | null>(() => {
if (!this.isDashboardRoute()) {
return null;
@@ -338,23 +339,10 @@ export class WorkspaceShellComponent {
workspace: true,
}).secondary.filter((link) => link.section !== 'downloads');
});
readonly downloadsContext = computed<WorkspaceContext | null>(() => {
if (!this.isElectron) {
return null;
}
const context = this.currentContext() ?? this.railContext();
if (
!context ||
(context.provider !== 'xtreams' && context.provider !== 'stalker')
) {
return null;
}
return context;
});
readonly isDownloadsView = computed(
() => this.currentSection() === 'downloads'
() =>
this.currentSection() === 'downloads' ||
this.isGlobalDownloadsRoute()
);
readonly canOpenPlaylistInfo = computed(() =>
Boolean(this.activePlaylist())
@@ -366,20 +354,21 @@ export class WorkspaceShellComponent {
() => {
const context = this.currentContext();
const section = this.currentSection();
if (!context || !section) {
return null;
}
const isGlobalDownloads = this.isGlobalDownloadsRoute();
if (
(context.provider === 'xtreams' ||
context.provider === 'stalker') &&
section === 'downloads'
isGlobalDownloads ||
(context &&
(context.provider === 'xtreams' ||
context.provider === 'stalker') &&
section === 'downloads')
) {
const playlistId = context?.playlistId;
const hasClearable = this.downloadsService
.downloads()
.some(
(item) =>
item.playlistId === context.playlistId &&
(!playlistId || item.playlistId === playlistId) &&
(item.status === 'completed' ||
item.status === 'failed' ||
item.status === 'canceled')
@@ -392,6 +381,10 @@ export class WorkspaceShellComponent {
};
}
if (!context || !section) {
return null;
}
if (context.provider === 'xtreams' && section === 'recent') {
return {
icon: 'delete_sweep',
@@ -563,17 +556,22 @@ export class WorkspaceShellComponent {
async runHeaderBulkAction(): Promise<void> {
const context = this.currentContext();
const section = this.currentSection();
if (!context || !section) {
const isGlobalDownloads = this.isGlobalDownloadsRoute();
if (
isGlobalDownloads ||
(context &&
(context.provider === 'xtreams' ||
context.provider === 'stalker') &&
section === 'downloads')
) {
const playlistId = context?.playlistId;
await this.downloadsService.clearCompleted(playlistId);
await this.downloadsService.loadDownloads(playlistId);
return;
}
if (
(context.provider === 'xtreams' ||
context.provider === 'stalker') &&
section === 'downloads'
) {
await this.downloadsService.clearCompleted(context.playlistId);
await this.downloadsService.loadDownloads(context.playlistId);
if (!context || !section) {
return;
}
@@ -593,17 +591,7 @@ export class WorkspaceShellComponent {
}
openDownloadsShortcut(): void {
const context = this.downloadsContext();
if (!context) {
return;
}
this.router.navigate([
'/workspace',
context.provider,
context.playlistId,
'downloads',
]);
this.router.navigate(['/workspace/downloads']);
}
openPlaylistInfo(): void {
@@ -1,4 +1,4 @@
@import '../../nav-list.scss';
@use '../../nav-list.scss';
:host {
display: flex;
@@ -1,14 +1,11 @@
import { map } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from 'm3u-state';
import { PlaylistType, RecentPlaylistsComponent } from 'components';
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state';
import { map } from 'rxjs';
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
@Component({
@@ -25,7 +22,9 @@ export class WorkspaceSourcesComponent {
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly searchQuery = toSignal(
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
@@ -72,7 +71,8 @@ export class WorkspaceSourcesComponent {
return isStalkerFilter || isXtreamFilter || isM3uFilter;
})
.filter((item) => item.title.toLowerCase().includes(query)).length;
.filter((item) => (item.title || '').toLowerCase().includes(query))
.length;
});
readonly subtitle = computed(() => {
@@ -1,4 +1,4 @@
@import '../../../nav-list.scss';
@use '../../../nav-list.scss';
:host {
height: 100%;
+4
View File
@@ -511,6 +511,7 @@
"OPEN_SOURCE": "Open Source",
"MANAGE_SOURCES": "Manage Sources",
"NO_ACTIVE_SOURCE": "No active source selected yet.",
"NO_ACTIVE_SOURCE_HINT": "Select a playlist from the sidebar to continue where you left off.",
"ADD_SOURCE": "Add Source",
"RECENTLY_USED": "Recently used",
"GLOBAL_FAVORITES": "Global Favorites",
@@ -523,13 +524,16 @@
"LIST_VIEW": "List view",
"GRID_VIEW": "Grid view",
"NO_FAVORITES": "No favorites found for this filter.",
"NO_FAVORITES_HINT": "You haven't added any channels, movies, or series to your global favorites yet. Use the star icon to favorite items.",
"RECENT_SOURCES": "Recent Sources",
"MANAGE_ALL": "Manage all",
"NO_SOURCES": "No sources available yet.",
"NO_SOURCES_HINT": "Your recently used or added playlists will show up here for quick access.",
"OPEN_SOURCES": "Open Sources",
"RECENTLY_WATCHED": "Recently Watched",
"ACTIVITY": "Activity",
"NO_HISTORY": "No items found for this filter.",
"NO_HISTORY_HINT": "Your watching history will appear here once you start playing content from your playlists.",
"SOURCE_STATS": "Source Statistics",
"OVERVIEW": "Overview",
"TOTAL": "Total",
+2 -1
View File
@@ -1,3 +1,5 @@
@use '../../../libs/ui/components/src/lib/resizable/resizable.scss';
// ─── Tailwind CSS ─────────────────────────────────────────────────────────────
// Preflight is disabled in tailwind.config.js to avoid conflicts with
// Angular Material. Only components + utilities are loaded.
@@ -7,7 +9,6 @@
// ─── Third-party ─────────────────────────────────────────────────────────────
@import '@yangkghjh/videojs-aspect-ratio-panel/dist/videojs-aspect-ratio-panel.css';
@import '../../../libs/ui/components/src/lib/resizable/resizable.scss';
// ─── Google Fonts ─────────────────────────────────────────────────────────────
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@@ -0,0 +1,253 @@
# Workspace UI Refactor Summary
Date: 2026-02-22
## 1) What Was Refactored
The app moved from a setup-first flow (playlist management first, then provider-specific UI) to a **workspace shell** flow with a persistent app frame:
1. Fixed left navigation rail (global + context links).
2. Persistent top header (playlist switcher, search, actions, favorites shortcut, sorting where relevant).
3. Secondary left panel for:
1. Sources filters on `/workspace/sources`.
2. Provider category context for Xtream/Stalker content sections.
4. Unified workspace routing with dashboard and sources as first-class views.
Core entrypoint:
1. `apps/web/src/app/workspace/workspace-shell.component.ts`
2. `apps/web/src/app/workspace/workspace-shell.component.html`
Shared rail link model:
1. `apps/web/src/app/shared/navigation/portal-rail-links.ts`
2. `apps/web/src/app/shared/navigation/portal-rail-links.component.ts`
## 2) New Dashboard (Current State)
The dashboard is now a real widget host with persisted layout and customization.
Key parts:
1. Widget model and defaults:
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts`
2. Persisted layout store with migration support:
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts`
3. Widget host:
1. `libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts`
2. `libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.html`
4. Dashboard page (view + customize mode):
1. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts`
2. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html`
Active widget set:
1. Recent Sources
2. Source Statistics
3. Continue Watching
4. Recently Watched (global, scope-aware)
5. Global Favorites (global, scope-aware)
Notes:
1. Widget scope settings (provider + playlist filtering) are implemented for scoped widgets.
## 2.1) Dashboard Idea and Widget Implementation
### Product idea
The dashboard is designed as a content-first operational home, not a source setup page.
Goals:
1. Show high-value content immediately after app start.
2. Reduce context switching between Xtream, Stalker, and M3U flows.
3. Keep widgets independent so one data failure does not break the page.
4. Allow gradual customization without introducing heavy layout complexity too early.
Design principles:
1. Widgets are about user tasks (continue watching, favorites, recent activity), not provider internals.
2. Each widget must have explicit loading/empty/error states.
3. Widget settings should be persistent and reversible.
4. Navigation from widget cards should deep-link directly to the right route and content context.
### Implementation shape
Core implementation files:
1. Dashboard page and orchestration:
1. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.ts`
2. `libs/workspace/dashboard/feature/src/lib/workspace-dashboard.component.html`
2. Widget metadata contract:
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-widget.model.ts`
3. Layout + settings persistence:
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-layout.service.ts`
4. Widget rendering host:
1. `libs/workspace/dashboard/ui/src/lib/dashboard-widget-host.component.ts`
5. Data aggregation and widget inputs:
1. `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`
6. Shared activity item renderer (used by multiple widgets):
1. `libs/workspace/dashboard/ui/src/lib/widgets/dashboard-activity-items.component.ts`
### How widget rendering works
1. `workspace-dashboard` reads layout state (widget list, order, enabled state, settings).
2. For each enabled widget, `dashboard-widget-host` maps `widget.type` to a concrete widget component.
3. The host passes widget config (`scope`, `settings`, mode flags) into the widget.
4. Widget requests data through `dashboard-data.service` and/or existing provider services/stores.
5. Widget emits navigation actions, which are translated into direct route deep-links.
### How persistence works
Layout is stored as a versioned dashboard configuration:
1. Widget identity and type
2. Visibility (`enabled`)
3. Order
4. Scope (`providers`, optional `playlistIds`)
5. Widget-specific `settings`
On load:
1. Stored layout is validated against current widget registry/defaults.
2. Missing or removed widgets are migrated safely.
3. Invalid settings fall back to defaults, preventing dashboard hard-fail.
### Widget UX behavior implemented
1. `Recently Watched` and `Global Favorites`:
1. Content-kind chips (channels/vod/series)
2. List/grid toggle
3. Shared item styles
4. Deep-link navigation into details/views
2. `Continue Watching`:
1. Resume-oriented content block
3. `Recent Sources`:
1. Source activity with truncation-safe titles
4. `Source Statistics`:
1. Aggregated per-provider/source counters
5. `Recent Activity` was intentionally removed from scope.
### How to add a new widget (current workflow)
1. Add a new `type` in `dashboard-widget.model.ts`.
2. Implement widget component under `libs/workspace/dashboard/ui/src/lib/widgets/`.
3. Register it in `dashboard-widget-host.component.ts` type mapping.
4. Add default config in layout defaults/migration path (`dashboard-layout.service.ts`).
5. Extend `dashboard-data.service.ts` or reuse existing feature services for data.
6. Add explicit loading/empty/error UI states and deep-link behavior.
### Current boundaries
1. Advanced drag/resize grid and collision engine is not implemented yet.
2. External adapters (RSS/sports/news) are intentionally deferred.
## 3) Route Generalization (Current Routing Topology)
Primary app routing now starts in workspace:
1. `/` -> `/workspace`
2. `/workspace/dashboard`
3. `/workspace/sources`
4. `/workspace/playlists/:id` (M3U player in workspace frame)
5. `/workspace/xtreams/:id/...` (workspace layout applied)
6. `/workspace/stalker/:id/...` (workspace layout applied)
Defined in:
1. `apps/web/src/app/app.routes.ts`
Mechanism:
1. Existing provider route trees (`xtreamRoutes`, `stalkerRoutes`) are wrapped by `withWorkspaceLayout(...)` and receive `data.layout = 'workspace'`.
2. Provider shells/components use `isWorkspaceLayout` to hide legacy local sidebars/header pieces.
## 4) Generalized UX Behaviors Added
1. Playlist switcher integrated into persistent header and used as cross-context navigator:
1. `libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts`
2. Header search unified, with debounced apply for route/store updates:
1. `apps/web/src/app/workspace/workspace-shell.component.ts`
3. Sources filtering moved to dedicated panel:
1. `apps/web/src/app/workspace/workspace-sources-filters-panel.component.ts`
4. Context categories moved to shared workspace panel:
1. `apps/web/src/app/workspace/workspace-context-panel.component.ts`
5. Dashboard activity items deep-link directly to content context/details; Xtream live entries auto-play on open.
## 5) Legacy/Old Parts Still Present
### A) Legacy routes kept for compatibility
In `apps/web/src/app/app.routes.ts`:
1. `/home`
2. `/playlists`
3. `/iptv`
4. `/playlists/:id`
5. Non-workspace provider routes:
1. `/xtreams/:id/...`
2. `/stalker/:id/...`
6. Alias `/portals/:id` -> `StalkerMainContainerComponent`
### B) Legacy Xtream module tree kept as fallback
1. `apps/web/src/app/xtream/*`
This is still used by `legacyXtreamRouteFallback` when `!window.electron`.
### C) Dual-layout branching across provider components
Many provider components still have workspace/non-workspace conditional branches via `isWorkspaceLayout` (for sidebar/header/body behavior). This is intentional compatibility, but increases maintenance.
Examples:
1. `apps/web/src/app/xtream-electron/xtream-shell.component.html`
2. `apps/web/src/app/stalker/stalker-shell.component.html`
3. `apps/web/src/app/xtream-electron/xtream-main-container.component.html`
4. `apps/web/src/app/stalker/stalker-main-container.component.html`
5. `apps/web/src/app/home/video-player/video-player.component.html`
### D) Unused placeholder artifact
1. `apps/web/src/app/workspace/dashboard-placeholder.component.ts`
2. `apps/web/src/app/workspace/dashboard-placeholder.component.html`
3. `apps/web/src/app/workspace/dashboard-placeholder.component.scss`
These files are no longer routed.
## 6) Potential Removals (With Preconditions)
### Low risk (can remove soon)
1. Remove unused dashboard placeholder files (`dashboard-placeholder.*`).
### Medium risk (requires product decision)
1. Remove `/portals/:id` alias route if no external/deeplink dependency remains.
2. Remove legacy entry routes (`/home`, `/iptv`, `/playlists`, `/playlists/:id`) if workspace-only navigation is desired.
### High risk (requires platform/support policy decision)
1. Remove legacy fallback module `apps/web/src/app/xtream/*` and `legacyXtreamRouteFallback` only if non-Electron fallback is officially dropped.
2. Remove non-workspace provider routes (`/xtreams/:id/...`, `/stalker/:id/...`) only after verifying all deeplinks/bookmarks/integration paths have migrated to `/workspace/...`.
## 7) Recommended Cleanup Sequence
1. Remove unused placeholder files.
2. Decide whether non-workspace URLs must remain public and supported.
3. If workspace-only is approved:
1. Deprecate legacy routes with redirects.
2. Remove non-workspace route trees.
3. Remove `isWorkspaceLayout` branches and simplify provider layouts.
4. If non-Electron fallback is not required:
1. Remove `legacyXtreamRouteFallback`.
2. Remove `apps/web/src/app/xtream/*`.
## 8) Current Plan Items Still Open
From current plan scope, remaining work is primarily:
1. Widget settings UX improvements (scope presets, bulk provider toggles).
2. Phase 3 dashboard advanced layout system (drag/resize/collision handling).
3. Phase 4 external widget framework (RSS/scores/news adapters + reliability controls).
+16 -4
View File
@@ -289,11 +289,20 @@ export class PlaylistEffects {
}),
map((playlist: Playlist) => {
if (playlist.serverUrl && !this.dataService.isElectron) {
this.router.navigate(['/xtreams/', playlist._id]);
this.router.navigate([
'/workspace/xtreams/',
playlist._id,
]);
} else if (playlist.macAddress) {
this.router.navigate(['stalker', playlist._id]);
this.router.navigate([
'/workspace/stalker/',
playlist._id,
]);
} else {
this.router.navigate(['/playlists/', playlist._id]);
this.router.navigate([
'/workspace/playlists/',
playlist._id,
]);
}
return playlist;
}),
@@ -308,7 +317,10 @@ export class PlaylistEffects {
password: playlist.password || '',
type: 'xtream',
});
this.router.navigate(['/xtreams/', playlist._id]);
this.router.navigate([
'/workspace/xtreams/',
playlist._id,
]);
} else if (
playlist.macAddress &&
this.dataService.isElectron
@@ -2,7 +2,7 @@
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
padding: 2px 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -6,14 +6,14 @@
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
}}</mat-label>
<input formControlName="title" matInput />
</mat-form-field>
@if (playlist.portalUrl) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
@@ -21,7 +21,7 @@
</mat-form-field>
}
@if (playlist.macAddress) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
}}</mat-label>
@@ -29,31 +29,31 @@
</mat-form-field>
}
@if (playlist.portalUrl) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input matInput formControlName="stalkerSerialNumber" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId1" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId2" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature1" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
@@ -61,7 +61,7 @@
</mat-form-field>
}
@if (playlist.serverUrl) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
@@ -69,7 +69,7 @@
</mat-form-field>
}
@if (playlist.username) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
}}</mat-label>
@@ -77,7 +77,7 @@
</mat-form-field>
}
@if (playlist.password) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
}}</mat-label>
@@ -85,7 +85,7 @@
</mat-form-field>
}
@if (playlist.url) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
}}</mat-label>
@@ -95,20 +95,22 @@
matSuffix
type="button"
(click)="copyUrl()"
[matTooltip]="'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate"
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
</mat-form-field>
}
@if (playlist.filename) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
}}</mat-label>
<input matInput formControlName="filename" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
}}</mat-label>
@@ -116,7 +118,7 @@
</mat-form-field>
}
@if (playlist.count) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
}}</mat-label>
@@ -124,7 +126,7 @@
</mat-form-field>
}
@if (isDesktop) {
<mat-form-field class="full-width mb-10">
<mat-form-field class="w-full mb-10">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
@@ -135,7 +137,7 @@
</mat-form-field>
}
@if (playlist.filePath) {
<mat-form-field class="full-width">
<mat-form-field class="w-full">
<mat-label>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
</mat-label>
@@ -151,7 +153,7 @@
</mat-form-field>
}
@if (isDesktop && (playlist.url || playlist.filePath)) {
<mat-checkbox class="full-width" formControlName="autoRefresh">
<mat-checkbox class="w-full" formControlName="autoRefresh">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
</mat-checkbox>
<p>
@@ -112,7 +112,9 @@ export class RecentPlaylistsComponent {
);
})
.filter((item) =>
item.title.toLowerCase().includes(searchQuery.toLowerCase())
(item.title || '')
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
// Apply sorting using the SortService
@@ -160,11 +162,11 @@ export class RecentPlaylistsComponent {
getPlaylist(playlistMeta: PlaylistMeta): void {
if (playlistMeta.serverUrl) {
this.router.navigate(['xtreams', playlistMeta._id]);
this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]);
} else if (playlistMeta.macAddress) {
this.router.navigate(['stalker', playlistMeta._id]);
this.router.navigate(['/workspace', 'stalker', playlistMeta._id]);
} else {
this.router.navigate(['playlists', playlistMeta._id]);
this.router.navigate(['/workspace', 'playlists', playlistMeta._id]);
this.playlistClicked.emit(playlistMeta._id);
}
}
@@ -280,7 +282,7 @@ export class RecentPlaylistsComponent {
);
// Navigate to the playlist to trigger re-import
this.router.navigate(['xtreams', item._id]);
this.router.navigate(['/workspace', 'xtreams', item._id]);
} catch (error) {
console.error('Error refreshing Xtream playlist:', error);
this.snackBar.open(
+3
View File
@@ -0,0 +1,3 @@
import { signal } from '@angular/core';
const sig = signal<any>(undefined);
console.log(!!sig());