mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #765 from 4gray/fix/pre-v0-19-release-fixes
Fix/0.19 release fixes
This commit is contained in:
24 files changed
+776
-98
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
>
|
||||
<div
|
||||
*cdkVirtualFor="
|
||||
let item of channels() | filterBy: searchString() : 'title';
|
||||
let item of sortedChannels() | filterBy: searchString() : 'title';
|
||||
trackBy: trackBy;
|
||||
templateCacheSize: 0
|
||||
"
|
||||
|
||||
+100
-46
@@ -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('');
|
||||
}
|
||||
}
|
||||
+6
@@ -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 {
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
+70
-1
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user