diff --git a/CLAUDE.md b/CLAUDE.md
index 76d313a0e..471b679e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -185,6 +185,52 @@ Data strategies by environment:
| **Electron** | DB-first: Check DB → fetch API if missing → cache to DB |
| **PWA** | API-only: Always fetch from API, store in memory |
+**M3U Playlist Module Architecture**:
+
+The M3U playlist module handles traditional M3U/M3U8 playlists with support for 90,000+ channels.
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ VIDEO PLAYER PAGE │
+│ apps/web/src/app/home/video-player/ │
+├─────────────────────────────────────────────────────────────────────┤
+│ ┌─────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │
+│ │ Sidebar │ │ Video Player │ │ EPG List │ │
+│ │ │ │ (ArtPlayer/Video.js)│ │ (Right drawer) │ │
+│ │ ┌─────────┐ │ │ │ │ │ │
+│ │ │Channel │ │ │ │ │ │ │
+│ │ │List │ │ │ │ │ │ │
+│ │ │Container│ │ │ │ │ │ │
+│ │ └─────────┘ │ │ │ │ │ │
+│ └─────────────┘ └──────────────────────┘ └────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+Channel List Component Structure (parent coordinator pattern):
+```
+libs/ui/components/src/lib/channel-list-container/
+├── channel-list-container.component.ts # Parent - shared state coordinator
+├── all-channels-tab/ # Virtual scroll + debounced search
+├── groups-tab/ # Expansion panels + infinite scroll
+├── favorites-tab/ # CDK drag-drop reordering
+└── channel-list-item/ # Individual channel display
+```
+
+Key patterns:
+- **EnrichedChannel**: Pre-computed EPG data attached to channels for performance
+- **Parent coordinator**: Manages shared signals (`channelEpgMap`, `progressTick`, `favoriteIds`)
+- **Virtual scrolling**: CDK virtual scroll for 90,000+ channel lists
+- **Infinite scroll**: IntersectionObserver in groups tab loads 50 items at a time
+- **Global progress tick**: Single 30s interval instead of per-item intervals
+
+State management via NgRx (`libs/m3u-state/`):
+- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist
+- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive
+- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag
+- `FavoritesActions`: updateFavorites, setFavorites
+
+See `docs/architecture/m3u-playlist-module.md` for complete documentation.
+
**Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts`
- Home/playlists overview: `/`
diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts
index 2f0e3d1d7..8dc17730b 100644
--- a/apps/electron-backend/src/app/events/database/content.events.ts
+++ b/apps/electron-backend/src/app/events/database/content.events.ts
@@ -3,7 +3,7 @@
* Operations for managing and searching content (streams, movies, series)
*/
-import { and, desc, eq, inArray, or, sql } from 'drizzle-orm';
+import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { ipcMain } from 'electron';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
@@ -72,7 +72,7 @@ ipcMain.handle(
async (event, playlistId: string, type: 'live' | 'movie' | 'series') => {
try {
const db = await getDatabase();
- const result = await db
+ const baseQuery = db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
@@ -93,8 +93,11 @@ ipcMain.handle(
eq(schema.categories.playlistId, playlistId),
eq(schema.content.type, type)
)
- )
- .orderBy(desc(schema.content.added));
+ );
+
+ const result = await (type === 'live'
+ ? baseQuery.orderBy(asc(schema.content.id))
+ : baseQuery.orderBy(desc(schema.content.added)));
return result;
} catch (error) {
console.error('Error getting content:', error);
diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html
index b0c7b31b0..dc1290fb4 100644
--- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html
+++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html
@@ -14,7 +14,37 @@
-
{{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})
+
+ {{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})
+
+
+
+
+
+
+
} @else {
@if (selectedCategoryId()) {
-
+
} @else {
('server');
+ readonly liveChannelSortLabel = computed(() => {
+ const mode = this.liveChannelSortMode();
+ if (mode === 'name-asc') return 'Name A-Z';
+ if (mode === 'name-desc') return 'Name Z-A';
+ return 'Server Order';
+ });
readonly selectedCategoryInfo = computed(() => {
const categoryId = this.selectedCategoryId();
@@ -97,6 +110,17 @@ export class LiveStreamLayoutComponent implements OnInit {
favorites = new Map();
ngOnInit() {
+ const savedSortMode = localStorage.getItem(
+ LIVE_CHANNEL_SORT_STORAGE_KEY
+ );
+ if (
+ savedSortMode === 'server' ||
+ savedSortMode === 'name-asc' ||
+ savedSortMode === 'name-desc'
+ ) {
+ this.liveChannelSortMode.set(savedSortMode);
+ }
+
const playlist = this.xtreamStore.currentPlaylist();
if (playlist) {
this.favoritesService
@@ -173,4 +197,9 @@ export class LiveStreamLayoutComponent implements OnInit {
}
});
}
+
+ setLiveChannelSortMode(mode: LiveChannelSortMode): void {
+ this.liveChannelSortMode.set(mode);
+ localStorage.setItem(LIVE_CHANNEL_SORT_STORAGE_KEY, mode);
+ }
}
diff --git a/apps/web/src/app/xtream-tauri/portal-channels-list/portal-channels-list.component.html b/apps/web/src/app/xtream-tauri/portal-channels-list/portal-channels-list.component.html
index 2e1adf225..9534e50fd 100644
--- a/apps/web/src/app/xtream-tauri/portal-channels-list/portal-channels-list.component.html
+++ b/apps/web/src/app/xtream-tauri/portal-channels-list/portal-channels-list.component.html
@@ -18,7 +18,7 @@
>
();
+export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
+ readonly playClicked = output
();
+ readonly sortMode = input('server');
readonly xtreamStore = inject(XtreamStore);
private readonly favoritesService = inject(FavoritesService);
+ private readonly epgQueueService = inject(EpgQueueService);
private readonly route = inject(ActivatedRoute);
readonly channels = this.xtreamStore.selectItemsFromSelectedCategory;
+ readonly sortedChannels = computed(() => {
+ const mode = this.sortMode();
+ const channels = this.channels();
+ if (mode === 'server') {
+ return channels;
+ }
+
+ const collator = new Intl.Collator(undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ });
+
+ return [...channels].sort((a: any, b: any) => {
+ const titleA = a.title ?? a.name ?? '';
+ const titleB = b.title ?? b.name ?? '';
+ const result = collator.compare(titleA, titleB);
+ return mode === 'name-asc' ? result : -result;
+ });
+ });
favorites = new Map();
searchString = signal('');
currentPrograms = new Map();
currentProgramsProgress = new Map();
- programTimings = new Map(); // Changed to store timestamps
- private requestedChannels = new Set();
+ programTimings = new Map();
- @ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport;
+ readonly viewport = viewChild(CdkVirtualScrollViewport);
+
+ private subscriptions = new Subscription();
constructor(private cdr: ChangeDetectorRef) {}
@@ -86,58 +116,78 @@ export class PortalChannelsListComponent implements AfterViewInit {
});
});
}
+
+ // Subscribe to EPG results from the queue service
+ this.subscriptions.add(
+ this.epgQueueService.epgResult$.subscribe(
+ ({ streamId, items }) => {
+ if (items && items.length > 0) {
+ this.currentPrograms.set(streamId, items[0].title);
+ this.updateProgramProgress(
+ streamId,
+ items[0] as unknown as EpgProgram
+ );
+ this.cdr.detectChanges();
+ }
+ }
+ )
+ );
}
ngAfterViewInit() {
- if (
- this.viewport &&
- this.xtreamStore.selectedContentType() === 'live'
- ) {
- this.viewport.renderedRangeStream.subscribe((range) => {
- const visibleChannels = this.channels().slice(
- range.start,
- range.end
- );
- this.loadEpgForVisibleChannels(visibleChannels);
- });
+ const vp = this.viewport();
+ if (vp && this.xtreamStore.selectedContentType() === 'live') {
+ this.subscriptions.add(
+ vp.renderedRangeStream
+ .pipe(debounceTime(300))
+ .subscribe((range) => {
+ const visibleChannels = this.sortedChannels().slice(
+ range.start,
+ range.end
+ );
+ this.loadEpgForVisibleChannels(visibleChannels);
+ })
+ );
}
}
- private async loadEpgForVisibleChannels(channels: any[]): Promise {
+ private loadEpgForVisibleChannels(channels: any[]): void {
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) return;
+ const credentials: XtreamCredentials = {
+ serverUrl: playlist.serverUrl,
+ username: playlist.username,
+ password: playlist.password,
+ };
+
+ const visibleIds = new Set(
+ channels.map((ch) => ch.xtream_id)
+ );
+ const uncachedIds: number[] = [];
+
+ // Apply cached results immediately
for (const channel of channels) {
- // Skip if we already requested or have EPG data for this channel
- if (
- this.requestedChannels.has(channel.xtream_id) ||
- this.currentPrograms.has(channel.xtream_id)
- ) {
- continue;
- }
-
- // Mark as requested before making the API call
- this.requestedChannels.add(channel.xtream_id);
-
- try {
- const epgData = await this.xtreamStore.loadChannelEpg(
- channel.xtream_id
- );
- if (epgData && epgData.length > 0) {
+ const cached = this.epgQueueService.getCached(channel.xtream_id);
+ if (cached && cached.length > 0) {
+ if (!this.currentPrograms.has(channel.xtream_id)) {
this.currentPrograms.set(
channel.xtream_id,
- epgData[0].title
+ cached[0].title
+ );
+ this.updateProgramProgress(
+ channel.xtream_id,
+ cached[0] as unknown as EpgProgram
);
- this.updateProgramProgress(channel.xtream_id, epgData[0]);
- this.cdr.detectChanges();
}
- } catch (error) {
- console.error(
- `Failed to load EPG for channel ${channel.xtream_id}:`,
- error
- );
+ } else if (!this.currentPrograms.has(channel.xtream_id)) {
+ uncachedIds.push(channel.xtream_id);
}
}
+
+ if (uncachedIds.length > 0) {
+ this.epgQueueService.enqueue(uncachedIds, visibleIds, credentials);
+ }
}
private updateProgramProgress(streamId: number, program: EpgProgram) {
@@ -152,8 +202,8 @@ export class PortalChannelsListComponent implements AfterViewInit {
this.currentProgramsProgress.set(streamId, progress);
this.programTimings.set(streamId, {
- start: start * 1000, // Convert to milliseconds for date pipe
- end: end * 1000, // Convert to milliseconds for date pipe
+ start: start * 1000,
+ end: end * 1000,
});
}
}
@@ -180,4 +230,8 @@ export class PortalChannelsListComponent implements AfterViewInit {
this.cdr.detectChanges();
});
}
+
+ ngOnDestroy(): void {
+ this.subscriptions.unsubscribe();
+ }
}
diff --git a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html
index ffc0151e1..3ab6e4de6 100644
--- a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html
+++ b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html
@@ -74,6 +74,11 @@
(episodeClicked)="playEpisode($event)"
/>
+} @else if (detailsError()) {
+
} @else {
}
diff --git a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts
index 07a6a41b1..c38ca3ef9 100644
--- a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts
+++ b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts
@@ -28,6 +28,8 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
readonly selectedItem = this.xtreamStore.selectedItem;
readonly selectedContentType = this.xtreamStore.selectedContentType;
readonly isFavorite = this.xtreamStore.isFavorite;
+ readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
+ readonly detailsError = this.xtreamStore.detailsError;
ngOnInit(): void {
const { categoryId, serialId } = this.route.snapshot.params;
diff --git a/apps/web/src/app/xtream-tauri/services/epg-queue.service.ts b/apps/web/src/app/xtream-tauri/services/epg-queue.service.ts
new file mode 100644
index 000000000..41a23327e
--- /dev/null
+++ b/apps/web/src/app/xtream-tauri/services/epg-queue.service.ts
@@ -0,0 +1,134 @@
+import { inject, Injectable, OnDestroy } from '@angular/core';
+import { Subject } from 'rxjs';
+import { EpgItem } from 'shared-interfaces';
+import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
+
+interface CacheEntry {
+ data: EpgItem[];
+ timestamp: number;
+}
+
+/**
+ * Throttled EPG request queue with concurrency control, inter-request
+ * delay, and in-memory caching. Prevents Xtream providers from
+ * rate-limiting / banning the client when scrolling through large
+ * channel lists.
+ */
+@Injectable({ providedIn: 'root' })
+export class EpgQueueService implements OnDestroy {
+ private readonly apiService = inject(XtreamApiService);
+
+ private readonly cache = new Map();
+ private queue: number[] = [];
+ private readonly inFlight = new Set();
+ private visibleSet = new Set();
+ private processing = false;
+
+ private readonly maxConcurrency = 2;
+ private readonly delayMs = 200;
+ private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes
+
+ /** Emits EPG results as they arrive. */
+ readonly epgResult$ = new Subject<{ streamId: number; items: EpgItem[] }>();
+
+ /** Return cached EPG items if still valid, otherwise null. */
+ getCached(streamId: number): EpgItem[] | null {
+ const entry = this.cache.get(streamId);
+ if (!entry) return null;
+ if (Date.now() - entry.timestamp > this.cacheTtlMs) {
+ this.cache.delete(streamId);
+ return null;
+ }
+ return entry.data;
+ }
+
+ /**
+ * Enqueue stream IDs for EPG fetching.
+ *
+ * - IDs already cached or in-flight are skipped.
+ * - The internal queue is replaced so that stale IDs (no longer in
+ * `visibleIds`) are dropped on next dequeue.
+ */
+ enqueue(
+ streamIds: number[],
+ visibleIds: Set,
+ credentials: XtreamCredentials
+ ): void {
+ this.visibleSet = visibleIds;
+
+ const toFetch = streamIds.filter(
+ (id) =>
+ !this.getCached(id) &&
+ !this.inFlight.has(id)
+ );
+
+ // Replace the queue – any previously queued but now-invisible IDs
+ // will be skipped during processing via the visibleSet check.
+ this.queue = toFetch;
+
+ if (!this.processing) {
+ this.processQueue(credentials);
+ }
+ }
+
+ private async processQueue(credentials: XtreamCredentials): Promise {
+ this.processing = true;
+
+ while (this.queue.length > 0) {
+ // Wait until a concurrency slot is available
+ if (this.inFlight.size >= this.maxConcurrency) {
+ await this.delay(this.delayMs);
+ continue;
+ }
+
+ const streamId = this.queue.shift()!;
+
+ // Drop stale entries that are no longer visible
+ if (!this.visibleSet.has(streamId)) continue;
+
+ // Skip if it got cached while queued (e.g. duplicate)
+ if (this.getCached(streamId)) continue;
+
+ this.inFlight.add(streamId);
+ this.fetchEpg(credentials, streamId);
+
+ // Space out requests
+ await this.delay(this.delayMs);
+ }
+
+ this.processing = false;
+ }
+
+ private async fetchEpg(
+ credentials: XtreamCredentials,
+ streamId: number
+ ): Promise {
+ try {
+ const items = await this.apiService.getShortEpg(
+ credentials,
+ streamId,
+ 1
+ );
+ this.cache.set(streamId, {
+ data: items,
+ timestamp: Date.now(),
+ });
+ this.epgResult$.next({ streamId, items });
+ } catch (error) {
+ console.error(
+ `EpgQueueService: failed to load EPG for stream ${streamId}:`,
+ error
+ );
+ } finally {
+ this.inFlight.delete(streamId);
+ }
+ }
+
+ private delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ ngOnDestroy(): void {
+ this.epgResult$.complete();
+ }
+}
diff --git a/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts b/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts
index b2f5274c9..d50246601 100644
--- a/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts
+++ b/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts
@@ -271,6 +271,14 @@ export class XtreamApiService {
url,
params: serializedParams,
});
+
+ // The IPC layer catches errors and returns { type: 'ERROR', message, status }
+ // instead of rejecting. Convert that back into a thrown error so callers
+ // can handle it with .catch() / try-catch.
+ if (response?.type === 'ERROR' || (!response?.payload && response?.message)) {
+ throw new Error(response?.message ?? 'Request failed');
+ }
+
return response?.payload;
}
}
diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts
index 0a6f509ac..0c50b19d9 100644
--- a/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts
+++ b/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts
@@ -8,6 +8,12 @@ import {
} from '@ngrx/signals';
import { ContentType } from '../../xtream-state';
+export type XtreamCategorySortMode =
+ | 'date-desc'
+ | 'date-asc'
+ | 'name-asc'
+ | 'name-desc';
+
/**
* Selection state for managing UI selection and pagination
*/
@@ -17,7 +23,10 @@ export interface SelectionState {
selectedItem: any | null;
page: number;
limit: number;
+ contentSortMode: XtreamCategorySortMode;
+ categorySearchTerm: string;
isLoadingDetails: boolean;
+ detailsError: string | null;
}
/**
@@ -29,7 +38,10 @@ const initialSelectionState: SelectionState = {
selectedItem: null,
page: 0,
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
+ contentSortMode: 'date-desc',
+ categorySearchTerm: '',
isLoadingDetails: false,
+ detailsError: null,
};
/**
@@ -45,6 +57,51 @@ export function withSelection() {
withState(initialSelectionState),
withComputed((store) => {
+ const getItemDate = (item: any, categoryType: ContentType): number => {
+ const value =
+ categoryType === 'series'
+ ? item.last_modified ?? item.added
+ : item.added;
+ return parseInt(value ?? '', 10) || 0;
+ };
+
+ const sortByMode = (
+ items: any[],
+ sortMode: XtreamCategorySortMode,
+ categoryType: ContentType
+ ): any[] => {
+ const collator = new Intl.Collator(undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ });
+
+ return [...items].sort((a: any, b: any) => {
+ if (sortMode === 'date-desc') {
+ return getItemDate(b, categoryType) - getItemDate(a, categoryType);
+ }
+ if (sortMode === 'date-asc') {
+ return getItemDate(a, categoryType) - getItemDate(b, categoryType);
+ }
+
+ const titleA = a.title ?? a.name ?? '';
+ const titleB = b.title ?? b.name ?? '';
+ const byName = collator.compare(titleA, titleB);
+ return sortMode === 'name-asc' ? byName : -byName;
+ });
+ };
+
+ const filterBySearchTerm = (items: any[], searchTerm: string): any[] => {
+ const normalized = searchTerm.trim().toLocaleLowerCase();
+ if (!normalized) {
+ return items;
+ }
+
+ return items.filter((item: any) => {
+ const title = (item.title ?? item.name ?? '').toString();
+ return title.toLocaleLowerCase().includes(normalized);
+ });
+ };
+
// Memoized sorted content - only recalculates when content/type changes
const sortedContent = computed(() => {
const categoryType = store.selectedContentType();
@@ -56,21 +113,7 @@ export function withSelection() {
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
- return [...content].sort((a: any, b: any) => {
- const dateA =
- parseInt(
- categoryType === 'series'
- ? a.last_modified
- : a.added
- ) || 0;
- const dateB =
- parseInt(
- categoryType === 'series'
- ? b.last_modified
- : b.added
- ) || 0;
- return dateB - dateA;
- });
+ return sortByMode(content, 'date-desc', categoryType);
});
return {
@@ -136,6 +179,8 @@ export function withSelection() {
const endIndex = startIndex + store.limit();
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
+ const sortMode = store.contentSortMode();
+ const searchTerm = store.categorySearchTerm();
// Access parent store content (from withContent)
const storeAny = store as any;
@@ -152,6 +197,17 @@ export function withSelection() {
(item: any) =>
Number(item.category_id) === categoryId
);
+ if (categoryType === 'vod' || categoryType === 'series') {
+ filteredContent = filterBySearchTerm(
+ filteredContent,
+ searchTerm
+ );
+ filteredContent = sortByMode(
+ filteredContent,
+ sortMode,
+ categoryType
+ );
+ }
} else {
filteredContent = sortedContent();
}
@@ -165,6 +221,8 @@ export function withSelection() {
selectItemsFromSelectedCategory: computed(() => {
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
+ const sortMode = store.contentSortMode();
+ const searchTerm = store.categorySearchTerm();
// Access parent store content (from withContent)
const storeAny = store as any;
@@ -179,9 +237,18 @@ export function withSelection() {
return sortedContent();
}
- return content.filter(
+ const filteredContent = content.filter(
(item: any) => Number(item.category_id) === categoryId
);
+ if (categoryType === 'vod' || categoryType === 'series') {
+ return sortByMode(
+ filterBySearchTerm(filteredContent, searchTerm),
+ sortMode,
+ categoryType
+ );
+ }
+
+ return filteredContent;
}),
/**
@@ -190,6 +257,7 @@ export function withSelection() {
getTotalPages: computed(() => {
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
+ const searchTerm = store.categorySearchTerm();
// Access parent store content (from withContent)
const storeAny = store as any;
@@ -202,10 +270,14 @@ export function withSelection() {
let totalItems = 0;
if (categoryId) {
- totalItems = content.filter(
+ let filtered = content.filter(
(item: any) =>
Number(item.category_id) === categoryId
- ).length;
+ );
+ if (categoryType === 'vod' || categoryType === 'series') {
+ filtered = filterBySearchTerm(filtered, searchTerm);
+ }
+ totalItems = filtered.length;
} else {
totalItems = content.length;
}
@@ -273,6 +345,7 @@ export function withSelection() {
selectedContentType: type,
selectedCategoryId: null,
page: 0,
+ categorySearchTerm: '',
});
},
@@ -290,6 +363,7 @@ export function withSelection() {
patchState(store, {
selectedCategoryId: newCategoryId,
page: 0,
+ categorySearchTerm: '',
});
}
},
@@ -308,6 +382,13 @@ export function withSelection() {
patchState(store, { isLoadingDetails: isLoading });
},
+ /**
+ * Set the details error state
+ */
+ setDetailsError(error: string | null): void {
+ patchState(store, { detailsError: error });
+ },
+
/**
* Set the current page
*/
@@ -323,6 +404,26 @@ export function withSelection() {
localStorage.setItem('xtream-page-size', String(limit));
},
+ /**
+ * Set category content sort mode
+ */
+ setContentSortMode(mode: XtreamCategorySortMode): void {
+ patchState(store, {
+ contentSortMode: mode,
+ page: 0,
+ });
+ },
+
+ /**
+ * Set selected category search term
+ */
+ setCategorySearchTerm(term: string): void {
+ patchState(store, {
+ categorySearchTerm: term,
+ page: 0,
+ });
+ },
+
/**
* Reset selection state
*/
diff --git a/apps/web/src/app/xtream-tauri/stores/xtream.store.ts b/apps/web/src/app/xtream-tauri/stores/xtream.store.ts
index a7ad98b40..5516500e5 100644
--- a/apps/web/src/app/xtream-tauri/stores/xtream.store.ts
+++ b/apps/web/src/app/xtream-tauri/stores/xtream.store.ts
@@ -131,6 +131,7 @@ export const XtreamStore = signalStore(
if (!playlist) return;
store.setIsLoadingDetails(true);
+ store.setDetailsError(null);
xtreamApiService.getVodInfo({
serverUrl: playlist.serverUrl,
username: playlist.username,
@@ -143,6 +144,9 @@ export const XtreamStore = signalStore(
});
}).catch((error: unknown) => {
console.error('Error fetching VOD details:', error);
+ store.setDetailsError(
+ error instanceof Error ? error.message : 'Unknown error'
+ );
}).finally(() => {
store.setIsLoadingDetails(false);
});
@@ -159,6 +163,7 @@ export const XtreamStore = signalStore(
if (!playlist) return;
store.setIsLoadingDetails(true);
+ store.setDetailsError(null);
xtreamApiService.getSeriesInfo({
serverUrl: playlist.serverUrl,
username: playlist.username,
@@ -171,6 +176,9 @@ export const XtreamStore = signalStore(
});
}).catch((error: unknown) => {
console.error('Error fetching series details:', error);
+ store.setDetailsError(
+ error instanceof Error ? error.message : 'Unknown error'
+ );
}).finally(() => {
store.setIsLoadingDetails(false);
});
diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.html b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.html
index 2efde06a6..385a3e29a 100644
--- a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.html
+++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.html
@@ -135,6 +135,11 @@
}
+} @else if (detailsError()) {
+
} @else {
}
diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts
index b31b0ef4c..dedcd17f4 100644
--- a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts
+++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts
@@ -45,6 +45,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly isFavorite = this.xtreamStore.isFavorite;
readonly selectedItem = this.xtreamStore.selectedItem;
+ readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
+ readonly detailsError = this.xtreamStore.detailsError;
readonly hasPlaybackPosition = computed(() => {
const vodId = this.route.snapshot.params.vodId;
diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html
index 51b9e9d1b..621363de1 100644
--- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html
+++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html
@@ -33,12 +33,77 @@
@if (!xtreamStore.selectedItem() && !xtreamStore.isLoadingDetails()) {
}
{
+ const hasCategorySelected = this.selectedCategoryId() != null;
+ const type = this.xtreamStore.selectedContentType();
+ return hasCategorySelected && (type === 'vod' || type === 'series');
+ });
+ readonly contentSortLabel = computed(() => {
+ const mode = this.contentSortMode();
+ if (mode === 'date-asc') return 'Date Added (Oldest First)';
+ if (mode === 'name-asc') return 'Name A-Z';
+ if (mode === 'name-desc') return 'Name Z-A';
+ return 'Date Added (Latest First)';
+ });
ngOnInit(): void {
+ const savedSortMode = localStorage.getItem(XTREAM_CATEGORY_SORT_STORAGE_KEY);
+ if (
+ savedSortMode === 'date-desc' ||
+ savedSortMode === 'date-asc' ||
+ savedSortMode === 'name-asc' ||
+ savedSortMode === 'name-desc'
+ ) {
+ this.xtreamStore.setContentSortMode(savedSortMode);
+ }
+
const { categoryId } = this.route.snapshot.params;
if (categoryId)
this.xtreamStore.setSelectedCategory(Number(categoryId));
@@ -132,4 +167,17 @@ export class XtreamMainContainerComponent implements OnInit {
}
});
}
+
+ setContentSortMode(mode: XtreamCategorySortMode): void {
+ this.xtreamStore.setContentSortMode(mode);
+ localStorage.setItem(XTREAM_CATEGORY_SORT_STORAGE_KEY, mode);
+ }
+
+ onCategorySearchChange(term: string): void {
+ this.xtreamStore.setCategorySearchTerm(term);
+ }
+
+ clearCategorySearch(): void {
+ this.xtreamStore.setCategorySearchTerm('');
+ }
}
diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.scss b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.scss
index 3cb935f40..1f2156e4e 100644
--- a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.scss
@@ -3,6 +3,12 @@
.scroll-viewport {
min-height: calc(100vh - 190px);
overflow-x: hidden;
+ width: 100%;
+
+ ::ng-deep .cdk-virtual-scroll-content-wrapper {
+ width: 100%;
+ max-width: 100%;
+ }
}
.search-panel {
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss
index d5e003a78..7615cfc41 100644
--- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss
@@ -1,3 +1,10 @@
+:host {
+ display: block;
+ width: 100%;
+ height: 100%;
+ container-type: inline-size;
+}
+
::ng-deep {
.mat-mdc-tab.mdc-tab {
padding: 0 5px;
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss
index 187e74762..8f131b5ae 100644
--- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss
@@ -1,6 +1,6 @@
// Main channel list item container
.channel-list-item {
- width: 360px;
+ width: 100%;
display: flex;
align-items: center;
height: 68px;
@@ -9,6 +9,9 @@
cursor: pointer;
transition: background-color 0.2s ease;
gap: 12px;
+ box-sizing: border-box;
+ min-width: 0;
+ overflow: hidden;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
@@ -224,3 +227,69 @@
}
}
}
+
+// ============================================================================
+// Container queries for responsive sidebar resizing
+// ============================================================================
+
+@container (max-width: 350px) {
+ .channel-list-item {
+ gap: 8px;
+ padding: 6px 8px;
+ }
+
+ .channel-logo {
+ width: 36px;
+ height: 36px;
+ }
+
+ // Hide EPG timeline (progress bar + times) but keep program title
+ .epg-timeline {
+ display: none;
+ }
+
+ .epg-placeholder {
+ display: none;
+ }
+
+ // Hide info button, keep only favorite
+ .program-info-button {
+ display: none;
+ }
+}
+
+@container (max-width: 280px) {
+ .channel-logo {
+ width: 28px;
+ height: 28px;
+ }
+
+ // Hide all EPG content
+ .epg-container {
+ display: none;
+ }
+
+ .channel-list-item {
+ height: 48px;
+
+ &.compact {
+ height: 40px;
+ }
+ }
+
+ // Hide action buttons
+ .action-buttons {
+ display: none;
+ }
+}
+
+@container (max-width: 200px) {
+ .channel-logo {
+ display: none;
+ }
+
+ .channel-list-item {
+ gap: 4px;
+ padding: 4px 6px;
+ }
+}
diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html
index 872e143ed..9c3bb8e1e 100644
--- a/libs/ui/components/src/lib/content-hero/content-hero.component.html
+++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html
@@ -4,7 +4,13 @@
- @if (isLoading) {
+ @if (errorMessage()) {
+
+ cloud_off
+
{{ errorMessage() }}
+
+
+ } @else if (isLoading()) {
@@ -85,12 +91,12 @@
@@ -102,10 +108,10 @@
- @if (posterUrl) {
+ @if (posterUrl()) {
-
{{ title }}
+
{{ title() }}
@@ -127,9 +133,9 @@
- @if (description) {
+ @if (description()) {
- {{ description }}
+ {{ description() }}
}
@@ -148,4 +154,4 @@
}
-
\ 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 d1fe1cb28..93d725ce6 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
@@ -179,6 +179,29 @@
display: none;
}
}
+
+ // Error state
+ &__error {
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ gap: 16px;
+
+ .error-icon {
+ font-size: 64px;
+ width: 64px;
+ height: 64px;
+ color: var(--text-muted);
+ }
+
+ .error-title {
+ font-size: 1.25rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin: 0;
+ }
+ }
}
// ============================================================================
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 2dcfff209..75fb970c2 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
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
-import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { Component, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
@@ -17,13 +17,14 @@ import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
styleUrls: ['./content-hero.component.scss'],
})
export class ContentHeroComponent {
- @Input() title: string | undefined;
- @Input() description: string | undefined;
- @Input() posterUrl: string | undefined;
- @Input() backdropUrl: string | undefined;
- @Input() isLoading = false;
+ readonly title = input
();
+ readonly description = input();
+ readonly posterUrl = input();
+ readonly backdropUrl = input();
+ readonly isLoading = input(false);
+ readonly errorMessage = input();
- @Output() backClicked = new EventEmitter();
+ readonly backClicked = output();
onBack(): void {
this.backClicked.emit();