From f1dfef6d9ffccb8cbf9a78371c31cb340f107e40 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 8 Sep 2025 18:12:16 +0200 Subject: [PATCH] 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. --- shared/xtream-item.interface.ts | 1 + src/app/services/player.service.ts | 2 + src/app/services/playlists.service.ts | 87 ++++++++++- src/app/services/pwa.service.ts | 48 +++--- src/app/services/settings-store.service.ts | 13 +- src/app/services/tauri.service.ts | 119 ++++++++++----- .../favorites-layout.component.html | 82 +++++++++++ .../favorites-layout.component.scss | 138 ++++++++++++++++++ .../favorites-layout.component.ts | 50 +++++++ .../grid-list/grid-list.component.scss | 79 ++++++++++ .../grid-list/grid-list.component.ts | 109 ++++++++++++++ .../search-form/search-form.component.html | 30 ++++ .../search-form/search-form.component.ts | 82 +++++++++++ .../search-result-item.component.ts | 96 ++++++++++++ src/app/shared/playlist-meta.type.ts | 1 + src/app/shared/services/mpv-player.service.ts | 19 +-- src/app/state/actions.ts | 5 + src/app/state/effects.ts | 2 +- src/app/state/reducers.ts | 11 ++ src/app/state/selectors.ts | 12 +- src/app/state/state.ts | 2 + .../category-content-view.component.html | 76 +++------- .../category-content-view.component.scss | 80 +--------- .../category-content-view.component.ts | 92 +++++++++--- src/app/xtream-tauri/detail-view.scss | 15 +- .../favorites/favorites.component.html | 80 +--------- .../favorites/favorites.component.scss | 126 ---------------- .../favorites/favorites.component.ts | 17 +-- .../live-stream-layout.component.html | 3 + .../live-stream-layout.component.scss | 4 +- .../navigation/navigation.component.html | 64 ++++---- .../navigation/navigation.component.ts | 20 ++- .../navigation/navigation.interface.ts | 5 + .../portal-channels-list.component.ts | 6 +- .../search-results.component.html | 109 +++----------- .../search-results.component.ts | 80 +++++----- .../vod-details/vod-details.component.ts | 1 - .../xtream-main-container.component.html | 7 + .../xtream-main-container.component.scss | 16 +- .../xtream-main-container.component.ts | 5 +- .../xtream-shell/xtream-shell.component.html | 2 + .../xtream-shell/xtream-shell.component.ts | 21 ++- src/app/xtream-tauri/xtream.store.ts | 14 +- src/app/xtream/detail-view.scss | 5 +- .../playlist-error-view.component.html | 24 +-- .../playlist-error-view.component.scss | 15 ++ .../vod-details/vod-details.component.ts | 50 ++++--- 47 files changed, 1262 insertions(+), 663 deletions(-) create mode 100644 src/app/shared/components/favorites-layout/favorites-layout.component.html create mode 100644 src/app/shared/components/favorites-layout/favorites-layout.component.scss create mode 100644 src/app/shared/components/favorites-layout/favorites-layout.component.ts create mode 100644 src/app/shared/components/grid-list/grid-list.component.scss create mode 100644 src/app/shared/components/grid-list/grid-list.component.ts create mode 100644 src/app/shared/components/search-form/search-form.component.html create mode 100644 src/app/shared/components/search-form/search-form.component.ts create mode 100644 src/app/shared/components/search-result-item/search-result-item.component.ts create mode 100644 src/app/xtream-tauri/navigation/navigation.interface.ts diff --git a/shared/xtream-item.interface.ts b/shared/xtream-item.interface.ts index abe3d6f06..969cd1e28 100644 --- a/shared/xtream-item.interface.ts +++ b/shared/xtream-item.interface.ts @@ -11,4 +11,5 @@ export interface XtreamItem { rating_imdb?: string; xtream_id?: number; type?: 'movie' | 'series' | 'live'; + added_at?: number; } diff --git a/src/app/services/player.service.ts b/src/app/services/player.service.ts index 6fc8d9be5..61979bf8e 100644 --- a/src/app/services/player.service.ts +++ b/src/app/services/player.service.ts @@ -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( diff --git a/src/app/services/playlists.service.ts b/src/app/services/playlists.service.ts index 3c892b4b6..db78e93df 100644 --- a/src/app/services/playlists.service.ts +++ b/src/app/services/playlists.service.ts @@ -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[]; @@ -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[]; + }>(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 + ), + }) + ) + ); + } } diff --git a/src/app/services/pwa.service.ts b/src/app/services/pwa.service.ts index 7490250e2..3c0503d81 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -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 } ); } 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; 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) { diff --git a/src/app/services/settings-store.service.ts b/src/app/services/settings-store.service.ts index 9b6c6038a..19d180a91 100644 --- a/src/app/services/settings-store.service.ts +++ b/src/app/services/settings-store.service.ts @@ -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) { diff --git a/src/app/services/tauri.service.ts b/src/app/services/tauri.service.ts index 452424a15..7301642ed 100644 --- a/src/app/services/tauri.service.ts +++ b/src/app/services/tauri.service.ts @@ -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 } ); } 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 { diff --git a/src/app/shared/components/favorites-layout/favorites-layout.component.html b/src/app/shared/components/favorites-layout/favorites-layout.component.html new file mode 100644 index 000000000..1bf7b961a --- /dev/null +++ b/src/app/shared/components/favorites-layout/favorites-layout.component.html @@ -0,0 +1,82 @@ + + +
+
+

+ + {{ + (titleTranslationString() | translate) + + ' (' + + selectedCategoryId() + + ')' + }} +

+
+
+ @if (favorites().length) { +
+ @for (item of favorites(); track $index) { + + + +

+ {{ item?.title || item?.details?.info?.name }} +

+

+ Added: + {{ item?.added_at | date: 'd MMMM, y, HH:MM' }} +

+
+ +
+ } +
+ } @else { +
+ favorite_border +

No items in the selected category

+
+ } +
+ +
diff --git a/src/app/shared/components/favorites-layout/favorites-layout.component.scss b/src/app/shared/components/favorites-layout/favorites-layout.component.scss new file mode 100644 index 000000000..790580d65 --- /dev/null +++ b/src/app/shared/components/favorites-layout/favorites-layout.component.scss @@ -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); + } +} diff --git a/src/app/shared/components/favorites-layout/favorites-layout.component.ts b/src/app/shared/components/favorites-layout/favorites-layout.component.ts new file mode 100644 index 000000000..fa97beb4e --- /dev/null +++ b/src/app/shared/components/favorites-layout/favorites-layout.component.ts @@ -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([]); + readonly favorites = input([]); + readonly selectedCategoryId = input('movie'); + readonly titleTranslationString = input('CHANNELS.FAVORITES'); + + readonly categoryClicked = output(); + readonly removeFromFavorites = output(); + readonly openItem = output(); + + setCategoryId(categoryId: any) { + this.categoryClicked.emit({ category_id: categoryId }); + } + + removeFavorite(item: any) { + this.removeFromFavorites.emit(item); + } + + openFavorite(item: any) { + this.openItem.emit(item); + } +} diff --git a/src/app/shared/components/grid-list/grid-list.component.scss b/src/app/shared/components/grid-list/grid-list.component.scss new file mode 100644 index 000000000..ec2c8ff1d --- /dev/null +++ b/src/app/shared/components/grid-list/grid-list.component.scss @@ -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; + } +} diff --git a/src/app/shared/components/grid-list/grid-list.component.ts b/src/app/shared/components/grid-list/grid-list.component.ts new file mode 100644 index 000000000..aa62a54de --- /dev/null +++ b/src/app/shared/components/grid-list/grid-list.component.ts @@ -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: `
+ @if (isLoading()) { +
+ +
+ } @else { + @for (item of items(); track $index) { + @let i = $any(item); + + @let poster = i.poster_url ?? i.cover; + @if (poster) { + + } @else { + + } + @let rating = i.rating ?? i.rating_imdb; + @if (rating) { +
+ star{{ rating }} +
+ } + @let title = i.title ?? i.name; + +
+ {{ title || 'No name' }} +
+
+
+ } @empty { + + } + } +
+ @if (items()?.length > 0) { + + } `, + styleUrl: './grid-list.component.scss', + imports: [ + TranslatePipe, + PlaylistErrorViewComponent, + MatCardModule, + MatIcon, + MatProgressSpinner, + MatTooltip, + NgOptimizedImage, + MatPaginatorModule, + ], +}) +export class GridListComponent { + readonly items = input(); + readonly isLoading = input(); + readonly itemClicked = output(); + readonly pageChange = output(); + + readonly pageIndex = input(); + readonly totalPages = input(); + readonly limit = input(); + readonly pageSizeOptions = input(); +} diff --git a/src/app/shared/components/search-form/search-form.component.html b/src/app/shared/components/search-form/search-form.component.html new file mode 100644 index 000000000..87706979c --- /dev/null +++ b/src/app/shared/components/search-form/search-form.component.html @@ -0,0 +1,30 @@ +
+ + {{ placeholder }} + + search + + + @if (filterConfig.length > 0) { +
+ @for (filter of filterConfig; track filter.key) { + + @if (filter.translationKey) { + {{ filter.translationKey | translate }} + } @else { + {{ filter.label }} + } + + } +
+ } +
diff --git a/src/app/shared/components/search-form/search-form.component.ts b/src/app/shared/components/search-form/search-form.component.ts new file mode 100644 index 000000000..dfca06ee7 --- /dev/null +++ b/src/app/shared/components/search-form/search-form.component.ts @@ -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(''); + + @Output() filtersChange = new EventEmitter(); + @Output() search = new EventEmitter(); + + 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(); + } +} diff --git a/src/app/shared/components/search-result-item/search-result-item.component.ts b/src/app/shared/components/search-result-item/search-result-item.component.ts new file mode 100644 index 000000000..cae49b44e --- /dev/null +++ b/src/app/shared/components/search-result-item/search-result-item.component.ts @@ -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: ` + + @if (posterUrl) { + + } @else { +
+ movie +
+ } +

{{ title }}

+ @if (description) { +

{{ description }}

+ } + @if (type) { +
{{ type }}
+ } + @if (showPlaylistInfo && playlistName) { +
+ playlist_play + {{ playlistName }} +
+ } +
+ `, + 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(); +} diff --git a/src/app/shared/playlist-meta.type.ts b/src/app/shared/playlist-meta.type.ts index 29e09a2b5..f5e2ba45a 100644 --- a/src/app/shared/playlist-meta.type.ts +++ b/src/app/shared/playlist-meta.type.ts @@ -20,4 +20,5 @@ export type PlaylistMeta = Pick< | 'password' | 'macAddress' | 'portalUrl' + | 'recentlyViewed' >; diff --git a/src/app/shared/services/mpv-player.service.ts b/src/app/shared/services/mpv-player.service.ts index 416b5e3d4..f8eac3ae4 100644 --- a/src/app/shared/services/mpv-player.service.ts +++ b/src/app/shared/services/mpv-player.service.ts @@ -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 { - return await invoke('open_in_mpv', { - url, - path: mpvPath, - title, - thumbnail, - }); - } - async playStream(processId: number): Promise { await invoke('mpv_play', { processId }); } diff --git a/src/app/state/actions.ts b/src/app/state/actions.ts index b3663e891..fea65f0ea 100644 --- a/src/app/state/actions.ts +++ b/src/app/state/actions.ts @@ -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 }>() +); diff --git a/src/app/state/effects.ts b/src/app/state/effects.ts index 84ccb6dee..18de37a31 100644 --- a/src/app/state/effects.ts +++ b/src/app/state/effects.ts @@ -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]); } diff --git a/src/app/state/reducers.ts b/src/app/state/reducers.ts index ce51ea22c..370063ceb 100644 --- a/src/app/state/reducers.ts +++ b/src/app/state/reducers.ts @@ -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; diff --git a/src/app/state/selectors.ts b/src/app/state/selectors.ts index 78d0dffde..b8c376fc5 100644 --- a/src/app/state/selectors.ts +++ b/src/app/state/selectors.ts @@ -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; } diff --git a/src/app/state/state.ts b/src/app/state/state.ts index cbca3d34c..5d839642d 100644 --- a/src/app/state/state.ts +++ b/src/app/state/state.ts @@ -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, }; diff --git a/src/app/xtream-tauri/category-content-view/category-content-view.component.html b/src/app/xtream-tauri/category-content-view/category-content-view.component.html index 9aa2ec0c0..7d8c03e69 100644 --- a/src/app/xtream-tauri/category-content-view/category-content-view.component.html +++ b/src/app/xtream-tauri/category-content-view/category-content-view.component.html @@ -1,61 +1,31 @@ @if (selectedCategory()) { -
- @for (item of paginatedContent(); track $index) { - @let i = $any(item); - - @if (i.poster_url) { - - } @else { - - } - @let rating = $any(item).rating; - @if (rating) { -
- star{{ rating }} -
- } - -
{{ i.title || 'No name' }}
-
-
- } @empty { - + } @else if (contentType === 'series') { + } -
- - @if (paginatedContent().length > 0) { - } } @else { diff --git a/src/app/xtream-tauri/category-content-view/category-content-view.component.scss b/src/app/xtream-tauri/category-content-view/category-content-view.component.scss index d4480ce96..dd017d7f1 100644 --- a/src/app/xtream-tauri/category-content-view/category-content-view.component.scss +++ b/src/app/xtream-tauri/category-content-view/category-content-view.component.scss @@ -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; */ } diff --git a/src/app/xtream-tauri/category-content-view/category-content-view.component.ts b/src/app/xtream-tauri/category-content-view/category-content-view.component.ts index b7ff2effd..75c57cc82 100644 --- a/src/app/xtream-tauri/category-content-view/category-content-view.component.ts +++ b/src/app/xtream-tauri/category-content-view/category-content-view.component.ts @@ -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); } } diff --git a/src/app/xtream-tauri/detail-view.scss b/src/app/xtream-tauri/detail-view.scss index 5fe8a5ee8..f789ce642 100644 --- a/src/app/xtream-tauri/detail-view.scss +++ b/src/app/xtream-tauri/detail-view.scss @@ -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; } diff --git a/src/app/xtream-tauri/favorites/favorites.component.html b/src/app/xtream-tauri/favorites/favorites.component.html index 1dda92b9f..2a130d92b 100644 --- a/src/app/xtream-tauri/favorites/favorites.component.html +++ b/src/app/xtream-tauri/favorites/favorites.component.html @@ -1,72 +1,8 @@ - - -
-
-

