Merge pull request #765 from 4gray/fix/pre-v0-19-release-fixes

Fix/0.19 release fixes
This commit is contained in:
4gray authored and GitHub committed 2026-02-13 20:59:38 +01:00
commit 12bbdf562a
24 files changed
+776 -98

No files matched your search

+46
View File
@@ -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: `/`
@@ -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);
@@ -14,7 +14,37 @@
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2>{{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})</h2>
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})
</h2>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button mat-menu-item (click)="setLiveChannelSortMode('server')">
<span>Server Order</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-asc')">
<span>Name A-Z</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-desc')">
<span>Name Z-A</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
} @else {
<app-playlist-switcher
class="switcher"
@@ -32,7 +62,10 @@
}
</div>
@if (selectedCategoryId()) {
<app-portal-channels-list (playClicked)="playLive($event)" />
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
(playClicked)="playLive($event)"
/>
} @else {
<app-category-view
[items]="categories()"
@@ -70,6 +70,11 @@
}
}
.category-title {
flex: 1;
margin: 0;
}
.no-channel-selected {
padding: 16px;
margin: 0;
@@ -7,6 +7,7 @@ import {
inject,
Injector,
OnInit,
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
@@ -16,6 +17,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
@@ -34,6 +36,9 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../stores/xtream.store';
type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@Component({
selector: 'app-live-stream-layout',
templateUrl: './live-stream-layout.component.html',
@@ -47,6 +52,7 @@ import { XtreamStore } from '../stores/xtream.store';
MatIconButton,
MatInputModule,
MatListModule,
MatMenuModule,
MatTooltipModule,
/* MpvPlayerBarComponent, */
PlaylistSwitcherComponent,
@@ -69,6 +75,13 @@ export class LiveStreamLayoutComponent implements OnInit {
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly epgItems = this.xtreamStore.epgItems;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly liveChannelSortMode = signal<LiveChannelSortMode>('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<number, boolean>();
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);
}
}
@@ -18,7 +18,7 @@
>
<div
*cdkVirtualFor="
let item of channels() | filterBy: searchString() : 'title';
let item of sortedChannels() | filterBy: searchString() : 'title';
trackBy: trackBy;
templateCacheSize: 0
"
@@ -7,11 +7,13 @@ import {
AfterViewInit,
ChangeDetectorRef,
Component,
EventEmitter,
computed,
inject,
Output,
input,
OnDestroy,
output,
signal,
ViewChild,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -20,7 +22,11 @@ import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute } from '@angular/router';
import { FilterPipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { XtreamCategory, XtreamItem } from 'shared-interfaces';
import { EpgQueueService } from '../services/epg-queue.service';
import { XtreamCredentials } from '../services/xtream-api.service';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../stores/xtream.store';
@@ -33,6 +39,8 @@ interface EpgProgram {
stop_timestamp: string;
}
type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
@Component({
selector: 'app-portal-channels-list',
templateUrl: './portal-channels-list.component.html',
@@ -48,22 +56,44 @@ interface EpgProgram {
TranslatePipe,
],
})
export class PortalChannelsListComponent implements AfterViewInit {
@Output() playClicked = new EventEmitter<any>();
export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
readonly playClicked = output<any>();
readonly sortMode = input<LiveChannelSortMode>('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<number, boolean>();
searchString = signal<string>('');
currentPrograms = new Map<number, string>();
currentProgramsProgress = new Map<number, number>();
programTimings = new Map<number, { start: number; end: number }>(); // Changed to store timestamps
private requestedChannels = new Set<number>();
programTimings = new Map<number, { start: number; end: number }>();
@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<void> {
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<number>(
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();
}
}
@@ -74,6 +74,11 @@
(episodeClicked)="playEpisode($event)"
/>
</app-content-hero>
} @else if (detailsError()) {
<app-content-hero
[errorMessage]="detailsError()"
(backClicked)="goBack()"
/>
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
}
@@ -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;
@@ -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<number, CacheEntry>();
private queue: number[] = [];
private readonly inFlight = new Set<number>();
private visibleSet = new Set<number>();
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<number>,
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<void> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
ngOnDestroy(): void {
this.epgResult$.complete();
}
}
@@ -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;
}
}
@@ -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<SelectionState>(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
*/
@@ -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);
});
@@ -135,6 +135,11 @@
</div>
}
</app-content-hero>
} @else if (detailsError()) {
<app-content-hero
[errorMessage]="detailsError()"
(backClicked)="goBack()"
/>
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
}
@@ -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;
@@ -33,12 +33,77 @@
<div class="main-content">
@if (!xtreamStore.selectedItem() && !xtreamStore.isLoadingDetails()) {
<div class="content-header">
<h2>
<button mat-icon-button disabled>
<mat-icon>category</mat-icon>
</button>
{{ getContentLabel() }}
</h2>
<div class="header-left">
<h2>
<button mat-icon-button disabled>
<mat-icon>category</mat-icon>
</button>
{{ getContentLabel() }}
</h2>
</div>
@if (canShowSortMenu()) {
<div class="header-actions">
<mat-form-field
class="category-search"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
spellcheck="false"
[ngModel]="categorySearchTerm()"
(ngModelChange)="onCategorySearchChange($event)"
placeholder="Search in category"
/>
@if (categorySearchTerm()) {
<button
mat-icon-button
matSuffix
aria-label="Clear search"
(click)="clearCategorySearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<button
mat-icon-button
[matMenuTriggerFor]="xtreamSortMenu"
[matTooltip]="'Sort: ' + contentSortLabel()"
aria-label="Sort content"
>
<mat-icon>sort</mat-icon>
</button>
</div>
<mat-menu #xtreamSortMenu="matMenu">
<button mat-menu-item (click)="setContentSortMode('date-desc')">
<span>Date Added (Latest First)</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('date-asc')">
<span>Date Added (Oldest First)</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('name-asc')">
<span>Name A-Z</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setContentSortMode('name-desc')">
<span>Name Z-A</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
}
</div>
}
<div
@@ -30,6 +30,9 @@
.content-header {
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
justify-content: space-between;
app-region: drag;
h2 {
@@ -46,6 +49,21 @@
}
}
.header-left {
min-width: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
app-region: no-drag;
}
.category-search {
width: 240px;
}
.scrollable-content {
flex: 1;
overflow-y: auto;
@@ -1,7 +1,11 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, computed, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
@@ -13,6 +17,9 @@ import {
} from './category-management-dialog/category-management-dialog.component';
import { CategoryViewComponent } from './category-view/category-view.component';
import { XtreamStore } from './stores/xtream.store';
import { XtreamCategorySortMode } from './stores/features/with-selection.feature';
const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
@Component({
selector: 'app-xtream-main-container',
@@ -20,8 +27,12 @@ import { XtreamStore } from './stores/xtream.store';
styleUrls: ['./xtream-main-container.component.scss', './sidebar.scss'],
imports: [
CategoryViewComponent,
FormsModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
MatMenuModule,
MatTooltipModule,
PlaylistSwitcherComponent,
ResizableDirective,
@@ -40,8 +51,32 @@ export class XtreamMainContainerComponent implements OnInit {
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly contentSortMode = this.xtreamStore.contentSortMode;
readonly categorySearchTerm = this.xtreamStore.categorySearchTerm;
readonly canShowSortMenu = computed(() => {
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('');
}
}
@@ -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 {
@@ -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;
@@ -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;
}
}
@@ -4,7 +4,13 @@
<button class="hero__back-button" (click)="onBack()">
<mat-icon>arrow_back</mat-icon>
</button>
@if (isLoading) {
@if (errorMessage()) {
<div class="hero__content hero__error">
<mat-icon class="error-icon">cloud_off</mat-icon>
<h2 class="error-title">{{ errorMessage() }}</h2>
<ng-content select="[hero-error-actions]"></ng-content>
</div>
} @else if (isLoading()) {
<div class="hero__content">
<!-- Skeleton Poster -->
<div class="poster">
@@ -85,12 +91,12 @@
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="!backdropUrl"
[class.hero__backdrop--blurred]="!backdropUrl()"
[style.backgroundImage]="
backdropUrl
? 'url(' + backdropUrl + ')'
: posterUrl
? 'url(' + posterUrl + ')'
backdropUrl()
? 'url(' + backdropUrl() + ')'
: posterUrl()
? 'url(' + posterUrl() + ')'
: 'none'
"
></div>
@@ -102,10 +108,10 @@
<div class="hero__content">
<!-- Poster Card -->
<div class="poster">
@if (posterUrl) {
@if (posterUrl()) {
<img
class="poster__image"
[src]="posterUrl"
[src]="posterUrl()"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
@@ -119,7 +125,7 @@
<!-- Details -->
<div class="details">
<h1 class="details__title">{{ title }}</h1>
<h1 class="details__title">{{ title() }}</h1>
<!-- Tags Slot -->
<div class="details__tags">
@@ -127,9 +133,9 @@
</div>
<!-- Description -->
@if (description) {
@if (description()) {
<p class="details__description">
{{ description }}
{{ description() }}
</p>
}
@@ -148,4 +154,4 @@
</div>
</div>
}
</section>
</section>
@@ -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;
}
}
}
// ============================================================================
@@ -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<string>();
readonly description = input<string>();
readonly posterUrl = input<string>();
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
@Output() backClicked = new EventEmitter<void>();
readonly backClicked = output<void>();
onBack(): void {
this.backClicked.emit();