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:
4gray committed 2025-09-08 18:12:16 +02:00
1 parent 87807dc1e7
commit f1dfef6d9f
47 files changed
+1262 -663

No files matched your search

+1
View File
@@ -11,4 +11,5 @@ export interface XtreamItem {
rating_imdb?: string;
xtream_id?: number;
type?: 'movie' | 'series' | 'live';
added_at?: number;
}
+2
View File
@@ -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>(
+81 -6
View File
@@ -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
),
})
)
);
}
}
+31 -17
View File
@@ -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) {
+12 -1
View File
@@ -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) {
+85 -34
View File
@@ -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>();
}
+1
View File
@@ -20,4 +20,5 @@ export type PlaylistMeta = Pick<
| 'password'
| 'macAddress'
| 'portalUrl'
| 'recentlyViewed'
>;
+4 -15
View File
@@ -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 });
}
+5
View File
@@ -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 }>()
);
+1 -1
View File
@@ -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]);
}
+11
View File
@@ -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;
+9 -3
View File
@@ -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;
}
+2
View File
@@ -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);
}
}
+8 -7
View File
@@ -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(
() => {
+11 -3
View File
@@ -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);
},
};
}
)
+3 -2
View File
@@ -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)
);
});
}
});
}