-

- {{ - selectedCategoryId() + - ' ' + - ('CHANNELS.FAVORITES' | translate) - }} -

- -
-
- @if (favoritesToShow().length) { -
- @for (item of favoritesToShow(); track item.content_id) { - - - -

{{ item.title }}

-

- Added: - {{ item.added_at | date: 'd MMMM, y, hh:mm' }} -

-
- -
- } -
- } @else { -
- favorite_border -

No favorite {{ selectedCategoryId() }} items

-
- } -
- -
+ diff --git a/src/app/xtream-tauri/favorites/favorites.component.scss b/src/app/xtream-tauri/favorites/favorites.component.scss index ed47bde80..e69de29bb 100644 --- a/src/app/xtream-tauri/favorites/favorites.component.scss +++ b/src/app/xtream-tauri/favorites/favorites.component.scss @@ -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; - } -} diff --git a/src/app/xtream-tauri/favorites/favorites.component.ts b/src/app/xtream-tauri/favorites/favorites.component.ts index 53132ca5b..a229d7586 100644 --- a/src/app/xtream-tauri/favorites/favorites.component.ts +++ b/src/app/xtream-tauri/favorites/favorites.component.ts @@ -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'], }) diff --git a/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html b/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html index e96637207..bdc4e497a 100644 --- a/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html +++ b/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html @@ -8,6 +8,9 @@

