mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(xtream): improvements in xtream portal UI
- Replaced the existing favorites component HTML structure with a new app-favorites-layout component for better organization and maintainability. - Removed unused styles from favorites.component.scss. - Updated favorites.component.ts to utilize the new layout component and streamline imports. - Enhanced detail-view styles for improved responsiveness and layout consistency. - Adjusted navigation component to conditionally render elements based on playlist type. - Introduced a new NavigationItem interface for better type safety in navigation items. - Refactored search results component to utilize new search-form and search-result-item components for cleaner code and separation of concerns. - Updated vod-details component to handle portal ID retrieval more robustly. - Made various style adjustments across components for improved UI consistency.
This commit is contained in:
1 parent
87807dc1e7
commit
f1dfef6d9f
47 files changed
+1262
-663
No files matched your search
@@ -11,4 +11,5 @@ export interface XtreamItem {
|
||||
rating_imdb?: string;
|
||||
xtream_id?: number;
|
||||
type?: 'movie' | 'series' | 'live';
|
||||
added_at?: number;
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export class PlayerService {
|
||||
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
|
||||
url: streamUrl,
|
||||
vlcPlayerPath: this.settingsStore.vlcPlayerPath(),
|
||||
title,
|
||||
thumbnail,
|
||||
});
|
||||
} else if (!isLiveContent) {
|
||||
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Injectable } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { parse } from 'iptv-playlist-parser';
|
||||
import { NgxIndexedDBService } from 'ngx-indexed-db';
|
||||
import { combineLatest, map, switchMap } from 'rxjs';
|
||||
import { combineLatest, EMPTY, map, switchMap } from 'rxjs';
|
||||
import { Channel } from '../../../shared/channel.interface';
|
||||
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants';
|
||||
import {
|
||||
@@ -20,15 +21,21 @@ import { XtreamItem } from '../../../shared/xtream-item.interface';
|
||||
import { XtreamSerieItem } from '../../../shared/xtream-serie-item.interface';
|
||||
import { DbStores } from '../indexed-db.config';
|
||||
import { PlaylistMeta } from '../shared/playlist-meta.type';
|
||||
import { selectCurrentPlaylist } from '../state/selectors';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PlaylistsService {
|
||||
private readonly currentPlaylist = this.store.selectSignal(
|
||||
selectCurrentPlaylist
|
||||
);
|
||||
|
||||
constructor(
|
||||
private dbService: NgxIndexedDBService,
|
||||
private snackBar: MatSnackBar,
|
||||
private translateService: TranslateService
|
||||
private translateService: TranslateService,
|
||||
private store: Store
|
||||
) {}
|
||||
|
||||
getAllPlaylists() {
|
||||
@@ -138,7 +145,10 @@ export class PlaylistsService {
|
||||
);
|
||||
}
|
||||
|
||||
getPortalFavorites(portalId: string) {
|
||||
getPortalFavorites(portalId?: string) {
|
||||
if (!portalId) {
|
||||
portalId = this.currentPlaylist()._id;
|
||||
}
|
||||
return this.dbService
|
||||
.getByID<{
|
||||
favorites: Partial<XtreamItem>[];
|
||||
@@ -146,11 +156,18 @@ export class PlaylistsService {
|
||||
.pipe(
|
||||
map((item) => {
|
||||
if (!item || !item.favorites) return [];
|
||||
return item.favorites.filter(
|
||||
return item.favorites; /* .filter(
|
||||
(itm) =>
|
||||
itm && itm.stream_type && itm.stream_type !== 'live'
|
||||
);
|
||||
})
|
||||
); */
|
||||
}),
|
||||
map((favorites) =>
|
||||
favorites.sort(
|
||||
(a, b) =>
|
||||
new Date(b.added_at).getTime() -
|
||||
new Date(a.added_at).getTime()
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,6 +188,9 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
addPortalFavorite(portalId: string, item: any) {
|
||||
if (!portalId) {
|
||||
portalId = this.currentPlaylist()._id;
|
||||
}
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) =>
|
||||
this.dbService.update(DbStores.Playlists, {
|
||||
@@ -182,6 +202,9 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
removeFromPortalFavorites(portalId: string, favoriteId: number | string) {
|
||||
if (!portalId) {
|
||||
portalId = this.currentPlaylist()._id;
|
||||
}
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) =>
|
||||
this.dbService.update(DbStores.Playlists, {
|
||||
@@ -302,4 +325,56 @@ export class PlaylistsService {
|
||||
removeAll() {
|
||||
return this.dbService.clear(DbStores.Playlists);
|
||||
}
|
||||
|
||||
getPortalRecentlyViewed() {
|
||||
const portalId = this.currentPlaylist()._id;
|
||||
return this.dbService
|
||||
.getByID<{
|
||||
recentlyViewed: Partial<XtreamItem>[];
|
||||
}>(DbStores.Playlists, portalId)
|
||||
.pipe(
|
||||
map((item) => {
|
||||
if (!item || !item.recentlyViewed) return [];
|
||||
return item.recentlyViewed;
|
||||
}),
|
||||
map((items) =>
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
new Date(b.added_at).getTime() -
|
||||
new Date(a.added_at).getTime()
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
addPortalRecentlyViewed(item: { id: string; title: string }) {
|
||||
const portalId = this.currentPlaylist()._id;
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) => {
|
||||
// Check if item already exists in recently viewed
|
||||
if (portal.recentlyViewed?.some((i) => i.id === item.id)) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.dbService.update(DbStores.Playlists, {
|
||||
...portal,
|
||||
recentlyViewed: [...(portal.recentlyViewed ?? []), item],
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
removeFromPortalRecentlyViewed(id: string | number) {
|
||||
const portalId = this.currentPlaylist()._id;
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) =>
|
||||
this.dbService.update(DbStores.Playlists, {
|
||||
...portal,
|
||||
recentlyViewed: portal.recentlyViewed.filter(
|
||||
(i) => i.id !== id
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
PLAYLIST_PARSE_RESPONSE,
|
||||
PLAYLIST_UPDATE,
|
||||
STALKER_REQUEST,
|
||||
STALKER_RESPONSE,
|
||||
XTREAM_REQUEST,
|
||||
XTREAM_RESPONSE,
|
||||
} from '../../../shared/ipc-commands';
|
||||
@@ -73,7 +72,7 @@ export class PwaService extends DataService {
|
||||
payload as { url: string; params: Record<string, string> }
|
||||
);
|
||||
} else if (type === STALKER_REQUEST) {
|
||||
this.forwardStalkerRequest(
|
||||
return this.forwardStalkerRequest(
|
||||
payload as {
|
||||
url: string;
|
||||
macAddress: string;
|
||||
@@ -205,26 +204,41 @@ export class PwaService extends DataService {
|
||||
}
|
||||
}
|
||||
|
||||
forwardStalkerRequest(payload: {
|
||||
async forwardStalkerRequest(payload: {
|
||||
url: string;
|
||||
params: Record<string, string>;
|
||||
macAddress: string;
|
||||
}) {
|
||||
return this.http
|
||||
.get(`${this.corsProxyUrl}/stalker`, {
|
||||
params: {
|
||||
url: payload.url,
|
||||
...payload.params,
|
||||
macAddress: payload.macAddress,
|
||||
},
|
||||
})
|
||||
.subscribe((response) => {
|
||||
window.postMessage({
|
||||
type: STALKER_RESPONSE,
|
||||
payload: (response as any).payload,
|
||||
action: payload.params.action,
|
||||
});
|
||||
try {
|
||||
// Build the query parameters
|
||||
const params = new URLSearchParams({
|
||||
url: payload.url,
|
||||
...payload.params,
|
||||
macAddress: payload.macAddress,
|
||||
});
|
||||
|
||||
// Make the fetch request
|
||||
const response = await fetch(
|
||||
`${this.corsProxyUrl}/stalker?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Error: ${response.statusText} (Status: ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and return the JSON response
|
||||
return (await response.json()).payload;
|
||||
} catch (err) {
|
||||
console.error('Stalker request error:', err);
|
||||
window.postMessage({
|
||||
type: ERROR,
|
||||
message: err.message ?? 'Error: not found',
|
||||
status: err.status ?? 404,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
getPlaylistFromUrl(url: string) {
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from '@ngrx/signals';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { StreamFormat } from '../settings/stream-format.enum';
|
||||
import { Language } from '../settings/language.enum';
|
||||
import { Settings, VideoPlayer } from '../settings/settings.interface';
|
||||
import { StreamFormat } from '../settings/stream-format.enum';
|
||||
import { Theme } from '../settings/theme.enum';
|
||||
import { STORE_KEY } from '../shared/enums/store-keys.enum';
|
||||
|
||||
@@ -62,6 +62,17 @@ export const SettingsStore = signalStore(
|
||||
epgUrl: store.epgUrl(),
|
||||
};
|
||||
},
|
||||
|
||||
getPlayer() {
|
||||
return store.player();
|
||||
},
|
||||
|
||||
isEmbeddedPlayer() {
|
||||
return (
|
||||
store.player() === VideoPlayer.VideoJs ||
|
||||
store.player() === VideoPlayer.Html5Player
|
||||
);
|
||||
},
|
||||
})),
|
||||
withHooks({
|
||||
onInit(store) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Params } from '@angular/router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { readTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { parse } from 'iptv-playlist-parser';
|
||||
@@ -24,9 +26,28 @@ import { DataService } from './data.service';
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TauriService extends DataService {
|
||||
private eventListeners: { [key: string]: () => void } = {};
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
console.log('Tauri service initialized...');
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private async setupEventListeners() {
|
||||
// Listen for player errors
|
||||
this.eventListeners['player-error'] = await listen(
|
||||
'player-error',
|
||||
(event) => {
|
||||
console.error('Player error:', event);
|
||||
this.snackBar.open(
|
||||
`Player Error: ${event.payload as string}`,
|
||||
'Close',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getAppVersion(): string {
|
||||
@@ -66,7 +87,7 @@ export class TauriService extends DataService {
|
||||
payload as { url: string; params: Record<string, string> }
|
||||
);
|
||||
} else if (type === 'STALKER_REQUEST') {
|
||||
this.fetchStalkerData(
|
||||
return this.fetchStalkerData(
|
||||
payload as {
|
||||
url: string;
|
||||
macAddress: string;
|
||||
@@ -75,36 +96,40 @@ export class TauriService extends DataService {
|
||||
);
|
||||
} else if (type === 'OPEN_MPV_PLAYER') {
|
||||
const data = payload as any;
|
||||
return invoke('open_in_mpv', {
|
||||
url: data.url,
|
||||
path: data.mpvPlayerPath || '',
|
||||
title: data.title ?? '',
|
||||
thumbnail: data.thumbnail ?? '',
|
||||
userAgent: data['user-agent'] ?? undefined,
|
||||
referer: data.referer ?? undefined,
|
||||
origin: data.origin ?? undefined,
|
||||
}).catch((error) => {
|
||||
window.postMessage({
|
||||
type: ERROR,
|
||||
message: `Error launching MPV: ${error}`,
|
||||
try {
|
||||
return await invoke('open_in_mpv', {
|
||||
url: data.url,
|
||||
path: data.mpvPlayerPath || '',
|
||||
title: data.title ?? '',
|
||||
thumbnail: data.thumbnail ?? '',
|
||||
userAgent: data['user-agent'] ?? undefined,
|
||||
referer: data.referer ?? undefined,
|
||||
origin: data.origin ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
this.snackBar.open(`Error launching MPV: ${error}`, 'Close', {
|
||||
duration: 5000,
|
||||
});
|
||||
console.error('MPV launch error:', error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
} else if (type === 'OPEN_VLC_PLAYER') {
|
||||
const data = payload as any;
|
||||
return invoke('open_in_vlc', {
|
||||
url: data.url,
|
||||
path: data.vlcPlayerPath || '',
|
||||
userAgent: data['user-agent'] ?? undefined,
|
||||
referer: data.referer ?? undefined,
|
||||
origin: data.origin ?? undefined,
|
||||
}).catch((error) => {
|
||||
window.postMessage({
|
||||
type: ERROR,
|
||||
message: `Error launching VLC: ${error}`,
|
||||
try {
|
||||
return await invoke('open_in_vlc', {
|
||||
url: data.url,
|
||||
path: data.vlcPlayerPath || '',
|
||||
userAgent: data['user-agent'] ?? undefined,
|
||||
referer: data.referer ?? undefined,
|
||||
origin: data.origin ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
this.snackBar.open(`Error launching VLC: ${error}`, 'Close', {
|
||||
duration: 5000,
|
||||
});
|
||||
console.error('VLC launch error:', error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
} else if (type === 'EPG_FETCH_DONE') {
|
||||
window.postMessage({
|
||||
type: EPG_GET_PROGRAM_DONE,
|
||||
@@ -164,12 +189,12 @@ export class TauriService extends DataService {
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
window.postMessage({
|
||||
return await response.json();
|
||||
/* window.postMessage({
|
||||
type: 'STALKER_RESPONSE',
|
||||
payload: result,
|
||||
action: payload.params.action,
|
||||
});
|
||||
}); */
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
window.postMessage({
|
||||
@@ -254,14 +279,40 @@ export class TauriService extends DataService {
|
||||
}
|
||||
|
||||
removeAllListeners(type: string): void {
|
||||
console.error(
|
||||
'Method not implemented. Following type was provided:',
|
||||
type
|
||||
);
|
||||
if (type === 'all') {
|
||||
// Unsubscribe from all Tauri events
|
||||
Object.values(this.eventListeners).forEach((unsubscribe) =>
|
||||
unsubscribe()
|
||||
);
|
||||
this.eventListeners = {};
|
||||
} else if (this.eventListeners[type]) {
|
||||
// Unsubscribe from a specific event
|
||||
this.eventListeners[type]();
|
||||
delete this.eventListeners[type];
|
||||
}
|
||||
|
||||
// Also remove any window message listeners
|
||||
// Note: This is a bit crude, but works for simple cases
|
||||
window.removeEventListener('message', this.getListenerForCommand(type));
|
||||
}
|
||||
|
||||
private getListenerForCommand(command: string): any {
|
||||
// This is a placeholder. In a real implementation, you would need to
|
||||
// store the actual listener functions to be able to remove them
|
||||
return () => {};
|
||||
}
|
||||
|
||||
listenOn(command: string, callback: (...args: any[]) => void): void {
|
||||
window.addEventListener('message', callback);
|
||||
if (command.startsWith('tauri:')) {
|
||||
// For Tauri specific events, use the Tauri event system
|
||||
const tauriEvent = command.replace('tauri:', '');
|
||||
listen(tauriEvent, callback).then((unsubscribe) => {
|
||||
this.eventListeners[command] = unsubscribe;
|
||||
});
|
||||
} else {
|
||||
// For backward compatibility, use window messages
|
||||
window.addEventListener('message', callback);
|
||||
}
|
||||
}
|
||||
|
||||
getAppEnvironment(): string {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
</div>
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>movie</mat-icon>
|
||||
</button>
|
||||
{{
|
||||
(titleTranslationString() | translate) +
|
||||
' (' +
|
||||
selectedCategoryId() +
|
||||
')'
|
||||
}}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="scrollable-content">
|
||||
@if (favorites().length) {
|
||||
<div class="items-grid">
|
||||
@for (item of favorites(); track $index) {
|
||||
<mat-card class="item-card" (click)="openFavorite(item)">
|
||||
<img
|
||||
[src]="
|
||||
item.poster_url ||
|
||||
item.cover ||
|
||||
'assets/default-' +
|
||||
selectedCategoryId() +
|
||||
'.png'
|
||||
"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
[alt]="item.title"
|
||||
class="item-image"
|
||||
/>
|
||||
<mat-card-content>
|
||||
<h3>
|
||||
{{ item?.title || item?.details?.info?.name }}
|
||||
</h3>
|
||||
<p class="added-date">
|
||||
Added:
|
||||
{{ item?.added_at | date: 'd MMMM, y, HH:MM' }}
|
||||
</p>
|
||||
</mat-card-content>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
class="favorite-button"
|
||||
[matTooltip]="
|
||||
'CHANNELS.REMOVE_FAVORITE' | translate
|
||||
"
|
||||
(click)="
|
||||
$event.stopPropagation(); removeFavorite(item)
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-items">
|
||||
<mat-icon>favorite_border</mat-icon>
|
||||
<p>No items in the selected category</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-mpv-player-bar />
|
||||
</div>
|
||||
@@ -0,0 +1,138 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; // Important for proper flex behavior
|
||||
}
|
||||
|
||||
.content-header {
|
||||
text-transform: capitalize;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.favorites-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.favorite-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 12px;
|
||||
flex-grow: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
max-height: 2.4em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.added-date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component';
|
||||
import { MpvPlayerBarComponent } from '../mpv-player-bar/mpv-player-bar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorites-layout',
|
||||
templateUrl: './favorites-layout.component.html',
|
||||
styleUrls: [
|
||||
'./favorites-layout.component.scss',
|
||||
'../../../xtream-tauri/sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
DatePipe,
|
||||
MatCardModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MpvPlayerBarComponent,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class FavoritesLayoutComponent {
|
||||
readonly categories = input<any[]>([]);
|
||||
readonly favorites = input<any[]>([]);
|
||||
readonly selectedCategoryId = input<string>('movie');
|
||||
readonly titleTranslationString = input<string>('CHANNELS.FAVORITES');
|
||||
|
||||
readonly categoryClicked = output<any>();
|
||||
readonly removeFromFavorites = output<any>();
|
||||
readonly openItem = output<any>();
|
||||
|
||||
setCategoryId(categoryId: any) {
|
||||
this.categoryClicked.emit({ category_id: categoryId });
|
||||
}
|
||||
|
||||
removeFavorite(item: any) {
|
||||
this.removeFromFavorites.emit(item);
|
||||
}
|
||||
|
||||
openFavorite(item: any) {
|
||||
this.openItem.emit(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
mat-card {
|
||||
overflow: hidden;
|
||||
transition: all 0.9s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
mat-card:hover {
|
||||
box-shadow:
|
||||
0 14px 28px rgb(0 0 0 / 25%),
|
||||
0 10px 10px rgb(0 0 0 / 22%);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 30px;
|
||||
justify-content: center;
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
width: 170px;
|
||||
|
||||
mat-card-content {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 100%;
|
||||
position: relative !important;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.stream-icon-placeholder {
|
||||
width: 100%;
|
||||
background: #999;
|
||||
max-height: 300px;
|
||||
min-height: 255px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 6px;
|
||||
background-color: #3bb33b;
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error-view/playlist-error-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-grid-list',
|
||||
template: `<div class="grid">
|
||||
@if (isLoading()) {
|
||||
<div class="loading-overlay">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
@for (item of items(); track $index) {
|
||||
@let i = $any(item);
|
||||
<mat-card (click)="itemClicked.emit(item)">
|
||||
@let poster = i.poster_url ?? i.cover;
|
||||
@if (poster) {
|
||||
<img
|
||||
class="stream-icon"
|
||||
[ngSrc]="
|
||||
poster ??
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
fill
|
||||
priority
|
||||
placeholder
|
||||
placeholder="data:image/png;base64,iVBORw0K..."
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<img
|
||||
class="stream-icon"
|
||||
src="./assets/images/default-poster.png"
|
||||
/>
|
||||
}
|
||||
@let rating = i.rating ?? i.rating_imdb;
|
||||
@if (rating) {
|
||||
<div
|
||||
class="rating"
|
||||
[matTooltip]="'XTREAM.IMDB_RATING' | translate"
|
||||
>
|
||||
<mat-icon>star</mat-icon>{{ rating }}
|
||||
</div>
|
||||
}
|
||||
@let title = i.title ?? i.name;
|
||||
<mat-card-actions>
|
||||
<div class="title">
|
||||
{{ title || 'No name' }}
|
||||
</div>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
} @empty {
|
||||
<app-playlist-error-view
|
||||
[title]="
|
||||
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
|
||||
| translate
|
||||
"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
|
||||
| translate
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'EMPTY_CATEGORY'"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (items()?.length > 0) {
|
||||
<mat-paginator
|
||||
[pageIndex]="pageIndex()"
|
||||
[length]="totalPages() * limit()"
|
||||
[pageSize]="limit()"
|
||||
[pageSizeOptions]="pageSizeOptions()"
|
||||
(page)="pageChange.emit($event)"
|
||||
aria-label="Select page"
|
||||
/>
|
||||
} `,
|
||||
styleUrl: './grid-list.component.scss',
|
||||
imports: [
|
||||
TranslatePipe,
|
||||
PlaylistErrorViewComponent,
|
||||
MatCardModule,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTooltip,
|
||||
NgOptimizedImage,
|
||||
MatPaginatorModule,
|
||||
],
|
||||
})
|
||||
export class GridListComponent {
|
||||
readonly items = input<any[]>();
|
||||
readonly isLoading = input<boolean>();
|
||||
readonly itemClicked = output<any>();
|
||||
readonly pageChange = output<any>();
|
||||
|
||||
readonly pageIndex = input<number>();
|
||||
readonly totalPages = input<number>();
|
||||
readonly limit = input<number>();
|
||||
readonly pageSizeOptions = input<number[]>();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<div class="search-container">
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>{{ placeholder }}</mat-label>
|
||||
<input
|
||||
#searchInput
|
||||
matInput
|
||||
spellcheck="false"
|
||||
[ngModel]="searchTerm()"
|
||||
(ngModelChange)="onSearchTermChange()"
|
||||
/>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
@if (filterConfig.length > 0) {
|
||||
<div class="type-filters">
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[(ngModel)]="filters[filter.key]"
|
||||
(ngModelChange)="onFilterChange(filter.key)"
|
||||
>
|
||||
@if (filter.translationKey) {
|
||||
{{ filter.translationKey | translate }}
|
||||
} @else {
|
||||
{{ filter.label }}
|
||||
}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
model,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export interface SearchFilters {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
export interface SearchFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './search-form.component.html',
|
||||
styles: [
|
||||
`
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class SearchFormComponent {
|
||||
@ViewChild('searchInput') searchInput!: ElementRef;
|
||||
@Input() placeholder = 'Search';
|
||||
@Input() filters: SearchFilters = {};
|
||||
@Input() filterConfig: SearchFilter[] = [];
|
||||
@Input() singleSelection = false; // Add this new input
|
||||
searchTerm = model<string>('');
|
||||
|
||||
@Output() filtersChange = new EventEmitter<SearchFilters>();
|
||||
@Output() search = new EventEmitter<void>();
|
||||
|
||||
onFilterChange(changedKey: string) {
|
||||
if (this.singleSelection) {
|
||||
// Reset all other filters when in single selection mode
|
||||
Object.keys(this.filters).forEach((key) => {
|
||||
if (key !== changedKey) {
|
||||
this.filters[key] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
this.filtersChange.emit(this.filters);
|
||||
this.search.emit();
|
||||
}
|
||||
|
||||
onSearchTermChange() {
|
||||
setTimeout(() => {
|
||||
this.searchTerm.set(this.searchInput.nativeElement.value);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
focusSearchInput() {
|
||||
this.searchInput.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-result-item',
|
||||
imports: [MatCardModule, MatIconModule],
|
||||
template: `
|
||||
<mat-card (click)="itemClick.emit()">
|
||||
@if (posterUrl) {
|
||||
<img
|
||||
[src]="posterUrl"
|
||||
[alt]="title"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
class="poster"
|
||||
/>
|
||||
} @else {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>movie</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<h4>{{ title }}</h4>
|
||||
@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>
|
||||
<span>{{ playlistName }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.playlist-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class SearchResultItemComponent {
|
||||
@Input() title = '';
|
||||
@Input() description = '';
|
||||
@Input() posterUrl = '';
|
||||
@Input() type = '';
|
||||
@Input() playlistName = '';
|
||||
@Input() showPlaylistInfo = false;
|
||||
|
||||
@Output() itemClick = new EventEmitter<void>();
|
||||
}
|
||||
@@ -20,4 +20,5 @@ export type PlaylistMeta = Pick<
|
||||
| 'password'
|
||||
| 'macAddress'
|
||||
| 'portalUrl'
|
||||
| 'recentlyViewed'
|
||||
>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
@@ -20,6 +20,9 @@ export class MpvPlayerService {
|
||||
public activeProcesses$ = this.activeProcessesSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
this.initializeEventListeners();
|
||||
this.loadActiveProcesses();
|
||||
}
|
||||
@@ -60,20 +63,6 @@ export class MpvPlayerService {
|
||||
}
|
||||
}
|
||||
|
||||
async openStream(
|
||||
url: string,
|
||||
title: string,
|
||||
thumbnail?: string,
|
||||
mpvPath: string = ''
|
||||
): Promise<number> {
|
||||
return await invoke<number>('open_in_mpv', {
|
||||
url,
|
||||
path: mpvPath,
|
||||
title,
|
||||
thumbnail,
|
||||
});
|
||||
}
|
||||
|
||||
async playStream(processId: number): Promise<void> {
|
||||
await invoke('mpv_play', { processId });
|
||||
}
|
||||
|
||||
@@ -131,3 +131,8 @@ export const setSelectedFilters = createAction(
|
||||
`${STORE_KEY} Set selected filters`,
|
||||
props<{ selectedFilters: string[] }>()
|
||||
);
|
||||
|
||||
export const setCurrentPlaylistId = createAction(
|
||||
`${STORE_KEY} Set current playlist id`,
|
||||
props<{ playlistId: string | undefined }>()
|
||||
);
|
||||
@@ -238,7 +238,7 @@ export class PlaylistEffects {
|
||||
if (playlist.serverUrl && !isTauri()) {
|
||||
this.router.navigate(['/xtreams/', playlist._id]);
|
||||
} else if (playlist.macAddress) {
|
||||
this.router.navigate(['portals', playlist._id]);
|
||||
this.router.navigate(['stalker', playlist._id]);
|
||||
} else {
|
||||
this.router.navigate(['/playlists/', playlist._id]);
|
||||
}
|
||||
|
||||
@@ -240,6 +240,15 @@ export const playlistReducer = createReducer(
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
on(
|
||||
PlaylistActions.setCurrentPlaylistId,
|
||||
(state, { playlistId }): PlaylistState => {
|
||||
return {
|
||||
...state,
|
||||
currentPlaylistId: playlistId,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -254,3 +263,5 @@ export const selectPlaylistId = (state: PlaylistState) =>
|
||||
state.playlists?.selectedId;
|
||||
export const selectPlaylistsLoadingFlag = (state: PlaylistState) =>
|
||||
state.playlists?.allPlaylistsLoaded;
|
||||
export const selectCurrentPlaylistId = (state: PlaylistState) =>
|
||||
state.currentPlaylistId;
|
||||
@@ -23,6 +23,11 @@ export const selectCurrentEpgProgram = createSelector(
|
||||
fromPlaylistState.selectCurrentEpgProgram
|
||||
);
|
||||
|
||||
export const selectCurrentPlaylistId = createSelector(
|
||||
selectPlaylistState,
|
||||
fromPlaylistState.selectCurrentPlaylistId
|
||||
);
|
||||
|
||||
export const selectChannels = createSelector(
|
||||
selectPlaylistState,
|
||||
fromPlaylistState.selectChannels
|
||||
@@ -68,7 +73,7 @@ export const selectActivePlaylistId = createSelector(
|
||||
export const selectPlaylistTitle = createSelector(
|
||||
selectPlaylistsMetaState,
|
||||
fromPlaylistMetaState.getPlaylistMetaEntities,
|
||||
fromPlaylistState.selectPlaylistId,
|
||||
selectCurrentPlaylistId,
|
||||
(data) => {
|
||||
if (data.selectedId === GLOBAL_FAVORITES_PLAYLIST_ID) {
|
||||
return 'Global favorites';
|
||||
@@ -93,9 +98,10 @@ export const selectPlaylistEntities = createSelector(
|
||||
export const selectCurrentPlaylist = createSelector(
|
||||
selectPlaylistEntities,
|
||||
selectRouteParam('id'),
|
||||
(entities, id) => {
|
||||
selectCurrentPlaylistId,
|
||||
(entities, id, currentPlaylistId) => {
|
||||
if (entities) {
|
||||
return entities[id];
|
||||
return entities[id] || entities[currentPlaylistId];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface PlaylistState {
|
||||
epgAvailable: boolean;
|
||||
channels: Channel[]; // TODO: use entity store
|
||||
playlists: PlaylistMetaState;
|
||||
currentPlaylistId: string | undefined;
|
||||
}
|
||||
|
||||
export const initialState: PlaylistState = {
|
||||
@@ -17,4 +18,5 @@ export const initialState: PlaylistState = {
|
||||
epgAvailable: false,
|
||||
channels: [],
|
||||
playlists: initialPlaylistMetaState,
|
||||
currentPlaylistId: undefined,
|
||||
};
|
||||
@@ -1,61 +1,31 @@
|
||||
@if (selectedCategory()) {
|
||||
<div class="grid">
|
||||
@for (item of paginatedContent(); track $index) {
|
||||
@let i = $any(item);
|
||||
<mat-card (click)="onItemClick(item)">
|
||||
@if (i.poster_url) {
|
||||
<img
|
||||
class="stream-icon"
|
||||
[ngSrc]="
|
||||
i.poster_url ?? './assets/images/default-poster.png'
|
||||
"
|
||||
fill
|
||||
priority
|
||||
placeholder
|
||||
placeholder="data:image/png;base64,iVBORw0K..."
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<img
|
||||
class="stream-icon"
|
||||
src="./assets/images/default-poster.png"
|
||||
/>
|
||||
}
|
||||
@let rating = $any(item).rating;
|
||||
@if (rating) {
|
||||
<div
|
||||
class="rating"
|
||||
[matTooltip]="'XTREAM.IMDB_RATING' | translate"
|
||||
>
|
||||
<mat-icon>star</mat-icon>{{ rating }}
|
||||
</div>
|
||||
}
|
||||
<mat-card-actions>
|
||||
<div class="title">{{ i.title || 'No name' }}</div>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
} @empty {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION' | translate
|
||||
@if (isStalker && selectedItem()) {
|
||||
@if (contentType === 'vod') {
|
||||
<app-vod-details
|
||||
[item]="selectedItem()"
|
||||
(playClicked)="
|
||||
createLinkToPlayVod(
|
||||
selectedItem().cmd,
|
||||
selectedItem().info.name,
|
||||
selectedItem().info.movie_image
|
||||
)
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'EMPTY_CATEGORY'"
|
||||
(addToFavoritesClicked)="addToFavorites($event)"
|
||||
(removeFromFavoritesClicked)="removeFromFavorites($event)"
|
||||
/>
|
||||
} @else if (contentType === 'series') {
|
||||
<app-stalker-series-view />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (paginatedContent().length > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalPages() * limit()"
|
||||
[pageSize]="limit()"
|
||||
} @else {
|
||||
<app-grid-list
|
||||
[isLoading]="isPaginatedContentLoading()"
|
||||
[items]="paginatedContent()"
|
||||
(itemClicked)="onItemClick($event)"
|
||||
[pageIndex]="pageIndex()"
|
||||
[totalPages]="totalPages()"
|
||||
[limit]="limit()"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
(page)="onPageChange($event)"
|
||||
aria-label="Select page"
|
||||
(pageChange)="onPageChange($event)"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
|
||||
@@ -1,82 +1,4 @@
|
||||
:host {
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
mat-card {
|
||||
overflow: hidden;
|
||||
transition: all 0.9s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
mat-card:hover {
|
||||
box-shadow: 0 14px 28px rgb(0 0 0 / 25%), 0 10px 10px rgb(0 0 0 / 22%);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 30px;
|
||||
justify-content: center;
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
width: 170px;
|
||||
|
||||
mat-card-content {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 100%;
|
||||
position: relative !important;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
background-color: #f0f0f0
|
||||
}
|
||||
|
||||
.stream-icon-placeholder {
|
||||
width: 100%;
|
||||
background: #999;
|
||||
max-height: 300px;
|
||||
min-height: 255px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 6px;
|
||||
background-color: #3bb33b;
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
/* padding: 10px; */
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
|
||||
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../../stalker/stalker.store';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
|
||||
import { XtreamStore } from '../xtream.store';
|
||||
|
||||
@@ -15,35 +17,87 @@ import { XtreamStore } from '../xtream.store';
|
||||
styleUrls: ['./category-content-view.component.scss'],
|
||||
imports: [
|
||||
MatCardModule,
|
||||
MatIcon,
|
||||
MatPaginatorModule,
|
||||
MatTooltip,
|
||||
NgOptimizedImage,
|
||||
PlaylistErrorViewComponent,
|
||||
TranslatePipe,
|
||||
VodDetailsComponent,
|
||||
GridListComponent,
|
||||
StalkerSeriesViewComponent,
|
||||
],
|
||||
})
|
||||
export class CategoryContentViewComponent {
|
||||
export class CategoryContentViewComponent implements OnInit {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
readonly limit = this.xtreamStore.limit;
|
||||
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
|
||||
readonly contentType = this.activatedRoute.snapshot.data['contentType'];
|
||||
private readonly store = this.isStalker
|
||||
? inject(StalkerStore)
|
||||
: inject(XtreamStore);
|
||||
|
||||
readonly limit = this.store.limit;
|
||||
readonly pageIndex = this.store.page;
|
||||
readonly pageSizeOptions = [5, 10, 25, 50, 100];
|
||||
readonly paginatedContent = this.xtreamStore.getPaginatedContent;
|
||||
readonly selectedCategory = this.xtreamStore.getSelectedCategory;
|
||||
readonly totalPages = this.xtreamStore.getTotalPages;
|
||||
readonly selectedCategory = this.store.getSelectedCategory;
|
||||
readonly paginatedContent = this.store.getPaginatedContent;
|
||||
readonly isPaginatedContentLoading = this.store.isPaginatedContentLoading;
|
||||
readonly selectedItem = this.store.selectedItem;
|
||||
readonly totalPages = this.store.getTotalPages;
|
||||
readonly bigStore = inject(Store);
|
||||
|
||||
seasons = [];
|
||||
|
||||
ngOnInit() {
|
||||
const { categoryId } = this.activatedRoute.snapshot.params;
|
||||
if (categoryId) this.store.setSelectedCategory(categoryId);
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent) {
|
||||
this.xtreamStore.setPage(event.pageIndex + 1);
|
||||
this.xtreamStore.setLimit(event.pageSize);
|
||||
this.store.setPage(event.pageIndex);
|
||||
this.store.setLimit(event.pageSize);
|
||||
localStorage.setItem('xtream-page-size', event.pageSize.toString());
|
||||
}
|
||||
|
||||
onItemClick(item: any) {
|
||||
this.xtreamStore.setSelectedItem(item);
|
||||
this.router.navigate([item.xtream_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
const selectedItem = {
|
||||
id: item.id,
|
||||
cmd: item.cmd,
|
||||
info: {
|
||||
movie_image: item.screenshot_uri,
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
director: item.director,
|
||||
releasedate: item.year,
|
||||
genre: item.genres_str,
|
||||
actors: item.actors,
|
||||
rating_imdb: item.rating_imdb,
|
||||
rating_kinopoisk: item.rating_kinopoisk,
|
||||
},
|
||||
};
|
||||
|
||||
this.store.setSelectedItem(selectedItem);
|
||||
if (!this.isStalker) {
|
||||
this.router.navigate([item.xtream_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createLinkToPlayVod(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string
|
||||
) {
|
||||
await this.store.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
addToFavorites(item: any) {
|
||||
console.debug('Add to favorites', item);
|
||||
this.store.addToFavorites(item);
|
||||
}
|
||||
|
||||
removeFromFavorites(favoriteId: string) {
|
||||
console.debug('Remove from favorites', favoriteId);
|
||||
this.store.removeFromFavorites(favoriteId);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
grid-template-columns: minmax(250px, 30%) minmax(300px, 1fr);
|
||||
grid-template-areas:
|
||||
"image details"
|
||||
"seasons seasons";
|
||||
grid-template-areas:
|
||||
'image details'
|
||||
'seasons seasons';
|
||||
padding: 20px;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -61,6 +61,7 @@
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.placeholder-cover {
|
||||
@@ -87,10 +88,10 @@ app-season-container {
|
||||
@media (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"image"
|
||||
"details"
|
||||
"seasons";
|
||||
grid-template-areas:
|
||||
'image'
|
||||
'details'
|
||||
'seasons';
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +1,8 @@
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
</div>
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
<h2>
|
||||
{{
|
||||
selectedCategoryId() +
|
||||
' ' +
|
||||
('CHANNELS.FAVORITES' | translate)
|
||||
}}
|
||||
</h2>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="scrollable-content">
|
||||
@if (favoritesToShow().length) {
|
||||
<div class="items-grid">
|
||||
@for (item of favoritesToShow(); track item.content_id) {
|
||||
<mat-card class="item-card" (click)="openItem(item)">
|
||||
<img
|
||||
[src]="
|
||||
item.poster_url ||
|
||||
'assets/default-' +
|
||||
selectedCategoryId() +
|
||||
'.png'
|
||||
"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
[alt]="item.title"
|
||||
class="item-image"
|
||||
/>
|
||||
<mat-card-content>
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p class="added-date">
|
||||
Added:
|
||||
{{ item.added_at | date: 'd MMMM, y, hh:mm' }}
|
||||
</p>
|
||||
</mat-card-content>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
class="favorite-button"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
removeFromFavorites(item)
|
||||
"
|
||||
>
|
||||
<mat-icon>favorite</mat-icon>
|
||||
</button>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-items">
|
||||
<mat-icon>favorite_border</mat-icon>
|
||||
<p>No favorite {{ selectedCategoryId() }} items</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-mpv-player-bar />
|
||||
</div>
|
||||
<app-favorites-layout
|
||||
[categories]="categories()"
|
||||
[favorites]="favoritesToShow()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
(removeFavorite)="removeFromFavorites($event)"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
@@ -1,126 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row; // Changed from column to row
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%; // Added to ensure full width
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; // Important for proper flex behavior
|
||||
}
|
||||
|
||||
.content-header {
|
||||
text-transform: capitalize;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.favorites-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.favorite-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 12px;
|
||||
flex-grow: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
max-height: 2.4em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.added-date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,18 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, switchMap } from 'rxjs';
|
||||
import { XtreamCategory } from '../../../../shared/xtream-category.interface';
|
||||
import { MpvPlayerBarComponent } from '../../shared/components/mpv-player-bar/mpv-player-bar.component';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import { selectActivePlaylist } from '../../state/selectors';
|
||||
import { CategoryViewComponent } from '../category-view/category-view.component';
|
||||
import { FavoriteItem } from '../services/favorite-item.interface';
|
||||
import { FavoritesService } from '../services/favorites.service';
|
||||
import { XtreamStore } from '../xtream.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorites',
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
DatePipe,
|
||||
MatCardModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MpvPlayerBarComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
imports: [FavoritesLayoutComponent, MatCardModule],
|
||||
templateUrl: './favorites.component.html',
|
||||
styleUrls: ['./favorites.component.scss', '../sidebar.scss'],
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
</button>
|
||||
<h2>{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}</h2>
|
||||
} @else {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@@ -42,7 +42,7 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
|
||||
button {
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
<span class="portal-name">{{
|
||||
currentPlaylist()?.title || 'Unknown Portal'
|
||||
}}</span>
|
||||
<span class="portal-label">{{ xtreamStore.portalStatus() }}</span>
|
||||
@if (!isStalkerPlaylist()) {
|
||||
<span class="portal-label">{{
|
||||
xtreamStore.portalStatus()
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="portal-actions">
|
||||
<button
|
||||
@@ -22,14 +26,16 @@
|
||||
>
|
||||
<mat-icon>home</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-button"
|
||||
(click)="openAccountInfo()"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
</button>
|
||||
@if (!isStalkerPlaylist()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-button"
|
||||
(click)="openAccountInfo()"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-button"
|
||||
@@ -45,36 +51,16 @@
|
||||
<div class="nav-section">
|
||||
<div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div>
|
||||
<mat-nav-list>
|
||||
<a
|
||||
mat-list-item
|
||||
(click)="categoryClick.emit('vod')"
|
||||
[class.active]="isContentTypeActive('vod')"
|
||||
>
|
||||
<mat-icon matListItemIcon>movie</mat-icon>
|
||||
<span matListItemTitle>{{
|
||||
'PORTALS.SIDEBAR.MOVIES' | translate
|
||||
}}</span>
|
||||
</a>
|
||||
<a
|
||||
mat-list-item
|
||||
(click)="categoryClick.emit('live')"
|
||||
[class.active]="isContentTypeActive('live')"
|
||||
>
|
||||
<mat-icon matListItemIcon>live_tv</mat-icon>
|
||||
<span matListItemTitle>{{
|
||||
'PORTALS.SIDEBAR.LIVE_TV' | translate
|
||||
}}</span>
|
||||
</a>
|
||||
<a
|
||||
mat-list-item
|
||||
(click)="categoryClick.emit('series')"
|
||||
[class.active]="isContentTypeActive('series')"
|
||||
>
|
||||
<mat-icon matListItemIcon>tv</mat-icon>
|
||||
<span matListItemTitle>{{
|
||||
'PORTALS.SIDEBAR.SERIES' | translate
|
||||
}}</span>
|
||||
</a>
|
||||
@for (item of navigationItems(); track item.id) {
|
||||
<a
|
||||
mat-list-item
|
||||
[class.active]="isContentTypeActive(item.id)"
|
||||
(click)="categoryClick.emit(item.id)"
|
||||
>
|
||||
<mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
|
||||
<span matListItemTitle>{{ item.labelKey | translate }}</span>
|
||||
</a>
|
||||
}
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, input, output } from '@angular/core';
|
||||
import { Component, computed, inject, input, output } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -12,6 +12,7 @@ import { SettingsComponent } from '../../settings/settings.component';
|
||||
import { selectPlaylistById } from '../../state/selectors';
|
||||
import { AccountInfoComponent } from '../account-info/account-info.component';
|
||||
import { XtreamStore } from '../xtream.store';
|
||||
import { NavigationItem } from './navigation.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation',
|
||||
@@ -34,15 +35,24 @@ export class NavigationComponent {
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
readonly portalStatus = input<'active' | 'inactive' | 'expired'>();
|
||||
readonly selectedContentType = input<string>();
|
||||
|
||||
readonly categoryClick = output<'vod' | 'live' | 'series'>();
|
||||
readonly categoryClick = output<string>();
|
||||
readonly pageClicked = output<'search' | 'recent' | 'favorites'>();
|
||||
|
||||
readonly currentPlaylist = this.store.selectSignal(
|
||||
selectPlaylistById(this.activatedRoute.snapshot.params.id)
|
||||
);
|
||||
|
||||
readonly isStalkerPlaylist = computed(
|
||||
() => this.currentPlaylist().macAddress
|
||||
);
|
||||
|
||||
readonly navigationItems = input<NavigationItem[]>();
|
||||
|
||||
getStatusColor(): string {
|
||||
if (this.isStalkerPlaylist()) return 'status-active';
|
||||
|
||||
switch (this.portalStatus()) {
|
||||
case 'active':
|
||||
return 'status-active';
|
||||
@@ -56,6 +66,8 @@ export class NavigationComponent {
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
if (this.isStalkerPlaylist()) return 'play_circle';
|
||||
|
||||
switch (this.portalStatus()) {
|
||||
case 'active':
|
||||
return 'check_circle';
|
||||
@@ -92,8 +104,8 @@ export class NavigationComponent {
|
||||
});
|
||||
}
|
||||
|
||||
isContentTypeActive(type: 'live' | 'vod' | 'series'): boolean {
|
||||
return this.xtreamStore.selectedContentType() === type;
|
||||
isContentTypeActive(type: string) {
|
||||
return this.selectedContentType() === type;
|
||||
}
|
||||
|
||||
openPlaylistInfo() {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface NavigationItem {
|
||||
id: string;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { XtreamCategory } from '../../../../shared/xtream-category.interface';
|
||||
import { XtreamItem } from '../../../../shared/xtream-item.interface';
|
||||
import { FilterPipe } from '../../shared/pipes/filter.pipe';
|
||||
@@ -53,9 +53,9 @@ interface EpgProgram {
|
||||
MatIconButton,
|
||||
MatListModule,
|
||||
MatInputModule,
|
||||
TranslateModule,
|
||||
TranslatePipe,
|
||||
MatTooltipModule,
|
||||
]
|
||||
],
|
||||
})
|
||||
export class PortalChannelsListComponent implements AfterViewInit {
|
||||
@Output() playClicked = new EventEmitter<any>();
|
||||
|
||||
@@ -1,53 +1,24 @@
|
||||
<div class="search-page">
|
||||
<div class="header">
|
||||
<h2>Search</h2>
|
||||
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
|
||||
@if (isGlobalSearch) {
|
||||
<button mat-icon-button (click)="dialogRef.close()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="search-container">
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search content</mat-label>
|
||||
<input
|
||||
#searchInput
|
||||
matInput
|
||||
spellcheck="false"
|
||||
autofocus
|
||||
[(ngModel)]="searchTerm"
|
||||
(ngModelChange)="onSearch()"
|
||||
/>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="type-filters">
|
||||
<mat-checkbox
|
||||
[(ngModel)]="filters.live"
|
||||
(ngModelChange)="onSearch()"
|
||||
>
|
||||
Live TV
|
||||
</mat-checkbox>
|
||||
<mat-checkbox
|
||||
[(ngModel)]="filters.movie"
|
||||
(ngModelChange)="onSearch()"
|
||||
>
|
||||
Movies
|
||||
</mat-checkbox>
|
||||
<mat-checkbox
|
||||
[(ngModel)]="filters.series"
|
||||
(ngModelChange)="onSearch()"
|
||||
>
|
||||
Series
|
||||
</mat-checkbox>
|
||||
<app-search-form
|
||||
[(searchTerm)]="searchTerm"
|
||||
[filters]="filters"
|
||||
[filterConfig]="filterConfig"
|
||||
/>
|
||||
|
||||
@if (!isGlobalSearch && xtreamStore.searchResults().length > 0) {
|
||||
<div class="results-count">
|
||||
Found {{ xtreamStore.searchResults().length }} results
|
||||
</div>
|
||||
|
||||
@if (!isGlobalSearch && xtreamStore.searchResults().length > 0) {
|
||||
<div class="results-count">
|
||||
Found {{ xtreamStore.searchResults().length }} results
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="results-container">
|
||||
@if (xtreamStore.searchResults().length > 0) {
|
||||
@@ -79,55 +50,15 @@
|
||||
item of $any(group).value;
|
||||
track item.id
|
||||
) {
|
||||
<mat-card (click)="selectItem(item)">
|
||||
<mat-card-content>
|
||||
@if (item.poster_url) {
|
||||
<img
|
||||
[src]="item.poster_url"
|
||||
[alt]="item.title"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
class="poster"
|
||||
/>
|
||||
}
|
||||
@if (!item.poster_url) {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>movie</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.description }}</p>
|
||||
<div
|
||||
class="type-badge"
|
||||
[class]="item.type"
|
||||
>
|
||||
{{ item.type }}
|
||||
</div>
|
||||
@if (
|
||||
item.playlist_id &&
|
||||
isGlobalSearch
|
||||
) {
|
||||
<div class="playlist-badge">
|
||||
{{ item.playlist_name }}
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
isGlobalSearch &&
|
||||
item.playlist_name
|
||||
) {
|
||||
<div class="playlist-badge">
|
||||
<mat-icon
|
||||
>playlist_play</mat-icon
|
||||
>
|
||||
<span>{{
|
||||
item.playlist_name
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<app-search-result-item
|
||||
[title]="item.title"
|
||||
[description]="item.description"
|
||||
[posterUrl]="item.poster_url"
|
||||
[type]="item.type"
|
||||
[playlistName]="item.playlist_name"
|
||||
[showPlaylistInfo]="isGlobalSearch"
|
||||
(itemClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,12 @@ import { KeyValuePipe } from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
Optional,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -22,10 +23,12 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { debounceTime, Subject } from 'rxjs';
|
||||
import { XtreamItem } from '../../../../shared/xtream-item.interface';
|
||||
import { DatabaseService } from '../../services/database.service';
|
||||
import { SearchFormComponent } from '../../shared/components/search-form/search-form.component';
|
||||
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
|
||||
import { XtreamStore } from '../xtream.store';
|
||||
|
||||
interface SearchResultsData {
|
||||
@@ -35,47 +38,68 @@ interface SearchResultsData {
|
||||
@Component({
|
||||
selector: 'app-search-results',
|
||||
imports: [
|
||||
MatIconButton,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatIcon,
|
||||
MatCheckboxModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
KeyValuePipe,
|
||||
MatCardModule,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
SearchFormComponent,
|
||||
SearchResultItemComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
providers: [],
|
||||
templateUrl: './search-results.component.html',
|
||||
styleUrls: ['./search-results.component.scss']
|
||||
styleUrls: ['./search-results.component.scss'],
|
||||
})
|
||||
export class SearchResultsComponent implements AfterViewInit, OnDestroy {
|
||||
export class SearchResultsComponent implements AfterViewInit {
|
||||
@ViewChild('searchInput') searchInput!: ElementRef;
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
readonly router = inject(Router);
|
||||
readonly activatedRoute = inject(ActivatedRoute);
|
||||
readonly databaseService = inject(DatabaseService);
|
||||
searchTerm = '';
|
||||
searchTerm = signal('');
|
||||
filters = {
|
||||
live: true,
|
||||
movie: true,
|
||||
series: true,
|
||||
};
|
||||
isGlobalSearch = false;
|
||||
private searchSubject = new Subject<string>();
|
||||
private readonly DEBOUNCE_TIME = 300;
|
||||
|
||||
readonly filterConfig = [
|
||||
{
|
||||
key: 'live',
|
||||
label: 'Live TV',
|
||||
translationKey: 'PORTALS.SIDEBAR.LIVE_TV',
|
||||
},
|
||||
{
|
||||
key: 'movie',
|
||||
label: 'Movies',
|
||||
translationKey: 'PORTALS.SIDEBAR.MOVIES',
|
||||
},
|
||||
{
|
||||
key: 'series',
|
||||
label: 'Series',
|
||||
translationKey: 'PORTALS.SIDEBAR.SERIES',
|
||||
},
|
||||
];
|
||||
|
||||
constructor(
|
||||
@Optional() @Inject(MAT_DIALOG_DATA) private data: SearchResultsData,
|
||||
@Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData,
|
||||
@Optional() public dialogRef: MatDialogRef<SearchResultsComponent>
|
||||
) {
|
||||
this.isGlobalSearch = data?.isGlobalSearch || false;
|
||||
|
||||
this.searchSubject
|
||||
.pipe(debounceTime(this.DEBOUNCE_TIME))
|
||||
.subscribe(() => {
|
||||
effect(() => {
|
||||
this.searchTerm();
|
||||
if (this.searchTerm().length >= 3) {
|
||||
this.executeSearch();
|
||||
});
|
||||
} else {
|
||||
this.xtreamStore.resetSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
@@ -85,24 +109,16 @@ export class SearchResultsComponent implements AfterViewInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
onSearch() {
|
||||
if (this.searchTerm.length >= 3) {
|
||||
this.searchSubject.next(this.searchTerm);
|
||||
} else {
|
||||
this.xtreamStore.resetSearchResults();
|
||||
}
|
||||
}
|
||||
|
||||
private executeSearch() {
|
||||
const types = Object.entries(this.filters)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([type]) => type);
|
||||
|
||||
if (this.isGlobalSearch) {
|
||||
this.searchGlobal(this.searchTerm, types);
|
||||
this.searchGlobal(this.searchTerm(), types);
|
||||
} else {
|
||||
this.xtreamStore.searchContent({
|
||||
term: this.searchTerm,
|
||||
term: this.searchTerm(),
|
||||
types,
|
||||
});
|
||||
}
|
||||
@@ -123,10 +139,6 @@ export class SearchResultsComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.searchSubject.complete();
|
||||
}
|
||||
|
||||
selectItem(
|
||||
item: XtreamItem & { playlist_id?: string; playlist_name?: string }
|
||||
) {
|
||||
|
||||
@@ -26,7 +26,6 @@ export class VodDetailsComponent implements OnInit, OnDestroy {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
readonly theme = this.settingsStore.theme;
|
||||
private readonly selectedContentType = this.xtreamStore.selectedContentType;
|
||||
|
||||
readonly isFavorite = this.xtreamStore.isFavorite;
|
||||
readonly selectedItem = this.xtreamStore.selectedItem;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
</div>
|
||||
<app-category-view
|
||||
@@ -16,6 +19,10 @@
|
||||
<button mat-icon-button (click)="historyBack()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
}
|
||||
{{ getContentLabel() }}
|
||||
</h2>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row; // Changed from column to row
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%; // Added to ensure full width
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CategoryViewComponent } from './category-view/category-view.component';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { XtreamCategory } from '../../../shared/xtream-category.interface';
|
||||
import { MpvPlayerBarComponent } from '../shared/components/mpv-player-bar/mpv-player-bar.component';
|
||||
import { XtreamStore } from './xtream.store';
|
||||
@@ -25,6 +25,7 @@ import { XtreamStore } from './xtream.store';
|
||||
export class XtreamMainContainerComponent implements OnInit {
|
||||
readonly router = inject(Router);
|
||||
readonly route = inject(ActivatedRoute);
|
||||
readonly translateService = inject(TranslateService);
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
|
||||
@@ -51,7 +52,7 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
this.xtreamStore.getSelectedCategory() === null ||
|
||||
this.xtreamStore.getSelectedCategory() === undefined
|
||||
) {
|
||||
return 'Select a category';
|
||||
return this.translateService.instant('PORTALS.SELECT_CATEGORY');
|
||||
} else {
|
||||
const selectedCategory = this.xtreamStore.getSelectedCategory();
|
||||
return selectedCategory
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
<app-navigation
|
||||
class="navigation-sidebar"
|
||||
[portalStatus]="portalStatus()"
|
||||
[navigationItems]="mainNavigationItems"
|
||||
(categoryClick)="handleCategoryClick($event)"
|
||||
(pageClicked)="handlePageClick($event)"
|
||||
[selectedContentType]="xtreamStore.selectedContentType()"
|
||||
/>
|
||||
<router-outlet />
|
||||
</div>
|
||||
@@ -5,6 +5,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
import * as PlaylistActions from '../../state/actions';
|
||||
import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
|
||||
import { NavigationComponent } from '../navigation/navigation.component';
|
||||
import { NavigationItem } from '../navigation/navigation.interface';
|
||||
import { XtreamStore } from '../xtream.store';
|
||||
|
||||
@Component({
|
||||
@@ -22,13 +23,31 @@ export class XtreamShellComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
readonly getImportCount = this.xtreamStore.getImportCount;
|
||||
readonly isImporting = this.xtreamStore.isImporting;
|
||||
readonly itemsToImport = this.xtreamStore.itemsToImport;
|
||||
readonly portalStatus = this.xtreamStore.portalStatus;
|
||||
|
||||
readonly mainNavigationItems: NavigationItem[] = [
|
||||
{
|
||||
id: 'vod',
|
||||
icon: 'movie',
|
||||
labelKey: 'PORTALS.SIDEBAR.MOVIES',
|
||||
},
|
||||
{
|
||||
id: 'live',
|
||||
icon: 'live_tv',
|
||||
labelKey: 'PORTALS.SIDEBAR.LIVE_TV',
|
||||
},
|
||||
{
|
||||
id: 'series',
|
||||
icon: 'tv',
|
||||
labelKey: 'PORTALS.SIDEBAR.SERIES',
|
||||
},
|
||||
];
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
|
||||
@@ -46,7 +46,7 @@ const initialState: XtreamState = {
|
||||
liveStreams: [],
|
||||
vodStreams: [],
|
||||
serialStreams: [],
|
||||
page: 1,
|
||||
page: 0,
|
||||
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
|
||||
selectedCategoryId: null,
|
||||
searchResults: [],
|
||||
@@ -115,7 +115,7 @@ export const XtreamStore = signalStore(
|
||||
);
|
||||
}),
|
||||
getPaginatedContent: computed(() => {
|
||||
const startIndex = (store.page() - 1) * store.limit();
|
||||
const startIndex = store.page() * store.limit();
|
||||
const endIndex = startIndex + store.limit();
|
||||
const categoryId = store.selectedCategoryId();
|
||||
|
||||
@@ -135,6 +135,7 @@ export const XtreamStore = signalStore(
|
||||
.filter((item) => item.category_id === categoryId)
|
||||
.slice(startIndex, endIndex);
|
||||
}),
|
||||
isPaginatedContentLoading: computed(() => store.isLoadingContent()),
|
||||
getTotalPages: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) return 0;
|
||||
@@ -461,7 +462,7 @@ export const XtreamStore = signalStore(
|
||||
const setSelectedCategory = (categoryId: number) => {
|
||||
patchState(store, {
|
||||
selectedCategoryId: Number(categoryId),
|
||||
page: 1,
|
||||
page: 0,
|
||||
});
|
||||
};
|
||||
const setSelectedContentType = (type: 'live' | 'vod' | 'series') =>
|
||||
@@ -685,6 +686,7 @@ export const XtreamStore = signalStore(
|
||||
);
|
||||
|
||||
return {
|
||||
createLinkToPlayVod: () => {},
|
||||
fetchXtreamPlaylist,
|
||||
fetchLiveCategories,
|
||||
fetchVodCategories,
|
||||
@@ -840,6 +842,12 @@ export const XtreamStore = signalStore(
|
||||
store.selectedContentType() === 'live'
|
||||
);
|
||||
},
|
||||
addToFavorites(item: any) {
|
||||
console.log('not needed now', item);
|
||||
},
|
||||
removeFromFavorites(favoriteId: string) {
|
||||
console.log('not needed now', favoriteId);
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
.container {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: 350px 450px;
|
||||
justify-content: center;
|
||||
grid-template-columns: 320px 1fr;
|
||||
padding: 0 10px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
@@ -65,6 +65,7 @@
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 5px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<div class="expired-account">
|
||||
@if (viewType === 'ERROR') {
|
||||
<img src="./assets/images/broken-tv.svg" />
|
||||
} @else if (viewType === 'EMPTY_CATEGORY') {
|
||||
<img
|
||||
style="max-width: 350px"
|
||||
src="./assets/images/empty-category.svg"
|
||||
/>
|
||||
} @else if (viewType === 'NO_SEARCH_RESULTS') {
|
||||
<mat-icon class="icon">search</mat-icon>
|
||||
}
|
||||
<div class="image-container">
|
||||
@if (viewType === 'ERROR') {
|
||||
<img src="./assets/images/broken-tv.svg" />
|
||||
} @else if (viewType === 'EMPTY_CATEGORY') {
|
||||
<img
|
||||
style="max-width: 350px"
|
||||
src="./assets/images/empty-category.svg"
|
||||
/>
|
||||
} @else if (viewType === 'NO_SEARCH_RESULTS') {
|
||||
<mat-icon class="icon">search</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h3>{{ title }}</h3>
|
||||
{{ description }}
|
||||
</div>
|
||||
|
||||
@if (showActionButtons) {
|
||||
<div>
|
||||
<div class="actions">
|
||||
<button mat-stroked-button routerLink="/">
|
||||
<mat-icon>home</mat-icon>
|
||||
{{ 'PORTALS.ERROR_VIEW.HOME' | translate }}
|
||||
|
||||
@@ -11,6 +11,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.image-container {
|
||||
max-width: 400px;
|
||||
height: auto;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
button {
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
height: 64px;
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon, MatIconModule } from '@angular/material/icon';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { XtreamVodDetails } from '../../../../shared/xtream-vod-details.interface';
|
||||
import { PlaylistsService } from '../../services/playlists.service';
|
||||
import { SafePipe } from './safe.pipe';
|
||||
@@ -23,12 +23,11 @@ import { SafePipe } from './safe.pipe';
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
NgIf,
|
||||
MatIconModule,
|
||||
SafePipe,
|
||||
TranslateModule,
|
||||
MatProgressSpinnerModule,
|
||||
]
|
||||
NgIf,
|
||||
SafePipe,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class VodDetailsComponent implements OnInit {
|
||||
@Input({ required: true }) item: XtreamVodDetails;
|
||||
@@ -39,7 +38,9 @@ export class VodDetailsComponent implements OnInit {
|
||||
|
||||
private playlistService = inject(PlaylistsService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private portalId = this.route.snapshot.paramMap.get('id');
|
||||
private portalId =
|
||||
this.route.snapshot.paramMap.get('id') ??
|
||||
this.route.parent.snapshot.params.id;
|
||||
|
||||
isFavorite = false;
|
||||
isLoading = false;
|
||||
@@ -52,21 +53,26 @@ export class VodDetailsComponent implements OnInit {
|
||||
this.playlistService
|
||||
.getPortalFavorites(this.portalId)
|
||||
.subscribe((favorites) => {
|
||||
this.isFavorite = favorites.some((i) => {
|
||||
const hasStreamId =
|
||||
i?.stream_id !== undefined &&
|
||||
this.item?.movie_data?.stream_id !== undefined;
|
||||
const hasId =
|
||||
(i as any)?.details?.id !== undefined &&
|
||||
(this.item as any)?.id !== undefined;
|
||||
if (!favorites || favorites.length === 0) {
|
||||
this.isFavorite = false;
|
||||
} else {
|
||||
this.isFavorite = favorites.some((i) => {
|
||||
const hasStreamId =
|
||||
i?.stream_id !== undefined &&
|
||||
this.item?.movie_data?.stream_id !== undefined;
|
||||
const hasId =
|
||||
(i as any)?.details?.id !== undefined &&
|
||||
(this.item as any)?.id !== undefined;
|
||||
|
||||
return (
|
||||
(hasStreamId &&
|
||||
i.stream_id === this.item.movie_data.stream_id) ||
|
||||
(hasId &&
|
||||
(i as any).details.id === (this.item as any).id)
|
||||
);
|
||||
});
|
||||
return (
|
||||
(hasStreamId &&
|
||||
i.stream_id ===
|
||||
this.item.movie_data.stream_id) ||
|
||||
(hasId &&
|
||||
(i as any).details.id === (this.item as any).id)
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user