mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #726 from 4gray/fix/pre-release-fixes-v0-18
fix/pre-release-fixes-v0-18
This commit is contained in:
52 files changed
+1067
-532
No files matched your search
@@ -28,6 +28,16 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
) => {
|
||||
ipcRenderer.on('EPG_PROGRESS_UPDATE', (_event, data) => callback(data));
|
||||
},
|
||||
// DB save content progress listener
|
||||
onDbSaveContentProgress: (callback: (count: number) => void) => {
|
||||
ipcRenderer.on('DB_SAVE_CONTENT_PROGRESS', (_event, count) =>
|
||||
callback(count)
|
||||
);
|
||||
},
|
||||
// Remove DB save content progress listener
|
||||
removeDbSaveContentProgress: () => {
|
||||
ipcRenderer.removeAllListeners('DB_SAVE_CONTENT_PROGRESS');
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
platform: process.platform,
|
||||
fetchPlaylistByUrl: (url: string, title?: string) =>
|
||||
|
||||
@@ -184,8 +184,8 @@ ipcMain.handle(
|
||||
);
|
||||
totalInserted += chunk.length;
|
||||
|
||||
// Send progress update
|
||||
event.sender.send('DB_SAVE_CONTENT_PROGRESS', totalInserted);
|
||||
// Send incremental progress update (chunk size, not cumulative total)
|
||||
event.sender.send('DB_SAVE_CONTENT_PROGRESS', chunk.length);
|
||||
}
|
||||
|
||||
return { success: true, count: totalInserted };
|
||||
|
||||
@@ -87,11 +87,7 @@ ipcMain.handle('DB_GET_RECENT_ITEMS', async (event, playlistId: string) => {
|
||||
schema.content,
|
||||
eq(schema.recentlyViewed.contentId, schema.content.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(eq(schema.categories.playlistId, playlistId))
|
||||
.where(eq(schema.recentlyViewed.playlistId, playlistId))
|
||||
.orderBy(desc(schema.recentlyViewed.viewedAt))
|
||||
.limit(100);
|
||||
return result;
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { EpgProgressPanelComponent } from './shared/epg-progress-panel/epg-progress-panel.component';
|
||||
import { RecentlyViewedComponent } from './xtream-tauri/recently-viewed/recently-viewed.component';
|
||||
import { SearchResultsComponent } from './xtream-tauri/search-results/search-results.component';
|
||||
import { GlobalRecentlyViewedComponent } from './xtream-tauri/recently-viewed/global-recently-viewed.component';
|
||||
import { GlobalSearchResultsComponent } from './xtream-tauri/search-results/global-search-results.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -147,7 +147,7 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
|
||||
openGlobalSearch(): void {
|
||||
this.dialog.open(SearchResultsComponent, {
|
||||
this.dialog.open(GlobalSearchResultsComponent, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
@@ -157,7 +157,7 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
|
||||
openGlobalRecent(): void {
|
||||
this.dialog.open(RecentlyViewedComponent, {
|
||||
this.dialog.open(GlobalRecentlyViewedComponent, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
PLAYLIST_UPDATE,
|
||||
XTREAM_RESPONSE,
|
||||
XTREAM_REQUEST,
|
||||
} from 'shared-interfaces';
|
||||
import { AppConfig } from '../../environments/environment';
|
||||
|
||||
@@ -69,7 +70,7 @@ export class ElectronService extends DataService {
|
||||
title: string;
|
||||
}
|
||||
);
|
||||
} else if (type === 'XTREAM_REQUEST') {
|
||||
} else if (type === XTREAM_REQUEST) {
|
||||
return await this.forwardXtreamRequest(
|
||||
payload as { url: string; params: Record<string, string> }
|
||||
);
|
||||
@@ -322,20 +323,4 @@ export class ElectronService extends DataService {
|
||||
getAppEnvironment(): string {
|
||||
return 'electron';
|
||||
}
|
||||
|
||||
async fetchData(url: string, queryParams: Params) {
|
||||
const urlObject = new URL(url);
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
urlObject.searchParams.append(key, value);
|
||||
});
|
||||
const response = await fetch(urlObject.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Error: ${response.statusText} (Status: ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -285,8 +285,4 @@ export class PwaService extends DataService {
|
||||
getAppEnvironment(): string {
|
||||
return 'pwa';
|
||||
}
|
||||
|
||||
fetchData(url: string, queryParams: Params) {
|
||||
// not implemented
|
||||
}
|
||||
}
|
||||
+63
-20
@@ -25,9 +25,6 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
@if (description) {
|
||||
<p>{{ description }}</p>
|
||||
}
|
||||
@if (type) {
|
||||
<div class="type-badge" [class]="type">{{ type }}</div>
|
||||
}
|
||||
@if (showPlaylistInfo && playlistName) {
|
||||
<div class="playlist-badge">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
@@ -40,46 +37,92 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
aspect-ratio: 2/3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
h4 {
|
||||
margin: 0;
|
||||
padding: 10px 0 2px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3em;
|
||||
height: 2.6em; /* Fixed height for 2 lines */
|
||||
color: var(--text-color, #fff);
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0 0 10px 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2em;
|
||||
height: 1.2em; /* Fixed height for 1 line */
|
||||
color: var(--text-color-secondary, rgba(255, 255, 255, 0.7));
|
||||
opacity: 0.9; /* Improved readability */
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
margin-top: auto;
|
||||
padding: 0 0 10px 0;
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
color: var(--text-color-secondary, inherit);
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
@@ -93,4 +136,4 @@ export class SearchResultItemComponent {
|
||||
@Input() showPlaylistInfo = false;
|
||||
|
||||
@Output() itemClick = new EventEmitter<void>();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,31 @@
|
||||
<div class="search-page">
|
||||
<div class="header">
|
||||
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
|
||||
</div>
|
||||
|
||||
@if (itemDetails) {
|
||||
<button
|
||||
mat-button
|
||||
(click)="itemDetails = null"
|
||||
[attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate"
|
||||
class="back-button"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
|
||||
</button>
|
||||
@switch (selectedFilterType()) {
|
||||
@case ('vod') {
|
||||
<app-vod-details
|
||||
[item]="itemDetails"
|
||||
(playClicked)="
|
||||
createLinkToPlayVodItv(
|
||||
itemDetails.cmd,
|
||||
itemDetails.info.name,
|
||||
itemDetails.info.movie_image
|
||||
)
|
||||
"
|
||||
(addToFavoritesClicked)="addToFavorites($event)"
|
||||
(removeFromFavoritesClicked)="removeFromFavorites($any($event))"
|
||||
/>
|
||||
}
|
||||
@case ('series') {
|
||||
<app-stalker-series-view />
|
||||
}
|
||||
@if (itemDetails) {
|
||||
@switch (selectedFilterType()) {
|
||||
@case ('vod') {
|
||||
<app-vod-details
|
||||
[item]="itemDetails"
|
||||
(playClicked)="
|
||||
createLinkToPlayVodItv(
|
||||
itemDetails.cmd,
|
||||
itemDetails.info.name,
|
||||
itemDetails.info.movie_image
|
||||
)
|
||||
"
|
||||
(addToFavoritesClicked)="addToFavorites($event)"
|
||||
(removeFromFavoritesClicked)="removeFromFavorites($any($event))"
|
||||
(backClicked)="itemDetails = null"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
@case ('series') {
|
||||
<app-stalker-series-view (backClicked)="itemDetails = null" />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="search-page">
|
||||
@if (!itemDetails) {
|
||||
<div class="header">
|
||||
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
|
||||
</div>
|
||||
<app-search-form
|
||||
[singleSelection]="true"
|
||||
[(searchTerm)]="searchTerm"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Component, inject, resource, signal } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -25,8 +23,6 @@ import { StalkerStore } from '../stalker.store';
|
||||
selector: 'app-stalker-search',
|
||||
imports: [
|
||||
MatCardModule,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
PlaylistErrorViewComponent,
|
||||
SearchFormComponent,
|
||||
@@ -38,10 +34,6 @@ import { StalkerStore } from '../stalker.store';
|
||||
templateUrl: './stalker-search.component.html',
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { selectActivePlaylist } from 'm3u-state';
|
||||
import { DataService } from 'services';
|
||||
import { XtreamApiService } from '../services/xtream-api.service';
|
||||
import { XtreamAccountInfo } from './account-info.interface';
|
||||
|
||||
@Component({
|
||||
@@ -20,7 +20,7 @@ export class AccountInfoComponent {
|
||||
liveStreamsCount: number;
|
||||
seriesCount: number;
|
||||
}>(MAT_DIALOG_DATA);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly xtreamApiService = inject(XtreamApiService);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
accountInfo: XtreamAccountInfo;
|
||||
@@ -44,14 +44,11 @@ export class AccountInfoComponent {
|
||||
if (!playlist) return;
|
||||
|
||||
try {
|
||||
this.accountInfo = await this.dataService.fetchData(
|
||||
`${playlist.serverUrl}/player_api.php`,
|
||||
{
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
action: 'get_account_info',
|
||||
}
|
||||
);
|
||||
this.accountInfo = await this.xtreamApiService.getAccountInfo({
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
});
|
||||
|
||||
if (this.accountInfo) {
|
||||
this.formattedExpDate = new Date(
|
||||
@@ -73,4 +70,4 @@ export class AccountInfoComponent {
|
||||
get isTrial(): boolean {
|
||||
return this.accountInfo?.user_info?.is_trial === '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-8
@@ -53,11 +53,15 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
|
||||
// Only set category if it's different from the currently selected one
|
||||
// This preserves the page state when navigating back from detail view
|
||||
if (
|
||||
categoryId &&
|
||||
this.store.selectedCategoryId() !== Number(categoryId)
|
||||
) {
|
||||
this.store.setSelectedCategory(categoryId);
|
||||
if (categoryId) {
|
||||
if (this.store.selectedCategoryId() !== Number(categoryId)) {
|
||||
this.store.setSelectedCategory(categoryId);
|
||||
}
|
||||
} else {
|
||||
// No categoryId in route means "All Items"
|
||||
if (this.store.selectedCategoryId() !== null) {
|
||||
this.store.setSelectedCategory(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +103,17 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
if (this.isStalker) {
|
||||
this.store.setSelectedItem(selectedItem);
|
||||
} else {
|
||||
this.router.navigate([item.xtream_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
// When viewing "Recently Added" (no category selected), include category_id in path
|
||||
const categoryId = this.store.selectedCategoryId();
|
||||
if (categoryId) {
|
||||
this.router.navigate([item.xtream_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
} else {
|
||||
this.router.navigate([item.category_id, item.xtream_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -32,7 +32,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="search-field"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
@@ -59,6 +63,9 @@
|
||||
>
|
||||
</mat-checkbox>
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
<span class="item-count"
|
||||
>({{ getItemCount(category) }})</span
|
||||
>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-message">
|
||||
|
||||
+11
-2
@@ -1,3 +1,13 @@
|
||||
::ng-deep .mat-mdc-dialog-content {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.mat-mdc-dialog-content {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -11,7 +21,6 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -53,7 +62,7 @@
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
padding: 2px 10px;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
+7
@@ -18,6 +18,7 @@ import { DatabaseService, XCategoryFromDb } from 'services';
|
||||
export interface CategoryManagementDialogData {
|
||||
playlistId: string;
|
||||
contentType: 'live' | 'vod' | 'series';
|
||||
itemCounts: Map<number, number>;
|
||||
}
|
||||
|
||||
interface CategoryWithSelection extends XCategoryFromDb {
|
||||
@@ -104,6 +105,12 @@ export class CategoryManagementDialogComponent implements OnInit {
|
||||
this.searchTerm.set('');
|
||||
}
|
||||
|
||||
getItemCount(category: CategoryWithSelection): number {
|
||||
// content.category_id references categories.id (internal DB id), not xtream_id
|
||||
const categoryId = Number(category.id);
|
||||
return this.data.itemCounts.get(categoryId) ?? 0;
|
||||
}
|
||||
|
||||
private getDbType(): 'live' | 'movies' | 'series' {
|
||||
switch (this.data.contentType) {
|
||||
case 'live':
|
||||
|
||||
@@ -32,7 +32,9 @@ export class CategoryViewComponent {
|
||||
}
|
||||
|
||||
getItemCount(item: XtreamCategory): number {
|
||||
const itemId = Number((item as any).category_id ?? item.id);
|
||||
// content.category_id references categories.id (internal DB id)
|
||||
// For DB format categories, use id; for API format, use category_id
|
||||
const itemId = Number(item.id ?? (item as any).category_id);
|
||||
return this.itemCounts().get(itemId) ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
async getContent(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
type: StreamType
|
||||
type: StreamType,
|
||||
onProgress?: (count: number) => void,
|
||||
onTotal?: (total: number) => void
|
||||
): Promise<XtreamContentItem[]> {
|
||||
// Check if we have cached data
|
||||
const exists = await this.dbService.hasXtreamContent(playlistId, type);
|
||||
@@ -198,11 +200,17 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
const remoteData = await this.apiService.getStreams(credentials, type);
|
||||
|
||||
if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) {
|
||||
// Save to cache
|
||||
// Report total items to import
|
||||
if (onTotal) {
|
||||
onTotal(remoteData.length);
|
||||
}
|
||||
|
||||
// Save to cache with progress tracking
|
||||
await this.dbService.saveXtreamContent(
|
||||
playlistId,
|
||||
remoteData as any[],
|
||||
type
|
||||
type,
|
||||
onProgress
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
async getContent(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
type: StreamType
|
||||
type: StreamType,
|
||||
onProgress?: (count: number) => void,
|
||||
onTotal?: (total: number) => void
|
||||
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
|
||||
const cacheKey = `${playlistId}-${type}-content`;
|
||||
|
||||
@@ -179,6 +181,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
// Fetch from API
|
||||
const content = await this.apiService.getStreams(credentials, type);
|
||||
|
||||
// Report total and progress (PWA doesn't have incremental save, so report all at once)
|
||||
if (onTotal) {
|
||||
onTotal(content.length);
|
||||
}
|
||||
if (onProgress) {
|
||||
onProgress(content.length);
|
||||
}
|
||||
|
||||
// Cache in memory
|
||||
this.contentCache.set(cacheKey, content);
|
||||
|
||||
|
||||
@@ -199,11 +199,15 @@ export interface IXtreamDataSource {
|
||||
|
||||
/**
|
||||
* Get content/streams for a playlist and type
|
||||
* @param onProgress - Optional callback for import progress (count)
|
||||
* @param onTotal - Optional callback for total items to import
|
||||
*/
|
||||
getContent(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
type: StreamType
|
||||
type: StreamType,
|
||||
onProgress?: ProgressCallback,
|
||||
onTotal?: (total: number) => void
|
||||
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[] | XtreamContentItem[]>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,6 +118,7 @@ export class LiveStreamLayoutComponent implements OnInit {
|
||||
data: {
|
||||
playlistId,
|
||||
contentType,
|
||||
itemCounts: this.categoryItemCounts(),
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '80vh',
|
||||
|
||||
@@ -61,6 +61,18 @@
|
||||
{{ 'PORTALS.SIDEBAR.LIBRARY' | translate }}
|
||||
</div> -->
|
||||
<mat-nav-list>
|
||||
@if (!isStalkerPlaylist()) {
|
||||
<a
|
||||
mat-list-item
|
||||
routerLink="./recently-added"
|
||||
routerLinkActive="active"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate"
|
||||
matTooltipPosition="right"
|
||||
(click)="pageSwitch('recently-added')"
|
||||
>
|
||||
<mat-icon matListItemIcon>new_releases</mat-icon>
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
mat-list-item
|
||||
routerLink="./search"
|
||||
|
||||
@@ -37,7 +37,7 @@ export class NavigationComponent {
|
||||
readonly selectedContentType = input<string>();
|
||||
|
||||
readonly categoryClick = output<string>();
|
||||
readonly pageClicked = output<'search' | 'recent' | 'favorites'>();
|
||||
readonly pageClicked = output<'search' | 'recent' | 'favorites' | 'recently-added'>();
|
||||
|
||||
readonly currentPlaylist = this.store.selectSignal(
|
||||
selectPlaylistById(this.activatedRoute.snapshot.params.id)
|
||||
@@ -113,7 +113,7 @@ export class NavigationComponent {
|
||||
});
|
||||
}
|
||||
|
||||
pageSwitch(page: 'search' | 'recent' | 'favorites') {
|
||||
pageSwitch(page: 'search' | 'recent' | 'favorites' | 'recently-added') {
|
||||
this.pageClicked.emit(page);
|
||||
this.xtreamStore.setSelectedContentType(undefined);
|
||||
}
|
||||
|
||||
@@ -89,14 +89,11 @@ export class PortalChannelsListComponent implements AfterViewInit {
|
||||
this.favoritesService
|
||||
.getFavorites(playlist.id)
|
||||
.subscribe((favorites) => {
|
||||
// Map using content.id instead of xtream_id
|
||||
favorites.forEach((fav: any) => {
|
||||
this.favorites.set(fav.xtream_id, true);
|
||||
});
|
||||
console.log(this.favorites);
|
||||
});
|
||||
}
|
||||
// Removed loadCurrentEpgData() call since we're using virtual scroll
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<div class="recently-added-page">
|
||||
<h2>{{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }}</h2>
|
||||
|
||||
@if (recentlyAddedVod().length > 0) {
|
||||
<div class="section">
|
||||
<h3>{{ 'PORTALS.SIDEBAR.MOVIES' | translate }}</h3>
|
||||
<div class="carousel">
|
||||
@for (item of recentlyAddedVod(); track item.xtream_id) {
|
||||
<app-search-result-item
|
||||
class="carousel-item"
|
||||
[title]="item.title || item.name"
|
||||
[description]="getDate(item) | date : 'mediumDate'"
|
||||
[posterUrl]="item.poster_url || item.stream_icon"
|
||||
[type]="'movie'"
|
||||
(itemClick)="openItem(item, 'vod')"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recentlyAddedSeries().length > 0) {
|
||||
<div class="section">
|
||||
<h3>{{ 'PORTALS.SIDEBAR.SERIES' | translate }}</h3>
|
||||
<div class="carousel">
|
||||
@for (item of recentlyAddedSeries(); track item.xtream_id) {
|
||||
<app-search-result-item
|
||||
class="carousel-item"
|
||||
[title]="item.title || item.name"
|
||||
[description]="getDate(item) | date : 'mediumDate'"
|
||||
[posterUrl]="item.poster_url || item.cover"
|
||||
[type]="'series'"
|
||||
(itemClick)="openItem(item, 'series')"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (recentlyAddedLive().length > 0) {
|
||||
<div class="section">
|
||||
<h3>{{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }}</h3>
|
||||
<div class="carousel">
|
||||
@for (item of recentlyAddedLive(); track item.xtream_id) {
|
||||
<app-search-result-item
|
||||
class="carousel-item"
|
||||
[title]="item.title || item.name"
|
||||
[description]="getDate(item) | date : 'mediumDate'"
|
||||
[posterUrl]="item.poster_url || item.stream_icon"
|
||||
[type]="'live'"
|
||||
(itemClick)="openItem(item, 'live')"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recently-added-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.carousel {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
|
||||
scrollbar-width: thin;
|
||||
|
||||
.carousel-item {
|
||||
min-width: 160px;
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
import { ContentType } from '../xtream-state';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recently-added',
|
||||
templateUrl: './recently-added.component.html',
|
||||
styleUrls: ['./recently-added.component.scss'],
|
||||
imports: [DatePipe, SearchResultItemComponent, TranslatePipe],
|
||||
})
|
||||
export class RecentlyAddedComponent {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
|
||||
readonly recentlyAddedLive = computed(() =>
|
||||
this.getRecentlyAdded(this.xtreamStore.liveStreams())
|
||||
);
|
||||
readonly recentlyAddedVod = computed(() =>
|
||||
this.getRecentlyAdded(this.xtreamStore.vodStreams())
|
||||
);
|
||||
readonly recentlyAddedSeries = computed(() =>
|
||||
this.getRecentlyAdded(this.xtreamStore.serialStreams(), true)
|
||||
);
|
||||
|
||||
private getRecentlyAdded(items: any[], isSeries = false) {
|
||||
return [...items]
|
||||
.sort((a, b) => {
|
||||
const dateA =
|
||||
parseInt(isSeries ? a.last_modified : a.added) || 0;
|
||||
const dateB =
|
||||
parseInt(isSeries ? b.last_modified : b.added) || 0;
|
||||
return dateB - dateA;
|
||||
})
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
getDate(item: any): number {
|
||||
const timestamp = item.added || item.last_modified;
|
||||
return parseInt(timestamp) * 1000;
|
||||
}
|
||||
|
||||
openItem(item: any, type: ContentType) {
|
||||
this.xtreamStore.setSelectedContentType(type);
|
||||
|
||||
if (type === 'live') {
|
||||
this.router.navigate(['..', type, item.category_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
} else {
|
||||
const itemId =
|
||||
item.xtream_id ||
|
||||
item.id ||
|
||||
(type === 'series' ? item.series_id : item.stream_id);
|
||||
this.router.navigate(['..', type, item.category_id, itemId], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RecentlyViewedComponent } from './recently-viewed.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-recently-viewed',
|
||||
imports: [RecentlyViewedComponent],
|
||||
providers: [XtreamStore],
|
||||
template: '<app-recently-viewed/>'
|
||||
})
|
||||
export class GlobalRecentlyViewedComponent {}
|
||||
@@ -20,9 +20,7 @@ import { XtreamStore } from '../stores/xtream.store';
|
||||
MatIconButton,
|
||||
TitleCasePipe,
|
||||
],
|
||||
providers: [
|
||||
XtreamStore
|
||||
],
|
||||
providers: [],
|
||||
templateUrl: './recently-viewed.component.html',
|
||||
styleUrl: './recently-viewed.component.scss',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { SearchResultsComponent } from './search-results.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-search-results',
|
||||
imports: [SearchResultsComponent],
|
||||
providers: [XtreamStore],
|
||||
template: '<app-search-results/>'
|
||||
})
|
||||
export class GlobalSearchResultsComponent {}
|
||||
@@ -1,24 +1,43 @@
|
||||
<div class="search-page">
|
||||
<div class="header">
|
||||
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
|
||||
@if (isGlobalSearch) {
|
||||
<button mat-icon-button (click)="dialogRef.close()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="header sticky-header">
|
||||
<div class="header-top">
|
||||
<div class="header-left">
|
||||
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
|
||||
|
||||
<div class="type-filters">
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[(ngModel)]="filters[filter.key]"
|
||||
(ngModelChange)="executeSearch()"
|
||||
>
|
||||
{{ filter.translationKey | translate }}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-search-form
|
||||
[(searchTerm)]="searchTerm"
|
||||
[filters]="filters"
|
||||
[filterConfig]="filterConfig"
|
||||
/>
|
||||
<div class="header-right">
|
||||
@if (!isGlobalSearch && xtreamStore.searchResults().length > 0) {
|
||||
<div class="results-count">
|
||||
Found {{ xtreamStore.searchResults().length }} results
|
||||
</div>
|
||||
}
|
||||
@if (isGlobalSearch) {
|
||||
<button mat-icon-button (click)="dialogRef.close()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!isGlobalSearch && xtreamStore.searchResults().length > 0) {
|
||||
<div class="results-count">
|
||||
Found {{ xtreamStore.searchResults().length }} results
|
||||
<div class="search-input-container">
|
||||
<app-search-form
|
||||
[(searchTerm)]="searchTerm"
|
||||
[filters]="filters"
|
||||
[filterConfig]="[]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="results-container">
|
||||
@if (xtreamStore.searchResults().length > 0) { @if (isGlobalSearch) {
|
||||
@@ -93,10 +112,22 @@
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
} } @else if (searchTerm && searchTerm.length >= 3) {
|
||||
} } @else if (searchTerm() && searchTerm().length >= 3) {
|
||||
<div class="no-items">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<p>No results found for "{{ searchTerm() }}"</p>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
|
||||
<p class="search-term-note">No results found for "{{ searchTerm() }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-items initial-state">
|
||||
<mat-icon>search</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -7,31 +7,75 @@
|
||||
}
|
||||
|
||||
.search-page {
|
||||
padding: 20px;
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
// Use theme background variable or fallback
|
||||
background: var(--background, var(--mat-sidenav-content-background-color, #fff));
|
||||
padding: 20px 0 10px 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
// Subtle shadow or border for separation
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
|
||||
.search-container {
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-field {
|
||||
width: 100%;
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
mat-checkbox {
|
||||
margin-right: 1rem;
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
|
||||
mat-checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 0.9rem;
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.results-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary, rgba(0,0,0,0.7));
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,54 +94,72 @@
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.poster,
|
||||
.poster-placeholder {
|
||||
border-radius: 13px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.poster-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.05);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
p {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary, rgba(0, 0, 0, 0.6));
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&.live {
|
||||
background: #e53935;
|
||||
color: white;
|
||||
background: rgba(229, 57, 53, 0.1);
|
||||
color: #e53935;
|
||||
}
|
||||
&.movie {
|
||||
background: #1e88e5;
|
||||
color: white;
|
||||
background: rgba(30, 136, 229, 0.1);
|
||||
color: #1e88e5;
|
||||
}
|
||||
&.series {
|
||||
background: #43a047;
|
||||
color: white;
|
||||
background: rgba(67, 160, 71, 0.1);
|
||||
color: #43a047;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,18 +171,45 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
.no-items-content {
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-term-note {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.initial-state {
|
||||
opacity: 0.8;
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +219,11 @@
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Ensure sticky header background matches overlay background */
|
||||
.header {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
mat-card {
|
||||
position: relative;
|
||||
@@ -200,49 +294,6 @@
|
||||
}
|
||||
|
||||
:host-context(.global-search-overlay) {
|
||||
.search-page {
|
||||
background: var(--background);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
mat-card {
|
||||
position: relative;
|
||||
|
||||
.poster,
|
||||
.poster-placeholder {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.playlist-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-group {
|
||||
.playlist-title {
|
||||
background: var(--surface);
|
||||
|
||||
@@ -51,9 +51,7 @@ interface SearchResultsData {
|
||||
SearchResultItemComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
providers: [
|
||||
XtreamStore
|
||||
],
|
||||
providers: [],
|
||||
templateUrl: './search-results.component.html',
|
||||
styleUrls: ['./search-results.component.scss'],
|
||||
})
|
||||
@@ -112,7 +110,7 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
});
|
||||
}
|
||||
|
||||
private executeSearch() {
|
||||
executeSearch() {
|
||||
const types = Object.entries(this.filters)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([type]) => type);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
XtreamSerieItem,
|
||||
XtreamVodDetails,
|
||||
XtreamVodStream,
|
||||
XTREAM_REQUEST,
|
||||
} from 'shared-interfaces';
|
||||
import { XtreamAccountInfo } from '../account-info/account-info.interface';
|
||||
|
||||
@@ -65,8 +66,8 @@ export class XtreamApiService {
|
||||
async getAccountInfo(
|
||||
credentials: XtreamCredentials
|
||||
): Promise<XtreamAccountInfo> {
|
||||
return this.dataService.fetchData(
|
||||
`${credentials.serverUrl}/player_api.php`,
|
||||
return this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
@@ -88,8 +89,8 @@ export class XtreamApiService {
|
||||
series: XtreamCodeActions.GetSeriesCategories,
|
||||
};
|
||||
|
||||
const response = await this.dataService.fetchData(
|
||||
`${credentials.serverUrl}/player_api.php`,
|
||||
const response = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: actionMap[type],
|
||||
username: credentials.username,
|
||||
@@ -106,8 +107,8 @@ export class XtreamApiService {
|
||||
async getLiveStreams(
|
||||
credentials: XtreamCredentials
|
||||
): Promise<XtreamLiveStream[]> {
|
||||
const response = await this.dataService.fetchData(
|
||||
`${credentials.serverUrl}/player_api.php`,
|
||||
const response = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetLiveStreams,
|
||||
username: credentials.username,
|
||||
@@ -124,8 +125,8 @@ export class XtreamApiService {
|
||||
async getVodStreams(
|
||||
credentials: XtreamCredentials
|
||||
): Promise<XtreamVodStream[]> {
|
||||
const response = await this.dataService.fetchData(
|
||||
`${credentials.serverUrl}/player_api.php`,
|
||||
const response = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetVodStreams,
|
||||
username: credentials.username,
|
||||
@@ -142,8 +143,8 @@ export class XtreamApiService {
|
||||
async getSeriesStreams(
|
||||
credentials: XtreamCredentials
|
||||
): Promise<XtreamSerieItem[]> {
|
||||
const response = await this.dataService.fetchData(
|
||||
`${credentials.serverUrl}/player_api.php`,
|
||||
const response = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetSeries,
|
||||
username: credentials.username,
|
||||
@@ -178,8 +179,8 @@ export class XtreamApiService {
|
||||
credentials: XtreamCredentials,
|
||||
vodId: string | number
|
||||
): Promise<XtreamVodDetails> {
|
||||
return this.dataService.fetchData(
|
||||
`${credentials.serverUrl}/player_api.php`,
|
||||
return this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetVodInfo,
|
||||
username: credentials.username,
|
||||
@@ -196,8 +197,8 @@ export class XtreamApiService {
|
||||
credentials: XtreamCredentials,
|
||||
seriesId: string | number
|
||||
): Promise<XtreamSerieDetails> {
|
||||
return this.dataService.fetchData(
|
||||
`${credentials.serverUrl}/player_api.php`,
|
||||
return this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetSeriesInfo,
|
||||
username: credentials.username,
|
||||
@@ -216,8 +217,8 @@ export class XtreamApiService {
|
||||
streamId: number,
|
||||
limit: number = 10
|
||||
): Promise<EpgItem[]> {
|
||||
const response: EpgResponse = await this.dataService.fetchData(
|
||||
`${credentials.serverUrl}/player_api.php`,
|
||||
const response: EpgResponse = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetShortEpg,
|
||||
username: credentials.username,
|
||||
@@ -256,4 +257,20 @@ export class XtreamApiService {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request via IPC to avoid CORS issues
|
||||
*/
|
||||
private async sendRequest(url: string, params: Record<string, string | number>): Promise<any> {
|
||||
const serializedParams: Record<string, string> = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
serializedParams[key] = String(value);
|
||||
});
|
||||
|
||||
const response = await this.dataService.sendIpcEvent(XTREAM_REQUEST, {
|
||||
url,
|
||||
params: serializedParams,
|
||||
});
|
||||
return response?.payload;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export * from './with-portal.feature';
|
||||
export * from './with-content.feature';
|
||||
export * from './with-selection.feature';
|
||||
export * from './with-search.feature';
|
||||
export * from './with-epg.feature';
|
||||
export * from './with-player.feature';
|
||||
export * from './with-portal.feature';
|
||||
export * from './with-search.feature';
|
||||
export * from './with-selection.feature';
|
||||
@@ -12,13 +12,12 @@ import {
|
||||
XtreamSerieItem,
|
||||
XtreamVodStream,
|
||||
} from 'shared-interfaces';
|
||||
import { ContentType } from '../../xtream-state';
|
||||
import { XtreamCredentials } from '../../services/xtream-api.service';
|
||||
import {
|
||||
IXtreamDataSource,
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamCategoryFromDb,
|
||||
} from '../../data-sources/xtream-data-source.interface';
|
||||
import { XtreamCredentials } from '../../services/xtream-api.service';
|
||||
import { ContentType } from '../../xtream-state';
|
||||
|
||||
/**
|
||||
* Content state for managing categories and streams
|
||||
@@ -181,7 +180,7 @@ export function withContent() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all content/streams in parallel
|
||||
* Fetch all content/streams in parallel with progress tracking
|
||||
*/
|
||||
async fetchAllContent(): Promise<void> {
|
||||
const ctx = getCredentialsFromStore();
|
||||
@@ -189,22 +188,42 @@ export function withContent() {
|
||||
|
||||
patchState(store, { isLoadingContent: true });
|
||||
|
||||
// Track combined progress across all content types
|
||||
let totalItems = 0;
|
||||
let importedItems = 0;
|
||||
|
||||
const onTotal = (count: number) => {
|
||||
totalItems += count;
|
||||
patchState(store, { itemsToImport: totalItems });
|
||||
};
|
||||
|
||||
const onProgress = (count: number) => {
|
||||
importedItems += count;
|
||||
patchState(store, { importCount: importedItems });
|
||||
};
|
||||
|
||||
try {
|
||||
const [live, vod, series] = await Promise.all([
|
||||
dataSource.getContent(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'live'
|
||||
'live',
|
||||
onProgress,
|
||||
onTotal
|
||||
),
|
||||
dataSource.getContent(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'movie'
|
||||
'movie',
|
||||
onProgress,
|
||||
onTotal
|
||||
),
|
||||
dataSource.getContent(
|
||||
ctx.playlistId,
|
||||
ctx.credentials,
|
||||
'series'
|
||||
'series',
|
||||
onProgress,
|
||||
onTotal
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -227,13 +246,17 @@ export function withContent() {
|
||||
const ctx = getCredentialsFromStore();
|
||||
if (!ctx) return;
|
||||
|
||||
patchState(store, { isImporting: true, importCount: 0 });
|
||||
patchState(store, {
|
||||
isImporting: true,
|
||||
importCount: 0,
|
||||
itemsToImport: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
// Fetch categories first
|
||||
await this.fetchAllCategories();
|
||||
|
||||
// Then fetch content
|
||||
// Then fetch content (with progress tracking)
|
||||
await this.fetchAllContent();
|
||||
|
||||
// Restore user data if needed
|
||||
@@ -241,8 +264,10 @@ export function withContent() {
|
||||
const restoreData = localStorage.getItem(restoreKey);
|
||||
if (restoreData) {
|
||||
try {
|
||||
const { favoritedXtreamIds, recentlyViewedXtreamIds } =
|
||||
JSON.parse(restoreData);
|
||||
const {
|
||||
favoritedXtreamIds,
|
||||
recentlyViewedXtreamIds,
|
||||
} = JSON.parse(restoreData);
|
||||
await dataSource.restoreUserData(
|
||||
ctx.playlistId,
|
||||
favoritedXtreamIds,
|
||||
@@ -250,7 +275,10 @@ export function withContent() {
|
||||
);
|
||||
localStorage.removeItem(restoreKey);
|
||||
} catch (err) {
|
||||
console.error('Error restoring user data:', err);
|
||||
console.error(
|
||||
'Error restoring user data:',
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -298,7 +326,9 @@ export function withContent() {
|
||||
* Update import progress
|
||||
*/
|
||||
setImportProgress(count: number, total?: number): void {
|
||||
const updates: Partial<ContentState> = { importCount: count };
|
||||
const updates: Partial<ContentState> = {
|
||||
importCount: count,
|
||||
};
|
||||
if (total !== undefined) {
|
||||
updates.itemsToImport = total;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { XtreamApiService, XtreamCredentials } from '../../services/xtream-api.service';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../../services/xtream-api.service';
|
||||
|
||||
/**
|
||||
* EPG state for managing Electronic Program Guide data
|
||||
@@ -105,7 +113,11 @@ export function withEpg() {
|
||||
}
|
||||
|
||||
try {
|
||||
return await apiService.getShortEpg(credentials, streamId, 1);
|
||||
return await apiService.getShortEpg(
|
||||
credentials,
|
||||
streamId,
|
||||
1
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error loading channel EPG:', error);
|
||||
return [];
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { XtreamSerieEpisode, XtreamVodDetails } from 'shared-interfaces';
|
||||
import { PlayerService } from '../../../services/player.service';
|
||||
import { XtreamUrlService } from '../../services/xtream-url.service';
|
||||
import { XtreamCredentials } from '../../services/xtream-api.service';
|
||||
import { XtreamUrlService } from '../../services/xtream-url.service';
|
||||
|
||||
/**
|
||||
* Player state for managing stream URLs and player settings
|
||||
@@ -100,7 +105,10 @@ export function withPlayer() {
|
||||
return '';
|
||||
}
|
||||
|
||||
const streamUrl = urlService.constructVodUrl(credentials, vodItem);
|
||||
const streamUrl = urlService.constructVodUrl(
|
||||
credentials,
|
||||
vodItem
|
||||
);
|
||||
patchState(store, { streamUrl });
|
||||
return streamUrl;
|
||||
},
|
||||
@@ -132,13 +140,15 @@ export function withPlayer() {
|
||||
): void {
|
||||
const playlist = getPlaylistFromStore();
|
||||
const storeAny = store as any;
|
||||
const contentType = storeAny.selectedContentType?.() || 'vod';
|
||||
const contentType =
|
||||
storeAny.selectedContentType?.() || 'vod';
|
||||
|
||||
playerService.openPlayer(
|
||||
streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
localStorage.getItem('hideExternalInfoDialog') === 'true',
|
||||
localStorage.getItem('hideExternalInfoDialog') ===
|
||||
'true',
|
||||
contentType === 'live',
|
||||
playlist?.userAgent,
|
||||
playlist?.referrer,
|
||||
@@ -150,7 +160,10 @@ export function withPlayer() {
|
||||
* Set hide external info dialog preference
|
||||
*/
|
||||
setHideExternalInfoDialog(hide: boolean): void {
|
||||
localStorage.setItem('hideExternalInfoDialog', String(hide));
|
||||
localStorage.setItem(
|
||||
'hideExternalInfoDialog',
|
||||
String(hide)
|
||||
);
|
||||
patchState(store, { hideExternalInfoDialog: hide });
|
||||
},
|
||||
|
||||
@@ -161,7 +174,8 @@ export function withPlayer() {
|
||||
patchState(store, {
|
||||
streamUrl: null,
|
||||
hideExternalInfoDialog:
|
||||
localStorage.getItem('hideExternalInfoDialog') === 'true',
|
||||
localStorage.getItem('hideExternalInfoDialog') ===
|
||||
'true',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectActivePlaylist } from 'm3u-state';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
import { XtreamApiService, XtreamCredentials } from '../../services/xtream-api.service';
|
||||
import {
|
||||
IXtreamDataSource,
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamPlaylistData,
|
||||
} from '../../data-sources/xtream-data-source.interface';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../../services/xtream-api.service';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
|
||||
/**
|
||||
* Portal state for managing playlist and portal status
|
||||
@@ -63,7 +70,8 @@ export function withPortal() {
|
||||
}
|
||||
|
||||
try {
|
||||
const playlist = await dataSource.getPlaylist(playlistId);
|
||||
const playlist =
|
||||
await dataSource.getPlaylist(playlistId);
|
||||
|
||||
if (playlist) {
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
@@ -84,7 +92,9 @@ export function withPortal() {
|
||||
};
|
||||
|
||||
await dataSource.createPlaylist(newPlaylist);
|
||||
patchState(store, { currentPlaylist: newPlaylist });
|
||||
patchState(store, {
|
||||
currentPlaylist: newPlaylist,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -109,7 +119,8 @@ export function withPortal() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await apiService.getAccountInfo(credentials);
|
||||
const response =
|
||||
await apiService.getAccountInfo(credentials);
|
||||
|
||||
if (!response?.user_info?.status) {
|
||||
patchState(store, { portalStatus: 'unavailable' });
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { GlobalSearchResult } from 'services';
|
||||
import {
|
||||
IXtreamDataSource,
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamContentItem,
|
||||
} from '../../data-sources/xtream-data-source.interface';
|
||||
|
||||
@@ -44,37 +44,10 @@ export function withSelection() {
|
||||
return signalStoreFeature(
|
||||
withState<SelectionState>(initialSelectionState),
|
||||
|
||||
withComputed((store) => ({
|
||||
/**
|
||||
* Get the selected category from the parent store's categories
|
||||
*/
|
||||
getSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) return null;
|
||||
|
||||
// Access parent store categories (from withContent)
|
||||
const storeAny = store as any;
|
||||
const allCategories = [
|
||||
...(storeAny.vodCategories?.() || []),
|
||||
...(storeAny.liveCategories?.() || []),
|
||||
...(storeAny.serialCategories?.() || []),
|
||||
];
|
||||
|
||||
return allCategories.find(
|
||||
(c: any) => c.id === categoryId || c.category_id === String(categoryId)
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the selected item by ID from content
|
||||
*/
|
||||
getSelectedItemById: computed(() => {
|
||||
withComputed((store) => {
|
||||
// Memoized sorted content - only recalculates when content/type changes
|
||||
const sortedContent = computed(() => {
|
||||
const categoryType = store.selectedContentType();
|
||||
const selectedItem = store.selectedItem();
|
||||
|
||||
if (!selectedItem) return null;
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
@@ -83,137 +56,213 @@ export function withSelection() {
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
return content.find(
|
||||
(item: any) =>
|
||||
item.stream_id === selectedItem.stream_id ||
|
||||
item.id === selectedItem.id ||
|
||||
item.series_id === selectedItem.series_id
|
||||
);
|
||||
}),
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get paginated content for the selected category
|
||||
*/
|
||||
getPaginatedContent: computed(() => {
|
||||
const startIndex = store.page() * store.limit();
|
||||
const endIndex = startIndex + store.limit();
|
||||
const categoryId = store.selectedCategoryId();
|
||||
|
||||
if (!categoryId) return [];
|
||||
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
return content
|
||||
.filter((item: any) => Number(item.category_id) === categoryId)
|
||||
.slice(startIndex, endIndex);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all items from the selected category (without pagination)
|
||||
*/
|
||||
selectItemsFromSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
if (!categoryId) return [];
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
return content.filter(
|
||||
(item: any) => Number(item.category_id) === categoryId
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get total pages for the selected category
|
||||
*/
|
||||
getTotalPages: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) return 0;
|
||||
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
const totalItems = content.filter(
|
||||
(item: any) => Number(item.category_id) === categoryId
|
||||
).length;
|
||||
|
||||
return Math.ceil(totalItems / store.limit());
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if paginated content is loading
|
||||
*/
|
||||
isPaginatedContentLoading: computed(() => {
|
||||
// Access parent store loading state (from withContent)
|
||||
const storeAny = store as any;
|
||||
return storeAny.isLoadingContent?.() || false;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Memoized category item counts map
|
||||
*/
|
||||
getCategoryItemCounts: computed(() => {
|
||||
const type = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const streams =
|
||||
type === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: type === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
const countMap = new Map<number, number>();
|
||||
for (const item of streams) {
|
||||
const catId = Number(item.category_id);
|
||||
if (!isNaN(catId)) {
|
||||
countMap.set(catId, (countMap.get(catId) || 0) + 1);
|
||||
return {
|
||||
/**
|
||||
* Get the selected category from the parent store's categories
|
||||
*/
|
||||
getSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) {
|
||||
return {
|
||||
id: 0,
|
||||
name: 'All Items',
|
||||
type: store.selectedContentType(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return countMap;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get categories for the currently selected content type
|
||||
*/
|
||||
getCategoriesBySelectedType: computed(() => {
|
||||
const type = store.selectedContentType();
|
||||
// Access parent store categories (from withContent)
|
||||
const storeAny = store as any;
|
||||
const allCategories = [
|
||||
...(storeAny.vodCategories?.() || []),
|
||||
...(storeAny.liveCategories?.() || []),
|
||||
...(storeAny.serialCategories?.() || []),
|
||||
];
|
||||
|
||||
// Access parent store categories (from withContent)
|
||||
const storeAny = store as any;
|
||||
return type === 'live'
|
||||
? storeAny.liveCategories?.() || []
|
||||
: type === 'vod'
|
||||
? storeAny.vodCategories?.() || []
|
||||
: storeAny.serialCategories?.() || [];
|
||||
}),
|
||||
})),
|
||||
return allCategories.find(
|
||||
(c: any) =>
|
||||
c.id === categoryId ||
|
||||
c.category_id === String(categoryId)
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the selected item by ID from content
|
||||
*/
|
||||
getSelectedItemById: computed(() => {
|
||||
const categoryType = store.selectedContentType();
|
||||
const selectedItem = store.selectedItem();
|
||||
|
||||
if (!selectedItem) return null;
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
return content.find(
|
||||
(item: any) =>
|
||||
item.stream_id === selectedItem.stream_id ||
|
||||
item.id === selectedItem.id ||
|
||||
item.series_id === selectedItem.series_id
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get paginated content for the selected category
|
||||
*/
|
||||
getPaginatedContent: computed(() => {
|
||||
const startIndex = store.page() * store.limit();
|
||||
const endIndex = startIndex + store.limit();
|
||||
const categoryId = store.selectedCategoryId();
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
let filteredContent = content;
|
||||
if (categoryId) {
|
||||
filteredContent = content.filter(
|
||||
(item: any) =>
|
||||
Number(item.category_id) === categoryId
|
||||
);
|
||||
} else {
|
||||
filteredContent = sortedContent();
|
||||
}
|
||||
|
||||
return filteredContent.slice(startIndex, endIndex);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all items from the selected category (without pagination)
|
||||
*/
|
||||
selectItemsFromSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
if (!categoryId) {
|
||||
return sortedContent();
|
||||
}
|
||||
|
||||
return content.filter(
|
||||
(item: any) => Number(item.category_id) === categoryId
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get total pages for the selected category
|
||||
*/
|
||||
getTotalPages: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
const categoryType = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: categoryType === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
let totalItems = 0;
|
||||
if (categoryId) {
|
||||
totalItems = content.filter(
|
||||
(item: any) =>
|
||||
Number(item.category_id) === categoryId
|
||||
).length;
|
||||
} else {
|
||||
totalItems = content.length;
|
||||
}
|
||||
|
||||
return Math.ceil(totalItems / store.limit());
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if paginated content is loading
|
||||
*/
|
||||
isPaginatedContentLoading: computed(() => {
|
||||
// Access parent store loading state (from withContent)
|
||||
const storeAny = store as any;
|
||||
return storeAny.isLoadingContent?.() || false;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Memoized category item counts map
|
||||
*/
|
||||
getCategoryItemCounts: computed(() => {
|
||||
const type = store.selectedContentType();
|
||||
|
||||
// Access parent store content (from withContent)
|
||||
const storeAny = store as any;
|
||||
const streams =
|
||||
type === 'live'
|
||||
? storeAny.liveStreams?.() || []
|
||||
: type === 'vod'
|
||||
? storeAny.vodStreams?.() || []
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
const countMap = new Map<number, number>();
|
||||
for (const item of streams) {
|
||||
const catId = Number(item.category_id);
|
||||
if (!isNaN(catId)) {
|
||||
countMap.set(catId, (countMap.get(catId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return countMap;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get categories for the currently selected content type
|
||||
*/
|
||||
getCategoriesBySelectedType: computed(() => {
|
||||
const type = store.selectedContentType();
|
||||
|
||||
// Access parent store categories (from withContent)
|
||||
const storeAny = store as any;
|
||||
return type === 'live'
|
||||
? storeAny.liveCategories?.() || []
|
||||
: type === 'vod'
|
||||
? storeAny.vodCategories?.() || []
|
||||
: storeAny.serialCategories?.() || [];
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
withMethods((store) => ({
|
||||
/**
|
||||
@@ -230,9 +279,10 @@ export function withSelection() {
|
||||
/**
|
||||
* Set the selected category
|
||||
*/
|
||||
setSelectedCategory(categoryId: number): void {
|
||||
setSelectedCategory(categoryId: number | null): void {
|
||||
patchState(store, {
|
||||
selectedCategoryId: Number(categoryId),
|
||||
selectedCategoryId:
|
||||
categoryId !== null ? Number(categoryId) : null,
|
||||
page: 0,
|
||||
});
|
||||
},
|
||||
@@ -272,7 +322,9 @@ export function withSelection() {
|
||||
resetSelection(): void {
|
||||
patchState(store, {
|
||||
...initialSelectionState,
|
||||
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
|
||||
limit: Number(
|
||||
localStorage.getItem('xtream-page-size') ?? 25
|
||||
),
|
||||
});
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
} from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectActivePlaylist } from 'm3u-state';
|
||||
import { DataService } from 'services';
|
||||
import {
|
||||
XtreamCodeActions,
|
||||
XtreamSerieDetails,
|
||||
XtreamVodDetails,
|
||||
} from 'shared-interfaces';
|
||||
@@ -17,6 +15,9 @@ import {
|
||||
import { withFavorites } from '../with-favorites.feature';
|
||||
import { withRecentItems } from '../with-recent-items';
|
||||
|
||||
// Import service
|
||||
import { XtreamApiService } from '../services/xtream-api.service';
|
||||
|
||||
// Import new feature stores
|
||||
import {
|
||||
withPortal,
|
||||
@@ -75,8 +76,10 @@ export const XtreamStore = signalStore(
|
||||
|
||||
// Cross-feature methods & orchestration
|
||||
withMethods((store) => {
|
||||
const dataService = inject(DataService);
|
||||
const xtreamApiService = inject(XtreamApiService);
|
||||
const ngrxStore = inject(Store);
|
||||
const searchContent = (store as any)
|
||||
.searchContent as (term: string, types: string[]) => Promise<unknown>;
|
||||
|
||||
return {
|
||||
/**
|
||||
@@ -122,15 +125,11 @@ export const XtreamStore = signalStore(
|
||||
if (!playlist) return;
|
||||
|
||||
store.setIsLoadingDetails(true);
|
||||
dataService.fetchData(
|
||||
`${playlist.serverUrl}/player_api.php`,
|
||||
{
|
||||
action: XtreamCodeActions.GetVodInfo,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
vod_id: params.vodId,
|
||||
}
|
||||
).then((vodDetails: XtreamVodDetails) => {
|
||||
xtreamApiService.getVodInfo({
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
}, params.vodId).then((vodDetails: XtreamVodDetails) => {
|
||||
store.setSelectedCategory(params.categoryId);
|
||||
store.setSelectedItem({
|
||||
...vodDetails,
|
||||
@@ -154,15 +153,11 @@ export const XtreamStore = signalStore(
|
||||
if (!playlist) return;
|
||||
|
||||
store.setIsLoadingDetails(true);
|
||||
dataService.fetchData(
|
||||
`${playlist.serverUrl}/player_api.php`,
|
||||
{
|
||||
action: XtreamCodeActions.GetSeriesInfo,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
series_id: params.serialId,
|
||||
}
|
||||
).then((serialDetails: XtreamSerieDetails) => {
|
||||
xtreamApiService.getSeriesInfo({
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
}, params.serialId).then((serialDetails: XtreamSerieDetails) => {
|
||||
store.setSelectedCategory(params.categoryId);
|
||||
store.setSelectedItem({
|
||||
...serialDetails,
|
||||
@@ -221,7 +216,7 @@ export const XtreamStore = signalStore(
|
||||
*/
|
||||
searchContent(params: { term: string; types: string[] }): void {
|
||||
// Call the underlying search method from withSearch
|
||||
(store as any).searchContent(params.term, params.types);
|
||||
void searchContent(params.term, params.types);
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -60,30 +60,43 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
}
|
||||
|
||||
getContentLabel(): string {
|
||||
if (
|
||||
this.xtreamStore.getSelectedCategory() === null ||
|
||||
this.xtreamStore.getSelectedCategory() === undefined
|
||||
) {
|
||||
return this.translateService.instant('PORTALS.SELECT_CATEGORY');
|
||||
} else {
|
||||
const selectedCategory = this.xtreamStore.getSelectedCategory();
|
||||
const categoryName = selectedCategory
|
||||
? (selectedCategory as any).name
|
||||
: 'Category Content';
|
||||
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
|
||||
|
||||
// Show page number when viewing category content (not detail view)
|
||||
// When no category is selected, show "Recently Added"
|
||||
if (selectedCategoryId === null || selectedCategoryId === undefined) {
|
||||
const recentlyAddedLabel = this.translateService.instant('PORTALS.SIDEBAR.RECENTLY_ADDED');
|
||||
|
||||
// Show page number when viewing recently added (not detail view)
|
||||
if (
|
||||
!this.xtreamStore.selectedItem() &&
|
||||
this.xtreamStore.getTotalPages() > 1
|
||||
) {
|
||||
const currentPage = this.xtreamStore.page() + 1; // +1 because page is 0-indexed
|
||||
const currentPage = this.xtreamStore.page() + 1;
|
||||
const totalPages = this.xtreamStore.getTotalPages();
|
||||
const pageLabel = this.translateService.instant('PORTALS.PAGE');
|
||||
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
|
||||
return `${recentlyAddedLabel} (${pageLabel} ${currentPage}/${totalPages})`;
|
||||
}
|
||||
|
||||
return `Content for ${categoryName}`;
|
||||
return recentlyAddedLabel;
|
||||
}
|
||||
|
||||
const selectedCategory = this.xtreamStore.getSelectedCategory();
|
||||
const categoryName = selectedCategory
|
||||
? (selectedCategory as any).name
|
||||
: 'Category Content';
|
||||
|
||||
// Show page number when viewing category content (not detail view)
|
||||
if (
|
||||
!this.xtreamStore.selectedItem() &&
|
||||
this.xtreamStore.getTotalPages() > 1
|
||||
) {
|
||||
const currentPage = this.xtreamStore.page() + 1; // +1 because page is 0-indexed
|
||||
const totalPages = this.xtreamStore.getTotalPages();
|
||||
const pageLabel = this.translateService.instant('PORTALS.PAGE');
|
||||
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
|
||||
}
|
||||
|
||||
return categoryName;
|
||||
}
|
||||
|
||||
historyBack() {
|
||||
@@ -105,6 +118,7 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
data: {
|
||||
playlistId,
|
||||
contentType,
|
||||
itemCounts: this.categoryItemCounts(),
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '80vh',
|
||||
|
||||
@@ -57,11 +57,16 @@ export class XtreamShellComponent {
|
||||
this.route.params.pipe(takeUntilDestroyed()).subscribe(async (params) => {
|
||||
const newPlaylistId = params['id'];
|
||||
|
||||
// Skip if playlist ID hasn't changed (after first load)
|
||||
if (!this.isFirstLoad && this.currentPlaylistId === newPlaylistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isFirstLoad) {
|
||||
// First load - just set the playlist ID without resetting
|
||||
this.xtreamStore.setPlaylistId(newPlaylistId);
|
||||
this.isFirstLoad = false;
|
||||
} else if (this.currentPlaylistId !== newPlaylistId) {
|
||||
} else {
|
||||
// Switching to a different playlist - reset and set new ID
|
||||
this.xtreamStore.resetStore(newPlaylistId);
|
||||
}
|
||||
@@ -85,7 +90,6 @@ export class XtreamShellComponent {
|
||||
this.xtreamStore.initializeContent();
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,10 +100,10 @@ export class XtreamShellComponent {
|
||||
});
|
||||
}
|
||||
|
||||
handlePageClick(page: 'search' | 'recent' | 'favorites') {
|
||||
handlePageClick(page: 'search' | 'recent' | 'favorites' | 'recently-added') {
|
||||
this.xtreamStore.setSelectedContentType(undefined);
|
||||
this.router.navigate([page], {
|
||||
relativeTo: this.route,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ export const xtreamRoutes: Route[] = [
|
||||
(c) => c.XtreamMainContainerComponent
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import(
|
||||
'./category-content-view/category-content-view.component'
|
||||
).then((c) => c.CategoryContentViewComponent),
|
||||
},
|
||||
{
|
||||
path: ':categoryId',
|
||||
loadComponent: () =>
|
||||
@@ -59,6 +66,13 @@ export const xtreamRoutes: Route[] = [
|
||||
(c) => c.XtreamMainContainerComponent
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import(
|
||||
'./category-content-view/category-content-view.component'
|
||||
).then((c) => c.CategoryContentViewComponent),
|
||||
},
|
||||
{
|
||||
path: ':categoryId',
|
||||
loadComponent: () =>
|
||||
@@ -96,6 +110,13 @@ export const xtreamRoutes: Route[] = [
|
||||
(c) => c.SearchResultsComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'recently-added',
|
||||
loadComponent: () =>
|
||||
import('./recently-added/recently-added.component').then(
|
||||
(c) => c.RecentlyAddedComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -384,6 +384,7 @@
|
||||
"LIBRARY": "Library",
|
||||
"SEARCH": "Search",
|
||||
"RECENT": "Recent",
|
||||
"RECENTLY_ADDED": "Recently added",
|
||||
"FAVORITES": "Favorites",
|
||||
"HOME": "Home",
|
||||
"PLAYLIST_INFO": "Playlist info",
|
||||
@@ -422,6 +423,10 @@
|
||||
"TITLE": "No results found",
|
||||
"NO_SEARCH_RESULTS": "Nothing found, try to change you search request"
|
||||
},
|
||||
"SEARCH_VIEW": {
|
||||
"INITIAL_TITLE": "Search for content",
|
||||
"INITIAL_DESCRIPTION": "Enter at least 3 characters to search across live TV, movies and series."
|
||||
},
|
||||
"STREAM_URL_COPIED": "Stream URL copied to clipboard",
|
||||
"COPY_STREAM_URL": "Copy Stream URL",
|
||||
"SELECT_CATEGORY": "Select a category",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"target": "ES2022",
|
||||
"typeRoots": ["node_modules/@types"],
|
||||
"lib": ["es2017", "es2016", "es2015", "dom"],
|
||||
"lib": ["es2018", "es2017", "es2016", "es2015", "dom"],
|
||||
"resolveJsonModule": true,
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Vendored
+3
@@ -210,6 +210,9 @@ declare global {
|
||||
error?: string;
|
||||
}) => void
|
||||
) => void;
|
||||
// DB save content progress listener
|
||||
onDbSaveContentProgress: (callback: (count: number) => void) => void;
|
||||
removeDbSaveContentProgress: () => void;
|
||||
dbDeleteAllPlaylists: () => Promise<{ success: boolean }>;
|
||||
};
|
||||
process: NodeJS.Process;
|
||||
|
||||
@@ -17,7 +17,6 @@ export abstract class DataService {
|
||||
abstract getAppVersion(): string;
|
||||
abstract sendIpcEvent(type: string, payload?: unknown): any;
|
||||
abstract removeAllListeners(type: string): void;
|
||||
abstract fetchData(url: string, queryParams: Params): any;
|
||||
abstract listenOn(
|
||||
command: string,
|
||||
callback: (...args: any[]) => void
|
||||
|
||||
@@ -237,19 +237,22 @@ export class DatabaseService {
|
||||
): Promise<number> {
|
||||
// Setup progress listener if callback provided
|
||||
if (onProgress) {
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'DB_SAVE_CONTENT_PROGRESS') {
|
||||
onProgress(event.data.count);
|
||||
}
|
||||
});
|
||||
window.electron.onDbSaveContentProgress(onProgress);
|
||||
}
|
||||
|
||||
const result = await window.electron.dbSaveContent(
|
||||
playlistId,
|
||||
streams,
|
||||
type
|
||||
);
|
||||
return result.count;
|
||||
try {
|
||||
const result = await window.electron.dbSaveContent(
|
||||
playlistId,
|
||||
streams,
|
||||
type
|
||||
);
|
||||
return result.count;
|
||||
} finally {
|
||||
// Clean up the listener
|
||||
if (onProgress) {
|
||||
window.electron.removeDbSaveContentProgress();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
.scroll-viewport {
|
||||
min-height: calc(100vh - 159px);
|
||||
min-height: calc(100vh - 190px);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
.search-bar {
|
||||
@include mat.form-field-density(-5);
|
||||
|
||||
|
||||
& {
|
||||
width: 96%;
|
||||
}
|
||||
@@ -36,7 +36,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
div[matformfieldlineripple], .mat-mdc-form-field-subscript-wrapper {
|
||||
div[matformfieldlineripple],
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,11 @@
|
||||
[class.selected]="isSelected(playlist)"
|
||||
(click)="selectPlaylist(playlist)"
|
||||
>
|
||||
@if (isSelected(playlist)) {
|
||||
<!-- @if (isSelected(playlist)) {
|
||||
<mat-icon class="selected-indicator">check</mat-icon>
|
||||
} @else {
|
||||
<div class="selected-indicator-placeholder"></div>
|
||||
}
|
||||
} -->
|
||||
|
||||
<div class="playlist-icon-container">
|
||||
<mat-icon class="playlist-type-icon">
|
||||
@@ -70,8 +70,14 @@
|
||||
[class]="getStatusClass(playlist._id)"
|
||||
></span>
|
||||
}
|
||||
@if (playlist.autoRefresh && !playlist.serverUrl && !playlist.macAddress) {
|
||||
<mat-icon class="auto-refresh-indicator">autorenew</mat-icon>
|
||||
@if (
|
||||
playlist.autoRefresh &&
|
||||
!playlist.serverUrl &&
|
||||
!playlist.macAddress
|
||||
) {
|
||||
<mat-icon class="auto-refresh-indicator"
|
||||
>autorenew</mat-icon
|
||||
>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
// Menu styles (global)
|
||||
::ng-deep .playlist-switcher-menu {
|
||||
min-width: 320px !important;
|
||||
max-width: 400px !important;
|
||||
max-width: 320px !important;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden !important;
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -201,7 +200,7 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
padding: 4px 12px;
|
||||
min-height: 52px;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
@@ -220,7 +219,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.selected-indicator {
|
||||
/* .selected-indicator {
|
||||
color: #8b5cf6;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
@@ -232,7 +231,7 @@
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
} */
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.playlist-item {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
@@ -12,10 +11,10 @@ import {
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
@@ -63,17 +62,22 @@ export class PlaylistSwitcherComponent {
|
||||
/** All playlists from store */
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
|
||||
/** Filtered playlists based on search query */
|
||||
/** Filtered playlists based on search query, sorted by importDate (newest first) */
|
||||
readonly filteredPlaylists = computed(() => {
|
||||
const query = this.searchQuery().toLowerCase().trim();
|
||||
const allPlaylists = this.playlists();
|
||||
if (!query) {
|
||||
return allPlaylists;
|
||||
}
|
||||
return allPlaylists.filter(
|
||||
(p) =>
|
||||
p.title?.toLowerCase().includes(query) ||
|
||||
p.filename?.toLowerCase().includes(query)
|
||||
const filtered = query
|
||||
? allPlaylists.filter(
|
||||
(p) =>
|
||||
p.title?.toLowerCase().includes(query) ||
|
||||
p.filename?.toLowerCase().includes(query)
|
||||
)
|
||||
: allPlaylists;
|
||||
|
||||
return [...filtered].sort(
|
||||
(a, b) =>
|
||||
new Date(b.importDate ?? 0).getTime() -
|
||||
new Date(a.importDate ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -96,12 +100,6 @@ export class PlaylistSwitcherComponent {
|
||||
.subscribe((event: NavigationEnd) => {
|
||||
this.updateActivePlaylistFromRoute(event.urlAfterRedirects);
|
||||
});
|
||||
|
||||
// Check portal status for Xtream playlists when playlists load
|
||||
effect(() => {
|
||||
const allPlaylists = this.playlists();
|
||||
this.checkPortalStatuses(allPlaylists);
|
||||
});
|
||||
}
|
||||
|
||||
private updateActivePlaylistFromRoute(url: string) {
|
||||
@@ -116,27 +114,35 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
private async checkPortalStatuses(playlists: PlaylistMeta[]) {
|
||||
const statusPromises = playlists
|
||||
.filter(playlist => playlist.serverUrl && playlist.username && playlist.password)
|
||||
.filter(
|
||||
(playlist) =>
|
||||
playlist.serverUrl && playlist.username && playlist.password
|
||||
)
|
||||
.map(async (playlist) => {
|
||||
try {
|
||||
const status = await this.portalStatusService.checkPortalStatus(
|
||||
playlist.serverUrl,
|
||||
playlist.username,
|
||||
playlist.password
|
||||
);
|
||||
const status =
|
||||
await this.portalStatusService.checkPortalStatus(
|
||||
playlist.serverUrl,
|
||||
playlist.username,
|
||||
playlist.password
|
||||
);
|
||||
return { id: playlist._id, status };
|
||||
} catch {
|
||||
return { id: playlist._id, status: 'unavailable' as PortalStatus };
|
||||
return {
|
||||
id: playlist._id,
|
||||
status: 'unavailable' as PortalStatus,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(statusPromises);
|
||||
const statusMap = new Map(results.map(r => [r.id, r.status]));
|
||||
const statusMap = new Map(results.map((r) => [r.id, r.status]));
|
||||
this.portalStatuses.set(statusMap);
|
||||
}
|
||||
|
||||
onMenuOpened() {
|
||||
this.isMenuOpen.set(true);
|
||||
this.checkPortalStatuses(this.playlists());
|
||||
}
|
||||
|
||||
onMenuClosed() {
|
||||
|
||||
Reference in new issue
Block a user