{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}

} @else { +

{{ 'PORTALS.ALL_CATEGORIES' | translate }}

} diff --git a/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.scss b/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.scss index 47abf0c57..0007e6683 100644 --- a/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.scss +++ b/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.scss @@ -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); } diff --git a/src/app/xtream-tauri/navigation/navigation.component.html b/src/app/xtream-tauri/navigation/navigation.component.html index cb84ca5f7..fc16f5be9 100644 --- a/src/app/xtream-tauri/navigation/navigation.component.html +++ b/src/app/xtream-tauri/navigation/navigation.component.html @@ -9,7 +9,11 @@ {{ currentPlaylist()?.title || 'Unknown Portal' }} - {{ xtreamStore.portalStatus() }} + @if (!isStalkerPlaylist()) { + {{ + xtreamStore.portalStatus() + }} + }
- + @if (!isStalkerPlaylist()) { + + }
diff --git a/src/app/xtream-tauri/navigation/navigation.component.ts b/src/app/xtream-tauri/navigation/navigation.component.ts index 44a38923b..ebafd2c2b 100644 --- a/src/app/xtream-tauri/navigation/navigation.component.ts +++ b/src/app/xtream-tauri/navigation/navigation.component.ts @@ -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(); - readonly categoryClick = output<'vod' | 'live' | 'series'>(); + readonly categoryClick = output(); 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(); + 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() { diff --git a/src/app/xtream-tauri/navigation/navigation.interface.ts b/src/app/xtream-tauri/navigation/navigation.interface.ts new file mode 100644 index 000000000..27ab3c9d9 --- /dev/null +++ b/src/app/xtream-tauri/navigation/navigation.interface.ts @@ -0,0 +1,5 @@ +export interface NavigationItem { + id: string; + icon: string; + labelKey: string; +} diff --git a/src/app/xtream-tauri/portal-channels-list/portal-channels-list.component.ts b/src/app/xtream-tauri/portal-channels-list/portal-channels-list.component.ts index 2d7e36aa3..c98a99b02 100644 --- a/src/app/xtream-tauri/portal-channels-list/portal-channels-list.component.ts +++ b/src/app/xtream-tauri/portal-channels-list/portal-channels-list.component.ts @@ -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(); diff --git a/src/app/xtream-tauri/search-results/search-results.component.html b/src/app/xtream-tauri/search-results/search-results.component.html index 43bc34153..c7aa7b510 100644 --- a/src/app/xtream-tauri/search-results/search-results.component.html +++ b/src/app/xtream-tauri/search-results/search-results.component.html @@ -1,53 +1,24 @@
-

Search

+

{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}

@if (isGlobalSearch) { }
-
- - Search content - - search - -
- - Live TV - - - Movies - - - Series - + + + @if (!isGlobalSearch && xtreamStore.searchResults().length > 0) { +
+ Found {{ xtreamStore.searchResults().length }} results
- - @if (!isGlobalSearch && xtreamStore.searchResults().length > 0) { -
- Found {{ xtreamStore.searchResults().length }} results -
- } -
+ }
@if (xtreamStore.searchResults().length > 0) { @@ -79,55 +50,15 @@ item of $any(group).value; track item.id ) { - - - @if (item.poster_url) { - - } - @if (!item.poster_url) { -
- movie -
- } -

{{ item.title }}

-

{{ item.description }}

-
- {{ item.type }} -
- @if ( - item.playlist_id && - isGlobalSearch - ) { -
- {{ item.playlist_name }} -
- } - @if ( - isGlobalSearch && - item.playlist_name - ) { -
- playlist_play - {{ - item.playlist_name - }} -
- } -
-
+ }
diff --git a/src/app/xtream-tauri/search-results/search-results.component.ts b/src/app/xtream-tauri/search-results/search-results.component.ts index ef17ea747..7f447917c 100644 --- a/src/app/xtream-tauri/search-results/search-results.component.ts +++ b/src/app/xtream-tauri/search-results/search-results.component.ts @@ -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(); - 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 ) { 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 } ) { diff --git a/src/app/xtream-tauri/vod-details/vod-details.component.ts b/src/app/xtream-tauri/vod-details/vod-details.component.ts index 005b64b8c..9cc6a897e 100644 --- a/src/app/xtream-tauri/vod-details/vod-details.component.ts +++ b/src/app/xtream-tauri/vod-details/vod-details.component.ts @@ -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; diff --git a/src/app/xtream-tauri/xtream-main-container.component.html b/src/app/xtream-tauri/xtream-main-container.component.html index bd8b94972..cdd557491 100644 --- a/src/app/xtream-tauri/xtream-main-container.component.html +++ b/src/app/xtream-tauri/xtream-main-container.component.html @@ -1,5 +1,8 @@ diff --git a/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts b/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts index ab65a5b2b..1100b8280 100644 --- a/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts +++ b/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts @@ -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( () => { diff --git a/src/app/xtream-tauri/xtream.store.ts b/src/app/xtream-tauri/xtream.store.ts index b2b505c71..8433fb239 100644 --- a/src/app/xtream-tauri/xtream.store.ts +++ b/src/app/xtream-tauri/xtream.store.ts @@ -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); + }, }; } ) diff --git a/src/app/xtream/detail-view.scss b/src/app/xtream/detail-view.scss index 91251f637..994a61046 100644 --- a/src/app/xtream/detail-view.scss +++ b/src/app/xtream/detail-view.scss @@ -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) { diff --git a/src/app/xtream/playlist-error-view/playlist-error-view.component.html b/src/app/xtream/playlist-error-view/playlist-error-view.component.html index 450bbcaa8..cd5121216 100644 --- a/src/app/xtream/playlist-error-view/playlist-error-view.component.html +++ b/src/app/xtream/playlist-error-view/playlist-error-view.component.html @@ -1,21 +1,23 @@