Merge pull request #726 from 4gray/fix/pre-release-fixes-v0-18

fix/pre-release-fixes-v0-18
This commit is contained in:
4gray authored and GitHub committed 2026-01-12 21:38:34 +01:00
commit d27268ca04
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;
+4 -4
View File
@@ -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%',
+2 -17
View File
@@ -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;
}
}
-4
View File
@@ -285,8 +285,4 @@ export class PwaService extends DataService {
getAppEnvironment(): string {
return 'pwa';
}
fetchData(url: string, queryParams: Params) {
// not implemented
}
}
@@ -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';
}
}
}
@@ -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,
});
}
}
}
@@ -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">
@@ -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;
@@ -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
),
},
],
},
];
+5
View File
@@ -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",
+1 -1
View File
@@ -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
View File
@@ -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;
-1
View File
@@ -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();
}
}
}
/**
@@ -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() {