- @if (!xtreamStore.selectedItem()) { + @if (!xtreamStore.selectedItem() && !xtreamStore.isLoadingDetails()) {

- - -
- - -
- - -
- -
- @if (posterUrl) { - Poster + +
+ - } @else { -
No Poster
- } +
+ + +
+ + + + +
+ +
+ + + + + +
+ +
+ + +
+ +
+
+ } @else { + +
- -
-

{{ title }}

+ +
- -
- + +
+ +
+ @if (posterUrl) { + Poster + } @else { +
No Poster
+ }
- - @if (description) { -

- {{ description }} -

- } + +
+

{{ title }}

- -
- + +
+ +
+ + + @if (description) { +

+ {{ description }} +

+ } + + +
+ +
+ + +
+ +
+ + +
- - -
- -
- - -
-
- + } + \ No newline at end of file diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index fbe107b5d..d1fe1cb28 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -471,3 +471,30 @@ .hero__content { animation: fadeInUp 0.6s ease-out 0.2s both; } + +// Skeleton Loader Overrides +ngx-skeleton-loader { + display: block; + width: 100%; + + // Ensure the loader inside poster takes up the correct aspect ratio space + ::ng-deep .loader { + display: block; + height: 100%; + } +} + +:host-context(.dark-theme) { + ngx-skeleton-loader { + ::ng-deep .loader { + background-color: rgba(255, 255, 255, 0.1); + } + } +} + +.poster { + // ... existing styles ... + // Ensure poster container has height/ratio for skeleton + min-height: 330px; // Fallback + aspect-ratio: 2 / 3; +} diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index 36bad6e15..2dcfff209 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -2,11 +2,17 @@ import { CommonModule } from '@angular/common'; import { Component, EventEmitter, Input, Output } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader'; @Component({ selector: 'app-content-hero', standalone: true, - imports: [CommonModule, MatIconModule, MatButtonModule], + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + NgxSkeletonLoaderComponent, + ], templateUrl: './content-hero.component.html', styleUrls: ['./content-hero.component.scss'], }) @@ -15,6 +21,7 @@ export class ContentHeroComponent { @Input() description: string | undefined; @Input() posterUrl: string | undefined; @Input() backdropUrl: string | undefined; + @Input() isLoading = false; @Output() backClicked = new EventEmitter(); From 2630025b11f0b78ddc1c8a296b826407a1c25d0d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Jan 2026 17:11:27 +0100 Subject: [PATCH 7/9] feat: restore hidden category state after playlist refresh --- .../src/app/api/main.preload.ts | 15 ++++++- .../app/events/database/category.events.ts | 10 ++++- .../src/app/events/database/xtream.events.ts | 20 +++++++-- .../electron-xtream-data-source.ts | 41 ++++++++++++++++++- global.d.ts | 7 +++- .../src/lib/database-electron.service.ts | 14 +++++-- .../recent-playlists.component.ts | 14 ++++--- 7 files changed, 105 insertions(+), 16 deletions(-) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 771fe67b2..db4ee78c8 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -129,8 +129,19 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.invoke('DB_HAS_CATEGORIES', playlistId, type), dbGetCategories: (playlistId: string, type: string) => ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type), - dbSaveCategories: (playlistId: string, categories: any[], type: string) => - ipcRenderer.invoke('DB_SAVE_CATEGORIES', playlistId, categories, type), + dbSaveCategories: ( + playlistId: string, + categories: any[], + type: string, + hiddenCategoryXtreamIds?: number[] + ) => + ipcRenderer.invoke( + 'DB_SAVE_CATEGORIES', + playlistId, + categories, + type, + hiddenCategoryXtreamIds + ), dbGetAllCategories: (playlistId: string, type: string) => ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type), dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) => diff --git a/apps/electron-backend/src/app/events/database/category.events.ts b/apps/electron-backend/src/app/events/database/category.events.ts index dbb7913bb..77cc65f51 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -62,6 +62,7 @@ ipcMain.handle( /** * Save categories in bulk + * Optionally accepts hidden category data to restore visibility preferences */ ipcMain.handle( 'DB_SAVE_CATEGORIES', @@ -72,7 +73,8 @@ ipcMain.handle( category_name: string; category_id: number; }>, - type: 'live' | 'movies' | 'series' + type: 'live' | 'movies' | 'series', + hiddenCategoryXtreamIds?: number[] ) => { try { // Skip if no categories to save @@ -81,11 +83,17 @@ ipcMain.handle( } const db = await getDatabase(); + + // Create a Set of hidden xtreamIds for efficient lookup + const hiddenSet = new Set(hiddenCategoryXtreamIds || []); + const values = categories.map((cat) => ({ playlistId, name: cat.category_name, type, xtreamId: cat.category_id, + // Restore hidden status if the category was previously hidden + hidden: hiddenSet.has(cat.category_id), })); await db.insert(schema.categories).values(values); diff --git a/apps/electron-backend/src/app/events/database/xtream.events.ts b/apps/electron-backend/src/app/events/database/xtream.events.ts index 0a04c68d4..34218c713 100644 --- a/apps/electron-backend/src/app/events/database/xtream.events.ts +++ b/apps/electron-backend/src/app/events/database/xtream.events.ts @@ -18,14 +18,27 @@ ipcMain.handle( try { const db = await getDatabase(); - // First, get all category IDs for this playlist + // First, get all categories for this playlist (including hidden status) const categories = await db - .select({ id: schema.categories.id }) + .select({ + id: schema.categories.id, + xtreamId: schema.categories.xtreamId, + type: schema.categories.type, + hidden: schema.categories.hidden, + }) .from(schema.categories) .where(eq(schema.categories.playlistId, playlistId)); const categoryIds = categories.map((c) => c.id); + // Save hidden categories info to restore after refresh + const hiddenCategories = categories + .filter((c) => c.hidden) + .map((c) => ({ + xtreamId: c.xtreamId, + type: c.type, + })); + // Before deleting content, save xtreamIds of favorited and recently viewed items // so they can be restored after refresh let favoritedXtreamIds: number[] = []; @@ -89,11 +102,12 @@ ipcMain.handle( .delete(schema.categories) .where(eq(schema.categories.playlistId, playlistId)); - // Return the saved xtreamIds so favorites and recently viewed can be restored + // Return the saved xtreamIds so favorites, recently viewed, and hidden categories can be restored return { success: true, favoritedXtreamIds, recentlyViewedXtreamIds, + hiddenCategories, }; } catch (error) { console.error('Error deleting Xtream content:', error); diff --git a/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts b/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts index 2ee794e14..18b022ef8 100644 --- a/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts +++ b/apps/web/src/app/xtream-tauri/data-sources/electron-xtream-data-source.ts @@ -98,10 +98,15 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { ); if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) { + // Check if there are saved hidden categories to restore + const hiddenCategoryXtreamIds = + this.getHiddenCategoryXtreamIds(playlistId, dbType); + await this.dbService.saveXtreamCategories( playlistId, remoteData, - dbType + dbType, + hiddenCategoryXtreamIds ); } @@ -109,6 +114,40 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { return this.dbService.getXtreamCategories(playlistId, dbType); } + /** + * Get hidden category xtreamIds from localStorage for a specific playlist and type + * Used to restore visibility preferences after playlist refresh + */ + private getHiddenCategoryXtreamIds( + playlistId: string, + type: 'live' | 'movies' | 'series' + ): number[] | undefined { + const restoreKey = `xtream-restore-${playlistId}`; + const restoreDataStr = localStorage.getItem(restoreKey); + + if (!restoreDataStr) { + return undefined; + } + + try { + const restoreData = JSON.parse(restoreDataStr); + const hiddenCategories = restoreData.hiddenCategories as + | { xtreamId: number; type: string }[] + | undefined; + + if (!hiddenCategories || hiddenCategories.length === 0) { + return undefined; + } + + // Filter for the specific type and return xtreamIds + return hiddenCategories + .filter((cat) => cat.type === type) + .map((cat) => cat.xtreamId); + } catch { + return undefined; + } + } + async getAllCategories( playlistId: string, type: DbCategoryType diff --git a/global.d.ts b/global.d.ts index 7ad9673e2..5efe28ee2 100644 --- a/global.d.ts +++ b/global.d.ts @@ -101,6 +101,10 @@ declare global { xtreamId: number; viewedAt: string; }[]; + hiddenCategories: { + xtreamId: number; + type: string; + }[]; }>; dbRestoreXtreamUserData: ( playlistId: string, @@ -121,7 +125,8 @@ declare global { dbSaveCategories: ( playlistId: string, categories: any[], - type: string + type: string, + hiddenCategoryXtreamIds?: number[] ) => Promise<{ success: boolean }>; dbGetAllCategories: ( playlistId: string, diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 41aec7929..e51f8498d 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -68,12 +68,13 @@ export class DatabaseService { /** * Delete all content and categories for an Xtream playlist (for refresh) * Keeps the playlist entry but removes all imported data - * Returns saved favorites and recently viewed xtreamIds for restoration + * Returns saved favorites, recently viewed xtreamIds, and hidden categories for restoration */ async deleteXtreamPlaylistContent(playlistId: string): Promise<{ success: boolean; favoritedXtreamIds: number[]; recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; + hiddenCategories: { xtreamId: number; type: string }[]; }> { return await window.electron.dbDeleteXtreamContent(playlistId); } @@ -160,13 +161,20 @@ export class DatabaseService { /** * Save categories in bulk + * Optionally accepts hidden category xtreamIds to restore visibility preferences */ async saveXtreamCategories( playlistId: string, categories: any[], - type: 'live' | 'movies' | 'series' + type: 'live' | 'movies' | 'series', + hiddenCategoryXtreamIds?: number[] ): Promise { - await window.electron.dbSaveCategories(playlistId, categories, type); + await window.electron.dbSaveCategories( + playlistId, + categories, + type, + hiddenCategoryXtreamIds + ); } /** diff --git a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts index b291f3b87..73e16ce99 100644 --- a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts @@ -222,11 +222,14 @@ export class RecentPlaylistsComponent { onConfirm: async () => { try { // Delete all content and categories for this playlist - // This returns the xtreamIds of favorites and recently viewed items - const { favoritedXtreamIds, recentlyViewedXtreamIds } = - await this.databaseService.deleteXtreamPlaylistContent( - item._id - ); + // This returns the xtreamIds of favorites, recently viewed items, and hidden categories + const { + favoritedXtreamIds, + recentlyViewedXtreamIds, + hiddenCategories, + } = await this.databaseService.deleteXtreamPlaylistContent( + item._id + ); const updateDate = Date.now(); @@ -250,6 +253,7 @@ export class RecentPlaylistsComponent { JSON.stringify({ favoritedXtreamIds, recentlyViewedXtreamIds, + hiddenCategories, }) ); From 6737270a58a21e55a80d8e90542714f543c1fb6d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Jan 2026 17:12:19 +0100 Subject: [PATCH 8/9] fix: provide XtreamStore to components --- .../src/app/stalker/stalker-shell/stalker-shell.component.ts | 5 +++-- .../recently-viewed/recently-viewed.component.ts | 3 +++ .../xtream-tauri/search-results/search-results.component.ts | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts index 8a186bc58..7ced6961d 100644 --- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts +++ b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts @@ -6,14 +6,15 @@ import { PlaylistActions, selectPlaylistById } from 'm3u-state'; import { map, switchMap } from 'rxjs'; import { NavigationComponent } from '../../xtream-tauri/navigation/navigation.component'; import { NavigationItem } from '../../xtream-tauri/navigation/navigation.interface'; +import { XtreamStore } from '../../xtream-tauri/stores/xtream.store'; import { StalkerStore } from '../stalker.store'; @Component({ selector: 'app-stalker-shell', templateUrl: './stalker-shell.component.html', styleUrls: ['./stalker-shell.component.scss'], - standalone: true, - imports: [NavigationComponent, RouterOutlet, NavigationComponent], + imports: [NavigationComponent, RouterOutlet], + providers: [XtreamStore], }) export class StalkerShellComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); diff --git a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts index ebfb87daa..5c24fea5a 100644 --- a/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/xtream-tauri/recently-viewed/recently-viewed.component.ts @@ -20,6 +20,9 @@ import { XtreamStore } from '../stores/xtream.store'; MatIconButton, TitleCasePipe, ], + providers: [ + XtreamStore + ], templateUrl: './recently-viewed.component.html', styleUrl: './recently-viewed.component.scss', }) diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts index 60198638c..b2a9e35e2 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts @@ -51,6 +51,9 @@ interface SearchResultsData { SearchResultItemComponent, TranslatePipe, ], + providers: [ + XtreamStore + ], templateUrl: './search-results.component.html', styleUrls: ['./search-results.component.scss'], }) From 7ee95bf606e369298d5304fadbbe1f5e5084acfc Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Jan 2026 17:13:56 +0100 Subject: [PATCH 9/9] docs: document xtream store, category management --- CLAUDE.md | 74 ++++++++++++++++++++++++ docs/architecture/category-management.md | 29 ++++++++-- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b885f245b..76d313a0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,80 @@ This is an Nx monorepo with the following structure: - Entity adapter pattern for managing playlists collection - Router store integration for route-based state +**XtreamStore Architecture** (Signal Store with Feature Composition): + +The Xtream Codes module uses NgRx Signal Store with a layered architecture: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PRESENTATION LAYER │ +│ Components use XtreamStore (facade) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ FACADE LAYER │ +│ XtreamStore │ +│ (Composes feature stores, unified API) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌────────────┬────────────┼────────────┬────────────┐ + ▼ ▼ ▼ ▼ ▼ +┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ +│ withPortal│ │withContent │ │withSelection│ │ withSearch │ │ withPlayer │ +└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘ + │ │ │ + └───────────────────────────┼──────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DATA SOURCE LAYER │ +│ IXtreamDataSource │ +│ ┌───────────────────┬───────────────────┐ │ +│ ▼ ▼ │ +│ ElectronDataSource PwaDataSource │ +│ (DB-first + API) (API-only) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +File structure: +``` +apps/web/src/app/xtream-tauri/ +├── stores/ +│ ├── features/ +│ │ ├── with-portal.feature.ts # Playlist & portal status +│ │ ├── with-content.feature.ts # Categories & streams +│ │ ├── with-selection.feature.ts # UI selection & pagination +│ │ ├── with-search.feature.ts # Search functionality +│ │ ├── with-epg.feature.ts # EPG data +│ │ ├── with-player.feature.ts # Stream URLs & player +│ │ └── index.ts +│ ├── xtream.store.ts # Facade composing all features +│ └── index.ts +├── services/ +│ ├── xtream-api.service.ts # Xtream Codes API calls +│ ├── xtream-url.service.ts # Stream URL construction +│ └── index.ts +├── data-sources/ +│ ├── xtream-data-source.interface.ts # Abstract interface + types +│ ├── electron-xtream-data-source.ts # DB-first implementation +│ ├── pwa-xtream-data-source.ts # API-only implementation +│ └── index.ts # Factory provider +└── with-favorites.feature.ts # Favorites (existing) +└── with-recent-items.ts # Recently viewed (existing) +``` + +Key patterns: +- **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality +- **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility +- **Data source abstraction**: `IXtreamDataSource` interface with environment-specific implementations +- **Factory injection**: `provideXtreamDataSource()` selects Electron or PWA implementation at runtime + +Data strategies by environment: +| Environment | Strategy | +|-------------|----------| +| **Electron** | DB-first: Check DB → fetch API if missing → cache to DB | +| **PWA** | API-only: Always fetch from API, store in memory | + **Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts` - Home/playlists overview: `/` diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index af20001e5..06ed9b54e 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -84,10 +84,21 @@ Added `reloadCategories()` method to refresh categories from database after visi ## Behavior Notes - **New categories**: When a playlist is refreshed, new categories from the remote API are added with `hidden = false` (visible by default) -- **Persistence**: Visibility settings survive playlist refresh +- **Persistence**: Visibility settings survive playlist refresh (see below) - **Per-playlist, per-type**: Categories are managed per playlist and per content type (live/movies/series) - **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database +### Visibility Preservation During Refresh + +When a user refreshes an Xtream playlist, hidden category preferences are preserved through the following mechanism: + +1. **Before deletion**: The `DB_DELETE_XTREAM_CONTENT` handler extracts and returns the `hidden` status of all categories (keyed by `xtreamId` and `type`) +2. **Temporary storage**: The hidden categories are stored in `localStorage` under key `xtream-restore-{playlistId}` along with favorites and recently viewed data +3. **During re-import**: When categories are saved via `DB_SAVE_CATEGORIES`, the data source checks `localStorage` for saved hidden category xtreamIds +4. **Restoration**: Categories matching the saved xtreamIds are inserted with `hidden = true`, preserving the user's visibility preferences + +This ensures that users don't lose their category visibility customizations when refreshing playlists to get updated content. + ## Files Changed ``` @@ -96,17 +107,23 @@ libs/shared/database/src/lib/ └── connection.ts # Added migration for existing databases apps/electron-backend/src/app/ -├── events/database/category.events.ts # New IPC handlers -└── api/main.preload.ts # Exposed new IPC methods +├── events/database/category.events.ts # IPC handlers (including hidden category restoration) +├── events/database/xtream.events.ts # Returns hidden categories during content deletion +└── api/main.preload.ts # Exposed new IPC methods (with hidden category params) libs/services/src/lib/ -└── database-electron.service.ts # New service methods +└── database-electron.service.ts # Service methods (with hidden category support) + +libs/ui/components/src/lib/recent-playlists/ +└── recent-playlists.component.ts # Stores hidden categories to localStorage on refresh apps/web/src/app/xtream-tauri/ -├── category-management-dialog/ # New dialog component +├── category-management-dialog/ # Dialog component │ ├── category-management-dialog.component.ts │ ├── category-management-dialog.component.html │ └── category-management-dialog.component.scss +├── data-sources/ +│ └── electron-xtream-data-source.ts # Reads/passes hidden categories on save ├── xtream-main-container.component.ts # Added button & dialog ├── xtream-main-container.component.html ├── live-stream-layout/ @@ -118,7 +135,7 @@ apps/web/src/app/xtream-tauri/ apps/web/src/assets/i18n/ └── en.json # Added translation keys -global.d.ts # TypeScript types for new IPC methods +global.d.ts # TypeScript types for IPC methods ``` ## Translation Keys