mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
Merge pull request #758 from 4gray/feat/search-feature-improvements
Feat/search feature improvements
This commit is contained in:
42 files changed
+1293
-583
No files matched your search
@@ -3,7 +3,7 @@
|
||||
* Operations for managing and searching content (streams, movies, series)
|
||||
*/
|
||||
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import { ipcMain } from 'electron';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
@@ -16,6 +16,25 @@ function escapeLikePattern(term: string): string {
|
||||
return term.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LIKE patterns for multiple case variants to improve non-ASCII matching
|
||||
* in SQLite (default LIKE case-insensitive behavior is ASCII-only).
|
||||
*/
|
||||
function buildLikePatterns(term: string): string[] {
|
||||
const variants = new Set<string>();
|
||||
const titleCase =
|
||||
term.length > 0
|
||||
? term.charAt(0).toLocaleUpperCase() + term.slice(1).toLocaleLowerCase()
|
||||
: term;
|
||||
|
||||
variants.add(term);
|
||||
variants.add(term.toLocaleLowerCase());
|
||||
variants.add(term.toLocaleUpperCase());
|
||||
variants.add(titleCase);
|
||||
|
||||
return [...variants].map(value => `%${escapeLikePattern(value)}%`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content exists
|
||||
*/
|
||||
@@ -243,12 +262,14 @@ ipcMain.handle(
|
||||
async (event, playlistId: string, searchTerm: string, types: string[]) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
// Note: SQLite's LOWER() only handles ASCII characters, not Unicode/Cyrillic.
|
||||
// We use a two-step approach:
|
||||
// 1. SQL filters by playlist and type
|
||||
// 2. JavaScript filters by title using proper Unicode toLowerCase()
|
||||
const searchTermLower = searchTerm.toLowerCase();
|
||||
const searchTermLower = searchTerm.toLocaleLowerCase();
|
||||
const likePatterns = buildLikePatterns(searchTerm);
|
||||
const likeConditions = likePatterns.map(
|
||||
pattern => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
|
||||
);
|
||||
|
||||
// Pre-filter with SQL LIKE (case-insensitive for ASCII, handled in native C code).
|
||||
// This avoids loading the entire content table into JS memory.
|
||||
const candidates = await db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
@@ -271,13 +292,18 @@ ipcMain.handle(
|
||||
inArray(
|
||||
schema.content.type,
|
||||
types as Array<'live' | 'movie' | 'series'>
|
||||
)
|
||||
),
|
||||
or(...likeConditions)
|
||||
)
|
||||
);
|
||||
)
|
||||
.limit(200);
|
||||
|
||||
// Filter in JavaScript for proper Unicode case-insensitive search
|
||||
// Post-filter for proper Unicode case-insensitive matching
|
||||
// (SQLite LIKE is only case-insensitive for ASCII)
|
||||
const result = candidates
|
||||
.filter(item => item.title?.toLowerCase().includes(searchTermLower))
|
||||
.filter(item =>
|
||||
item.title?.toLocaleLowerCase().includes(searchTermLower)
|
||||
)
|
||||
.slice(0, 50);
|
||||
|
||||
return result;
|
||||
@@ -296,12 +322,16 @@ ipcMain.handle(
|
||||
async (event, searchTerm: string, types: string[]) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
// Note: SQLite's LOWER() only handles ASCII characters, not Unicode/Cyrillic.
|
||||
// We use a two-step approach:
|
||||
// 1. SQL filters by type only
|
||||
// 2. JavaScript filters by title using proper Unicode toLowerCase()
|
||||
const searchTermLower = searchTerm.toLowerCase();
|
||||
const searchTermLower = searchTerm.toLocaleLowerCase();
|
||||
const likePatterns = buildLikePatterns(searchTerm);
|
||||
const likeConditions = likePatterns.map(
|
||||
pattern => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
|
||||
);
|
||||
|
||||
// Pre-filter with SQL LIKE (case-insensitive for ASCII, handled in native C code).
|
||||
// This avoids loading the entire content table into JS memory.
|
||||
// Previously this query loaded ALL content rows (potentially 100k+),
|
||||
// created JS objects for each, then filtered in JS — blocking the main process.
|
||||
const candidates = await db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
@@ -325,16 +355,23 @@ ipcMain.handle(
|
||||
eq(schema.categories.playlistId, schema.playlists.id)
|
||||
)
|
||||
.where(
|
||||
inArray(
|
||||
schema.content.type,
|
||||
types as Array<'live' | 'movie' | 'series'>
|
||||
and(
|
||||
inArray(
|
||||
schema.content.type,
|
||||
types as Array<'live' | 'movie' | 'series'>
|
||||
),
|
||||
or(...likeConditions)
|
||||
)
|
||||
);
|
||||
)
|
||||
.orderBy(schema.content.title)
|
||||
.limit(200);
|
||||
|
||||
// Filter in JavaScript for proper Unicode case-insensitive search
|
||||
// Post-filter for proper Unicode case-insensitive matching
|
||||
// (SQLite LIKE is only case-insensitive for ASCII)
|
||||
const result = candidates
|
||||
.filter(item => item.title?.toLowerCase().includes(searchTermLower))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
.filter(item =>
|
||||
item.title?.toLocaleLowerCase().includes(searchTermLower)
|
||||
)
|
||||
.slice(0, 50);
|
||||
|
||||
return result;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
@@ -16,6 +17,7 @@ import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
CategoryViewComponent,
|
||||
ContentCardComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<div class="search-container">
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="search-field"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{ placeholder }}</mat-label>
|
||||
<input
|
||||
#searchInput
|
||||
@@ -8,7 +12,13 @@
|
||||
[ngModel]="searchTerm()"
|
||||
(ngModelChange)="onSearchTermChange()"
|
||||
/>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
@if (searchTerm()) {
|
||||
<button mat-icon-button matSuffix (click)="clearSearch()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (filterConfig.length > 0) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
@@ -33,6 +34,7 @@ export interface SearchFilter {
|
||||
MatInputModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './search-form.component.html',
|
||||
@@ -76,6 +78,11 @@ export class SearchFormComponent {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.searchInput.nativeElement.value = '';
|
||||
this.searchTerm.set('');
|
||||
}
|
||||
|
||||
focusSearchInput() {
|
||||
this.searchInput.nativeElement.focus();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
<div class="header-right">
|
||||
@if (showResultsCount() && resultsCount() > 0) {
|
||||
<div class="results-count">
|
||||
Found {{ resultsCount() }} results
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.RESULTS_FOUND'
|
||||
| translate: { count: resultsCount() }
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
@if (showCloseButton()) {
|
||||
@@ -36,6 +39,9 @@
|
||||
(searchTermChange)="onSearchTermChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subheader slot (info notes, secondary controls) -->
|
||||
<ng-content select="[subheader]" />
|
||||
</div>
|
||||
|
||||
<div class="results-container">
|
||||
@@ -52,7 +58,12 @@
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
|
||||
<p class="search-term-note">No results found for "{{ searchTerm() }}"</p>
|
||||
<p class="search-term-note">
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
|
||||
| translate: { term: searchTerm() }
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (showInitialState) {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 20px 20px;
|
||||
padding: 0 20px 0 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -29,7 +29,7 @@
|
||||
z-index: 100;
|
||||
background: var(--background, var(--mat-sidenav-content-background-color, #fff));
|
||||
padding: 20px 0 10px 0;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 2px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
|
||||
display: flex;
|
||||
@@ -154,6 +154,9 @@
|
||||
|
||||
// Global search overlay context
|
||||
:host-context(.global-search-overlay) {
|
||||
// Prevent Electron's app-region: drag from bleeding through the dialog overlay
|
||||
app-region: no-drag;
|
||||
|
||||
.search-page {
|
||||
background: var(--background);
|
||||
height: 100vh;
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
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 (showPlaylistInfo && playlistName) {
|
||||
<div class="playlist-badge">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
<span>{{ playlistName }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
padding: 10px 0 2px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3em;
|
||||
height: 2.6em; /* Fixed height for 2 lines */
|
||||
color: var(--text-color, #fff);
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0 0 10px 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2em;
|
||||
height: 1.2em; /* Fixed height for 1 line */
|
||||
color: var(--text-color-secondary, rgba(255, 255, 255, 0.7));
|
||||
opacity: 0.9; /* Improved readability */
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
padding: 0 0 10px 0;
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
color: var(--text-color-secondary, inherit);
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class SearchResultItemComponent {
|
||||
@Input() title = '';
|
||||
@Input() description = '';
|
||||
@Input() posterUrl = '';
|
||||
@Input() type = '';
|
||||
@Input() playlistName = '';
|
||||
@Input() showPlaylistInfo = false;
|
||||
|
||||
@Output() itemClick = new EventEmitter<void>();
|
||||
}
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
@mixin thumbnail-wrapper {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.poster {
|
||||
width: 100%;
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
@let isEmbeddedPlayer =
|
||||
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
|
||||
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
@if (stalkerStore.selectedCategoryId()) {
|
||||
<button mat-icon-button (click)="backToCategories()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<h2>{{ selectedCategoryTitle() }}</h2>
|
||||
} @else {
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (stalkerStore.selectedCategoryId()) {
|
||||
<!-- <div class="search-panel">
|
||||
<mat-form-field class="search-bar" subscriptSizing="dynamic">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
[placeholder]="'PORTALS.SIDEBAR.SEARCH' | translate"
|
||||
[ngModel]="searchString()"
|
||||
(ngModelChange)="searchString.set($event)"
|
||||
/>
|
||||
@if (searchString()) {
|
||||
<button matSuffix mat-icon-button (click)="searchString.set('')">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div> -->
|
||||
<div #scrollContainer class="channels-list">
|
||||
@for (item of itvChannels(); track item.id ?? $index) {
|
||||
<div
|
||||
class="channel-item"
|
||||
[class.active]="stalkerStore.selectedItem()?.id === item.id"
|
||||
(click)="playChannel(item)"
|
||||
>
|
||||
<img
|
||||
class="stream-icon"
|
||||
[src]="item.logo"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
<div class="channel-info">
|
||||
<span class="title">{{
|
||||
item.o_name || item.name
|
||||
}}</span>
|
||||
@if (currentPrograms.get(item.id)) {
|
||||
<div class="program-info">
|
||||
<div class="current-program">
|
||||
{{ currentPrograms.get(item.id) }}
|
||||
</div>
|
||||
@if (currentProgramsProgress.has(item.id)) {
|
||||
<div class="progress-container">
|
||||
<span>{{
|
||||
programTimings.get(item.id)?.start
|
||||
| date: 'HH:mm'
|
||||
}}</span>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress"
|
||||
[style.width.%]="
|
||||
currentProgramsProgress.get(item.id)
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
<span>{{
|
||||
programTimings.get(item.id)?.end
|
||||
| date: 'HH:mm'
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="favorite-btn"
|
||||
(click)="toggleFavorite(item); $event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>{{
|
||||
favorites.get(item.id) ? 'star' : 'star_outline'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoadingMore()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (isCategoryLoading()) {
|
||||
<ngx-skeleton-loader
|
||||
count="20"
|
||||
[theme]="{
|
||||
width: '95%',
|
||||
height: '48px',
|
||||
borderRadius: '40px',
|
||||
}"
|
||||
/>
|
||||
} @else if (isCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
|
||||
(categoryClicked)="selectCategory($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
@if (streamUrl && isEmbeddedPlayer) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view [streamUrl]="streamUrl" />
|
||||
</div>
|
||||
}
|
||||
@if (stalkerStore.selectedItem()) {
|
||||
<div class="epg">
|
||||
<div class="epg-content">
|
||||
<app-epg-view [epgItems]="epgItems()" />
|
||||
@if (isLoadingEpg()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
@if (hasMoreEpg() && !isLoadingEpg()) {
|
||||
<div class="load-more-epg">
|
||||
<button mat-button (click)="loadMoreEpg()">
|
||||
{{ 'CHANNELS.LOAD_MORE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-channel-selected">
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 400px;
|
||||
min-width: 250px;
|
||||
max-width: 600px;
|
||||
border-right: 1px solid #111;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
|
||||
app-category-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
flex: 1;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.epg {
|
||||
flex: 1;
|
||||
height: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.epg-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.no-channel-selected {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
// Channel list
|
||||
.search-panel {
|
||||
padding: 8px 12px;
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.search-bar {
|
||||
div {
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
div[matformfieldlineripple] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.channels-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.channel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
width: calc(100% - 8px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
.title {
|
||||
color: #1db954;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(29, 185, 84, 0.2);
|
||||
|
||||
.title {
|
||||
color: #1db954;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.current-program {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
|
||||
span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
min-width: 20px;
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #1db954;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.load-more-epg {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
// Container queries for responsive behavior
|
||||
@container (max-width: 350px) {
|
||||
.channel-item {
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
padding: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.stream-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 260px) {
|
||||
.stream-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.video-player
|
||||
> app-web-player-view
|
||||
> app-art-player
|
||||
> .artplayer-container {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import { PlayerService } from '../../services/player.service';
|
||||
import { CategoryViewComponent } from '../../xtream-tauri/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-live-stream-layout',
|
||||
templateUrl: './stalker-live-stream-layout.component.html',
|
||||
styleUrls: [
|
||||
'./stalker-live-stream-layout.component.scss',
|
||||
'../../xtream-tauri/sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
DatePipe,
|
||||
EpgViewComponent,
|
||||
FormsModule,
|
||||
MatButton,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
PlaylistErrorViewComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly playerService = inject(PlayerService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
/** Categories */
|
||||
readonly categories = this.stalkerStore.getCategoryResource;
|
||||
readonly isCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
|
||||
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
|
||||
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
/** Channels */
|
||||
readonly itvChannels = this.stalkerStore.itvChannels;
|
||||
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
|
||||
readonly isLoadingMore = signal(false);
|
||||
|
||||
/** Search */
|
||||
readonly searchString = signal('');
|
||||
|
||||
/** Player */
|
||||
readonly player = this.settingsStore.player;
|
||||
streamUrl = '';
|
||||
|
||||
/** EPG */
|
||||
readonly epgItems = signal<EpgItem[]>([]);
|
||||
readonly isLoadingEpg = signal(false);
|
||||
readonly hasMoreEpg = signal(false);
|
||||
private epgPageSize = 10;
|
||||
private epgChannelId: number | string | null = null;
|
||||
|
||||
/** Channel list EPG preview */
|
||||
readonly currentPrograms = new Map<number, string>();
|
||||
readonly currentProgramsProgress = new Map<number, number>();
|
||||
readonly programTimings = new Map<number, { start: number; end: number }>();
|
||||
private readonly requestedEpgChannels = new Set<number>();
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
/** Favorites */
|
||||
readonly favorites = new Map<number, boolean>();
|
||||
|
||||
/** Scroll */
|
||||
readonly scrollContainer = viewChild<ElementRef>('scrollContainer');
|
||||
private scrollListener: (() => void) | null = null;
|
||||
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Load favorites for current playlist
|
||||
this.playlistService
|
||||
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((favs) => {
|
||||
favs.forEach((fav: any) => {
|
||||
this.favorites.set(fav.id, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset channels/page on category change
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedCategoryId();
|
||||
this.stalkerStore.setItvChannels([]);
|
||||
this.stalkerStore.setPage(0);
|
||||
this.clearEpgPreviewMaps();
|
||||
});
|
||||
|
||||
// Reset loading state when channels load + check viewport fill + load EPG previews
|
||||
effect(() => {
|
||||
const channels = this.itvChannels();
|
||||
if (channels.length > 0) {
|
||||
this.isLoadingMore.set(false);
|
||||
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
|
||||
this.loadEpgPreviewsForChannels(channels);
|
||||
}
|
||||
});
|
||||
|
||||
// Setup scroll listener when container becomes available
|
||||
effect(() => {
|
||||
const container = this.scrollContainer();
|
||||
if (container) {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
});
|
||||
|
||||
// Debounced server-side search
|
||||
effect(() => {
|
||||
const search = this.searchString();
|
||||
if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
|
||||
this.searchDebounceTimer = setTimeout(() => {
|
||||
this.stalkerStore.setItvChannels([]);
|
||||
this.stalkerStore.setSearchPhrase(search);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.removeScrollListener();
|
||||
if (this.searchDebounceTimer) {
|
||||
clearTimeout(this.searchDebounceTimer);
|
||||
}
|
||||
}
|
||||
|
||||
selectCategory(item: { category_name: string; category_id: string }) {
|
||||
this.stalkerStore.setSelectedCategory(item.category_id || '*');
|
||||
this.stalkerStore.setPage(0);
|
||||
}
|
||||
|
||||
backToCategories() {
|
||||
this.searchString.set('');
|
||||
this.stalkerStore.setSearchPhrase('');
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
}
|
||||
|
||||
async playChannel(item: any) {
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
|
||||
const isEmbeddedPlayer =
|
||||
this.player() === 'videojs' ||
|
||||
this.player() === 'html5' ||
|
||||
this.player() === 'artplayer';
|
||||
|
||||
try {
|
||||
const url = await this.stalkerStore.fetchLinkToPlay(
|
||||
this.currentPlaylist().portalUrl,
|
||||
this.currentPlaylist().macAddress,
|
||||
item.cmd
|
||||
);
|
||||
|
||||
this.loadEpgForChannel(item.id);
|
||||
|
||||
if (isEmbeddedPlayer) {
|
||||
this.streamUrl = url;
|
||||
} else {
|
||||
this.playerService.openPlayer(
|
||||
url,
|
||||
item.o_name || item.name,
|
||||
item.logo,
|
||||
true,
|
||||
true,
|
||||
this.currentPlaylist()?.userAgent,
|
||||
this.currentPlaylist()?.referrer,
|
||||
this.currentPlaylist()?.origin
|
||||
);
|
||||
}
|
||||
|
||||
// Add to recently viewed
|
||||
this.stalkerStore.addToRecentlyViewed({
|
||||
...item,
|
||||
id: item.id,
|
||||
cover: item.logo,
|
||||
title: item.o_name || item.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[StalkerLiveStream] Playback failed:', error);
|
||||
const errorMessage =
|
||||
error?.message === 'nothing_to_play'
|
||||
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
|
||||
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
|
||||
this.snackBar.open(errorMessage, null, { duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
toggleFavorite(item: any) {
|
||||
if (this.favorites.has(item.id)) {
|
||||
this.stalkerStore.removeFromFavorites(item.id);
|
||||
this.favorites.delete(item.id);
|
||||
} else {
|
||||
this.stalkerStore.addToFavorites({
|
||||
...item,
|
||||
category_id: 'itv',
|
||||
title: item.o_name || item.name,
|
||||
cover: item.logo,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
this.favorites.set(item.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
this.isLoadingMore.set(true);
|
||||
const nextPage = this.stalkerStore.page() + 1;
|
||||
this.stalkerStore.setPage(nextPage);
|
||||
}
|
||||
|
||||
async loadMoreEpg() {
|
||||
if (!this.epgChannelId || this.isLoadingEpg()) return;
|
||||
this.epgPageSize += 10;
|
||||
this.isLoadingEpg.set(true);
|
||||
try {
|
||||
const items = await this.stalkerStore.fetchChannelEpg(
|
||||
this.epgChannelId,
|
||||
this.epgPageSize
|
||||
);
|
||||
this.epgItems.set(items);
|
||||
this.hasMoreEpg.set(items.length >= this.epgPageSize);
|
||||
} catch {
|
||||
this.hasMoreEpg.set(false);
|
||||
} finally {
|
||||
this.isLoadingEpg.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEpgForChannel(channelId: number | string) {
|
||||
this.epgChannelId = channelId;
|
||||
this.epgPageSize = 10;
|
||||
this.isLoadingEpg.set(true);
|
||||
this.epgItems.set([]);
|
||||
this.hasMoreEpg.set(false);
|
||||
try {
|
||||
const items = await this.stalkerStore.fetchChannelEpg(
|
||||
channelId,
|
||||
this.epgPageSize
|
||||
);
|
||||
this.epgItems.set(items);
|
||||
this.hasMoreEpg.set(items.length >= this.epgPageSize);
|
||||
} catch {
|
||||
this.epgItems.set([]);
|
||||
} finally {
|
||||
this.isLoadingEpg.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEpgPreviewsForChannels(channels: any[]) {
|
||||
const newChannels = channels.filter(
|
||||
(ch) => ch.id && !this.requestedEpgChannels.has(ch.id)
|
||||
);
|
||||
if (newChannels.length === 0) return;
|
||||
|
||||
// Mark all as requested immediately to avoid duplicates
|
||||
for (const ch of newChannels) {
|
||||
this.requestedEpgChannels.add(ch.id);
|
||||
}
|
||||
|
||||
// Process in batches of 3 with a small delay between batches
|
||||
const batchSize = 3;
|
||||
for (let i = 0; i < newChannels.length; i += batchSize) {
|
||||
const batch = newChannels.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map((ch) => this.loadSingleEpgPreview(ch.id))
|
||||
);
|
||||
// Re-enter Angular zone to trigger change detection for OnPush
|
||||
this.ngZone.run(() => this.cdr.markForCheck());
|
||||
// Small delay between batches to avoid overwhelming the portal
|
||||
if (i + batchSize < newChannels.length) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSingleEpgPreview(channelId: number) {
|
||||
try {
|
||||
const items = await this.stalkerStore.fetchChannelEpg(
|
||||
channelId,
|
||||
1
|
||||
);
|
||||
if (items.length > 0) {
|
||||
const program = items[0];
|
||||
this.currentPrograms.set(channelId, program.title);
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
const start = parseInt(program.start_timestamp, 10);
|
||||
const end = parseInt(program.stop_timestamp, 10);
|
||||
|
||||
if (start && end && now >= start && now <= end) {
|
||||
const progress =
|
||||
((now - start) / (end - start)) * 100;
|
||||
this.currentProgramsProgress.set(
|
||||
channelId,
|
||||
progress
|
||||
);
|
||||
this.programTimings.set(channelId, {
|
||||
start: start * 1000,
|
||||
end: end * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently skip — channel just won't show EPG preview
|
||||
}
|
||||
}
|
||||
|
||||
private clearEpgPreviewMaps() {
|
||||
this.currentPrograms.clear();
|
||||
this.currentProgramsProgress.clear();
|
||||
this.programTimings.clear();
|
||||
this.requestedEpgChannels.clear();
|
||||
}
|
||||
|
||||
private setupScrollListener() {
|
||||
this.removeScrollListener();
|
||||
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const scrollThreshold = 150;
|
||||
|
||||
if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) {
|
||||
this.loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
this.scrollListener = () =>
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
private checkIfNeedsMoreContent() {
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = container;
|
||||
if (scrollHeight <= clientHeight) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private removeScrollListener() {
|
||||
if (this.scrollListener) {
|
||||
this.scrollListener();
|
||||
this.scrollListener = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,82 +7,34 @@
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
@let isItvCategorySelected =
|
||||
stalkerStore.selectedCategoryId() &&
|
||||
stalkerStore.selectedContentType() === 'itv';
|
||||
@if (isItvCategorySelected) {
|
||||
<button mat-icon-button (click)="backToCategories()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<h2>{{ selectedCategoryTitle() }}</h2>
|
||||
} @else {
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
}
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
</div>
|
||||
@if (isItvCategorySelected) {
|
||||
<div #itvScrollContainer style="overflow: auto; flex: 1">
|
||||
<mat-nav-list>
|
||||
@for (item of itvChannels(); track $index) {
|
||||
<mat-list-item (click)="createLinkToPlayItv(item)">
|
||||
<img
|
||||
matListItemAvatar
|
||||
class="stream-icon"
|
||||
[src]="item.logo"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
alt="logo"
|
||||
/>
|
||||
{{ item.o_name || item.name }}
|
||||
<button
|
||||
mat-icon-button
|
||||
matListItemMeta
|
||||
(click)="toggleFavorite(item); $event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>{{
|
||||
favorites.get(item.id) ? 'star' : 'star_outline'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-nav-list>
|
||||
|
||||
<!-- Loading indicator for infinite scroll -->
|
||||
@if (isLoadingMore()) {
|
||||
<div style="display: flex; justify-content: center; padding: 16px">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isCategoryLoading()) {
|
||||
<ngx-skeleton-loader
|
||||
count="20"
|
||||
[theme]="{
|
||||
width: '95%',
|
||||
height: '48px',
|
||||
borderRadius: '40px',
|
||||
}"
|
||||
/>
|
||||
} @else if (isCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
@if (isCategoryLoading()) {
|
||||
<ngx-skeleton-loader
|
||||
count="20"
|
||||
[theme]="{
|
||||
width: '95%',
|
||||
height: '48px',
|
||||
borderRadius: '40px',
|
||||
}"
|
||||
/>
|
||||
} @else if (isCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
|
||||
(categoryClicked)="categoryClicked($event)"
|
||||
/>
|
||||
}
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
|
||||
(categoryClicked)="categoryClicked($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -90,15 +42,11 @@
|
||||
@if (!stalkerStore.selectedItem()) {
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
@if (!isItvCategorySelected) {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
|
||||
@if (!isItvCategorySelected) {
|
||||
{{ getContentLabel() }}
|
||||
}
|
||||
{{ getContentLabel() }}
|
||||
|
||||
@if (!contentItems() && !isContentLoading()) {
|
||||
<h2>
|
||||
@@ -115,5 +63,4 @@
|
||||
>
|
||||
<router-outlet />
|
||||
</div>
|
||||
<!-- <app-mpv-player-bar /> -->
|
||||
</div>
|
||||
@@ -1,23 +1,10 @@
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '../xtream-tauri/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
|
||||
@@ -35,9 +22,7 @@ import { StalkerStore } from './stalker.store';
|
||||
CategoryViewComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatListModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressSpinnerModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
PlaylistErrorViewComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
@@ -46,7 +31,7 @@ import { StalkerStore } from './stalker.store';
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
export class StalkerMainContainerComponent {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
@@ -56,20 +41,6 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
| 'vod-details'
|
||||
| 'not-available' = 'category_content';
|
||||
|
||||
/* searchPhrase = this.portalStore.searchPhrase(); */
|
||||
|
||||
readonly pageIndex = this.stalkerStore.page;
|
||||
readonly isLoadingMore = signal(false);
|
||||
readonly favorites = new Map<number, boolean>();
|
||||
|
||||
/* load more functionality */
|
||||
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
|
||||
readonly itvChannels = this.stalkerStore.itvChannels;
|
||||
|
||||
/** Scrollable container for ITV channels */
|
||||
readonly itvScrollContainer = viewChild<ElementRef>('itvScrollContainer');
|
||||
private scrollListener: (() => void) | null = null;
|
||||
|
||||
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
@@ -82,243 +53,27 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly contentItems = this.stalkerStore.getPaginatedContent;
|
||||
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
|
||||
|
||||
/* readonly contentResource = resource({
|
||||
request: () => ({
|
||||
contentType: this.stalkerStore.selectedContentType(),
|
||||
category: this.stalkerStore.selectedCategoryId(),
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
search: this.searchPhrase,
|
||||
pageIndex: this.pageIndex(),
|
||||
}),
|
||||
loader: async ({ request }) => {
|
||||
if (
|
||||
!request.category ||
|
||||
request.category === null ||
|
||||
request.category === ''
|
||||
) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const { portalUrl, macAddress } = this.currentPlaylist();
|
||||
const params = {
|
||||
action: StalkerContentTypes[request.contentType]
|
||||
.getContentAction,
|
||||
type: request.contentType,
|
||||
category: request.category ?? '',
|
||||
genre: request.category ?? '',
|
||||
//sortby: 'added',
|
||||
...(request.search !== '' ? { search: request.search } : {}),
|
||||
p: request.pageIndex,
|
||||
};
|
||||
|
||||
const response = await this.dataService.sendIpcEvent(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
if (response) {
|
||||
const newItems = response.js.data.map((item) => ({
|
||||
...item,
|
||||
cover: item.screenshot_uri,
|
||||
}));
|
||||
|
||||
// Check if we're loading the first page or loading more
|
||||
if (request.pageIndex === 0) {
|
||||
this.itvChannels.set(newItems);
|
||||
} else {
|
||||
// Append new items to existing ones
|
||||
this.itvChannels.update((items) => [
|
||||
...items,
|
||||
...newItems,
|
||||
]);
|
||||
}
|
||||
|
||||
// Update hasMoreItems based on total count and current items
|
||||
const totalLoaded = this.itvChannels().length;
|
||||
this.hasMoreItems.set(totalLoaded < response.js.total_items);
|
||||
|
||||
return newItems;
|
||||
} else {
|
||||
this.currentLayout = 'not-available';
|
||||
this.showErrorAsNotification({
|
||||
message: 'Error',
|
||||
status: 500,
|
||||
});
|
||||
throw new Error(
|
||||
`Error: ${response.message} (Status: ${response.status})`
|
||||
);
|
||||
}
|
||||
},
|
||||
}); */
|
||||
|
||||
constructor(
|
||||
private readonly activatedRoute: ActivatedRoute,
|
||||
private readonly playlistService: PlaylistsService,
|
||||
private readonly router: Router,
|
||||
private readonly snackBar: MatSnackBar
|
||||
private readonly router: Router
|
||||
) {
|
||||
// reset category title after changing content type
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedContentType();
|
||||
});
|
||||
|
||||
// reset items when category changes
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedCategoryId();
|
||||
this.stalkerStore.setItvChannels([]);
|
||||
this.stalkerStore.setPage(0);
|
||||
//this.hasMoreItems.set(false);
|
||||
});
|
||||
|
||||
// reset loading state when content resource changes and check if we need more
|
||||
effect(() => {
|
||||
if (this.contentItems() !== undefined) {
|
||||
this.isLoadingMore.set(false);
|
||||
// Check if we need to load more to fill the viewport
|
||||
this.checkIfNeedsMoreContent();
|
||||
}
|
||||
});
|
||||
|
||||
// Also check when ITV channels are loaded
|
||||
effect(() => {
|
||||
const channels = this.itvChannels();
|
||||
if (channels.length > 0) {
|
||||
this.isLoadingMore.set(false);
|
||||
// Small delay to allow DOM to update before checking
|
||||
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
this.playlistService
|
||||
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
|
||||
.subscribe((favorites) => {
|
||||
favorites.forEach((fav: any) => {
|
||||
this.favorites.set(fav.id, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Setup scroll listener when container becomes available
|
||||
effect(() => {
|
||||
const scrollContainer = this.itvScrollContainer();
|
||||
if (scrollContainer) {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.removeScrollListener();
|
||||
}
|
||||
|
||||
private setupScrollListener() {
|
||||
this.removeScrollListener();
|
||||
|
||||
const container = this.itvScrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const scrollThreshold = 150; // pixels from bottom to trigger load
|
||||
|
||||
if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) {
|
||||
this.loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
this.scrollListener = () => container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the container needs more content to become scrollable.
|
||||
* If content doesn't fill the viewport and there are more items, load more.
|
||||
*/
|
||||
private checkIfNeedsMoreContent() {
|
||||
const container = this.itvScrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = container;
|
||||
// If content doesn't fill the container (no scrollbar), load more
|
||||
if (scrollHeight <= clientHeight) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private removeScrollListener() {
|
||||
if (this.scrollListener) {
|
||||
this.scrollListener();
|
||||
this.scrollListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
handshake() {
|
||||
/* this.sendRequest({
|
||||
action: StalkerPortalActions.Handshake,
|
||||
type: ContentType.STB,
|
||||
}); */
|
||||
}
|
||||
|
||||
categoryClicked(item: { category_name: string; category_id: string }) {
|
||||
this.stalkerStore.setSelectedCategory(Number(item.category_id));
|
||||
this.stalkerStore.setSelectedCategory(item.category_id || '*');
|
||||
this.currentLayout = 'category_content';
|
||||
this.stalkerStore.setPage(0);
|
||||
this.stalkerStore.setSelectedItem(undefined);
|
||||
if (this.stalkerStore.selectedContentType() === 'itv') return;
|
||||
|
||||
this.router.navigate(['.', item.category_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
}
|
||||
|
||||
createLinkToPlayItv(item: any) {
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
this.stalkerStore.createLinkToPlayVod(item.cmd, item.o_name || item.name, item.logo);
|
||||
}
|
||||
|
||||
toggleFavorite(item: any) {
|
||||
if (this.favorites.has(item.id)) {
|
||||
this.stalkerStore.removeFromFavorites(item.id);
|
||||
this.favorites.delete(item.id);
|
||||
this.snackBar.open('Removed from favorites', null, {
|
||||
duration: 1000,
|
||||
});
|
||||
} else {
|
||||
this.stalkerStore.addToFavorites({
|
||||
...item,
|
||||
category_id: 'itv',
|
||||
title: item.o_name || item.name,
|
||||
cover: item.logo,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
this.favorites.set(item.id, true);
|
||||
this.snackBar.open('Added to favorites', null, {
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingMore.set(true);
|
||||
const nextPage = this.pageIndex() + 1;
|
||||
this.stalkerStore.setPage(nextPage);
|
||||
/* this.pageIndex.set(nextPage); */
|
||||
}
|
||||
|
||||
historyBack() {
|
||||
this.currentLayout = 'category_content';
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ export const stalkerRoutes: Route[] = [
|
||||
{
|
||||
path: 'itv',
|
||||
loadComponent: () =>
|
||||
import('./stalker-main-container.component').then(
|
||||
(c) => c.StalkerMainContainerComponent
|
||||
),
|
||||
import(
|
||||
'./stalker-live-stream-layout/stalker-live-stream-layout.component'
|
||||
).then((c) => c.StalkerLiveStreamLayoutComponent),
|
||||
},
|
||||
{
|
||||
path: 'series',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
|
||||
import {
|
||||
EpgItem,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
STALKER_REQUEST,
|
||||
@@ -289,7 +290,8 @@ export const StalkerStore = signalStore(
|
||||
}
|
||||
// VOD uses 'genre' param, series uses 'category' param, itv uses both
|
||||
// Based on stalker-to-m3u implementation
|
||||
const categoryParam = params.category ?? '';
|
||||
// Use "*" for categories without an ID (e.g. "All") to fetch all items
|
||||
const categoryParam = params.category || '*';
|
||||
const queryParams: Record<string, string | number> = {
|
||||
action: StalkerContentTypes[params.contentType]
|
||||
.getContentAction,
|
||||
@@ -562,9 +564,12 @@ export const StalkerStore = signalStore(
|
||||
setSelectedContentType(type: 'vod' | 'itv' | 'series') {
|
||||
patchState(store, { selectedContentType: type });
|
||||
},
|
||||
setSelectedCategory(id: number) {
|
||||
setSelectedCategory(id: string | number | null) {
|
||||
patchState(store, {
|
||||
selectedCategoryId: id !== null ? String(id) : null,
|
||||
selectedCategoryId:
|
||||
id !== null && id !== undefined
|
||||
? String(id)
|
||||
: null,
|
||||
page: 0,
|
||||
});
|
||||
},
|
||||
@@ -654,6 +659,9 @@ export const StalkerStore = signalStore(
|
||||
setItvChannels(channels: any[]) {
|
||||
patchState(store, { itvChannels: channels });
|
||||
},
|
||||
setSearchPhrase(phrase: string) {
|
||||
patchState(store, { searchPhrase: phrase, page: 0 });
|
||||
},
|
||||
/**
|
||||
* Fetch episodes for a VOD series season (Ministra plugin)
|
||||
* @param videoId The video_id from the season item
|
||||
@@ -1055,6 +1063,60 @@ export const StalkerStore = signalStore(
|
||||
)
|
||||
.subscribe();
|
||||
},
|
||||
async fetchChannelEpg(
|
||||
channelId: number | string,
|
||||
size = 10
|
||||
): Promise<EpgItem[]> {
|
||||
const playlist = store.currentPlaylist() as Playlist;
|
||||
if (!playlist) return [];
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
action: StalkerPortalActions.GetShortEpg,
|
||||
type: 'itv',
|
||||
ch_id: String(channelId),
|
||||
size: String(size),
|
||||
};
|
||||
|
||||
let response: any;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
response = await dataService.sendIpcEvent(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params: queryParams,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const epgData =
|
||||
response?.js?.data ?? response?.js ?? [];
|
||||
const items = Array.isArray(epgData) ? epgData : [];
|
||||
|
||||
return items.map((item) => ({
|
||||
id: String(item.id ?? ''),
|
||||
epg_id: '',
|
||||
title: item.name ?? '',
|
||||
description: item.descr ?? '',
|
||||
lang: '',
|
||||
start: item.time ?? '',
|
||||
end: item.time_to ?? '',
|
||||
stop: item.time_to ?? '',
|
||||
channel_id: String(item.ch_id ?? channelId),
|
||||
start_timestamp: String(
|
||||
item.start_timestamp ?? ''
|
||||
),
|
||||
stop_timestamp: String(
|
||||
item.stop_timestamp ?? ''
|
||||
),
|
||||
}));
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -3,11 +3,11 @@
|
||||
[resultsCount]="xtreamStore.searchResults().length"
|
||||
[isLoading]="xtreamStore.isSearching()"
|
||||
[showCloseButton]="isGlobalSearch"
|
||||
[showResultsCount]="!isGlobalSearch"
|
||||
[showResultsCount]="!isGlobalSearch || !groupByPlaylist()"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
(closeClick)="onCloseDialog()"
|
||||
>
|
||||
<!-- Filters -->
|
||||
<!-- Type filters -->
|
||||
<ng-container filters>
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
@@ -19,43 +19,74 @@
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container subheader>
|
||||
@if (isGlobalSearch) {
|
||||
<div class="global-search-subheader">
|
||||
<div class="global-search-note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>{{ 'PORTALS.SEARCH_VIEW.XTREAM_ONLY_NOTE' | translate }}</span>
|
||||
</div>
|
||||
<mat-checkbox
|
||||
[ngModel]="groupByPlaylist()"
|
||||
(ngModelChange)="toggleGroupByPlaylist($event)"
|
||||
>
|
||||
{{ 'PORTALS.SEARCH_VIEW.GROUP_BY_PLAYLIST' | translate }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Results -->
|
||||
<ng-container results>
|
||||
@if (isGlobalSearch) {
|
||||
<!-- Global Search Results -->
|
||||
<div class="playlist-section">
|
||||
<h2 class="section-title">
|
||||
Found {{ xtreamStore.searchResults().length }} items across
|
||||
{{ (getGroupedResults() | keyvalue).length }} playlists
|
||||
</h2>
|
||||
@for (
|
||||
group of getGroupedResults() | keyvalue;
|
||||
track group.key
|
||||
) {
|
||||
<div class="playlist-group">
|
||||
<h3 class="playlist-title">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
{{ group.key }}
|
||||
<span class="item-count"
|
||||
>({{ $any(group).value.length }} items)</span
|
||||
>
|
||||
</h3>
|
||||
<div class="results-grid">
|
||||
@for (item of $any(group).value; track item.id) {
|
||||
<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)"
|
||||
/>
|
||||
}
|
||||
@if (groupByPlaylist()) {
|
||||
<!-- Grouped by playlist -->
|
||||
<div class="playlist-section">
|
||||
<h2 class="section-title">
|
||||
Found {{ xtreamStore.searchResults().length }} items across
|
||||
{{ (groupedResults() | keyvalue).length }} playlists
|
||||
</h2>
|
||||
@for (
|
||||
group of groupedResults() | keyvalue;
|
||||
track group.key
|
||||
) {
|
||||
<div class="playlist-group">
|
||||
<h3 class="playlist-title">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
{{ group.key }}
|
||||
<span class="item-count"
|
||||
>({{ $any(group).value.length }} items)</span
|
||||
>
|
||||
</h3>
|
||||
<div class="results-grid">
|
||||
@for (item of $any(group).value; track item.id) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url"
|
||||
[title]="item.title"
|
||||
[type]="item.type"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Flat list -->
|
||||
<div class="results-grid">
|
||||
@for (item of xtreamStore.searchResults(); track item.id) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url"
|
||||
[title]="item.title"
|
||||
[type]="item.type"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- Playlist-level Search Results -->
|
||||
<div class="results-grid">
|
||||
|
||||
@@ -55,6 +55,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Subheader row for global search (note + grouping toggle)
|
||||
.global-search-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.global-search-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.55;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep mat-checkbox {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist grouping styles for global search
|
||||
.playlist-group {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -2,10 +2,12 @@ import { KeyValuePipe } from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
Inject,
|
||||
Optional,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -24,7 +26,6 @@ import { XtreamContentItem } from '../data-sources/xtream-data-source.interface'
|
||||
import { ContentType } from '../xtream-state';
|
||||
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
|
||||
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
|
||||
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
import { SearchFilters } from '../stores/features/with-search.feature';
|
||||
|
||||
@@ -42,7 +43,6 @@ interface SearchResultsData {
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
SearchLayoutComponent,
|
||||
SearchResultItemComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
providers: [],
|
||||
@@ -62,8 +62,15 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
/** Search filters from store */
|
||||
readonly filters = this.xtreamStore.searchFilters;
|
||||
|
||||
private static readonly GROUP_BY_STORAGE_KEY = 'global-search-group-by-playlist';
|
||||
|
||||
isGlobalSearch = false;
|
||||
|
||||
/** Whether to group global search results by playlist */
|
||||
readonly groupByPlaylist = signal(
|
||||
localStorage.getItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY) !== 'false'
|
||||
);
|
||||
|
||||
readonly filterConfig = [
|
||||
{
|
||||
key: 'live' as keyof SearchFilters,
|
||||
@@ -82,16 +89,24 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
},
|
||||
];
|
||||
|
||||
/** Grouped results computed once per result change (avoids recalculating on every CD cycle) */
|
||||
readonly groupedResults = computed(() => {
|
||||
const results = this.xtreamStore.searchResults();
|
||||
if (!this.isGlobalSearch) return { default: results };
|
||||
return groupBy(results, 'playlist_name');
|
||||
});
|
||||
|
||||
constructor(
|
||||
@Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData,
|
||||
@Optional() public dialogRef: MatDialogRef<SearchResultsComponent>
|
||||
) {
|
||||
this.isGlobalSearch = data?.isGlobalSearch || false;
|
||||
|
||||
effect(() => {
|
||||
effect((onCleanup) => {
|
||||
const term = this.searchTerm();
|
||||
if (term.length >= 3) {
|
||||
this.executeSearch();
|
||||
const timeout = setTimeout(() => this.executeSearch(), 300);
|
||||
onCleanup(() => clearTimeout(timeout));
|
||||
} else if (term.length === 0) {
|
||||
this.clearResultsOnly();
|
||||
}
|
||||
@@ -146,6 +161,7 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
async searchGlobal(term: string, types: string[]) {
|
||||
this.xtreamStore.setIsSearching(true);
|
||||
try {
|
||||
const results = await this.databaseService.globalSearchContent(
|
||||
term,
|
||||
@@ -153,6 +169,8 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
);
|
||||
if (results && Array.isArray(results)) {
|
||||
this.xtreamStore.setGlobalSearchResults(results);
|
||||
} else {
|
||||
this.xtreamStore.setIsSearching(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in global search:', error);
|
||||
@@ -188,9 +206,12 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
this.dialogRef?.close();
|
||||
}
|
||||
|
||||
toggleGroupByPlaylist(value: boolean) {
|
||||
this.groupByPlaylist.set(value);
|
||||
localStorage.setItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY, String(value));
|
||||
}
|
||||
|
||||
getGroupedResults() {
|
||||
const results = this.xtreamStore.searchResults();
|
||||
if (!this.isGlobalSearch) return { default: results };
|
||||
return groupBy(results, 'playlist_name');
|
||||
return this.groupedResults();
|
||||
}
|
||||
}
|
||||
@@ -113,9 +113,17 @@ export function withSearch() {
|
||||
patchState(store, {
|
||||
searchResults: results as any,
|
||||
globalSearchResults: results,
|
||||
isSearching: false,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the searching state
|
||||
*/
|
||||
setIsSearching(value: boolean): void {
|
||||
patchState(store, { isSearching: value });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the search term
|
||||
*/
|
||||
|
||||
@@ -314,7 +314,11 @@
|
||||
"NO_SEARCH_RESULTS": "لم يتم العثور على نتائج، حاول تغيير طلب البحث"
|
||||
},
|
||||
"STREAM_URL_COPIED": "تم نسخ رابط البث إلى الحافظة",
|
||||
"COPY_STREAM_URL": "نسخ رابط البث"
|
||||
"COPY_STREAM_URL": "نسخ رابط البث",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "تم العثور على {{count}} نتيجة",
|
||||
"NO_RESULTS_FOR": "لم يتم العثور على نتائج لـ \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"INFORMATION": "معلومات",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -306,7 +306,11 @@
|
||||
"NO_SEARCH_RESULTS": "ما تلقى والو، حاول تبدل طلب البحث ديالك"
|
||||
},
|
||||
"STREAM_URL_COPIED": "رابط البث تنقل للحافظة",
|
||||
"COPY_STREAM_URL": "نسخ رابط البث"
|
||||
"COPY_STREAM_URL": "نسخ رابط البث",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "تلقاو {{count}} ديال النتايج",
|
||||
"NO_RESULTS_FOR": "ما تلقاو حتى نتيجة لـ \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"INFORMATION": "معلومات",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -281,7 +281,11 @@
|
||||
"NO_SEARCH_RESULTS": "Нічога не знойдзена, паспрабуйце змяніць пошукавы запыт"
|
||||
},
|
||||
"REMOVED_FROM_FAVORITES": "Выдалена з выбранага",
|
||||
"ADDED_TO_FAVORITES": "Дададзена ў выбранае"
|
||||
"ADDED_TO_FAVORITES": "Дададзена ў выбранае",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "Знойдзена {{count}} вынікаў",
|
||||
"NO_RESULTS_FOR": "Не знойдзена вынікаў для \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Інфармацыя"
|
||||
}
|
||||
@@ -381,7 +381,11 @@
|
||||
"REMOVED_FROM_FAVORITES": "Aus den Favoriten entfernt",
|
||||
"ADDED_TO_FAVORITES": "Zu den Favoriten hinzugefügt",
|
||||
"SELECT_CATEGORY": "Wählen Sie eine Kategorie aus",
|
||||
"PAGE": "Seite"
|
||||
"PAGE": "Seite",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "{{count}} Ergebnisse gefunden",
|
||||
"NO_RESULTS_FOR": "Keine Ergebnisse für \"{{term}}\" gefunden"
|
||||
}
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "Über IPTVnator",
|
||||
|
||||
@@ -426,7 +426,9 @@
|
||||
},
|
||||
"SEARCH_VIEW": {
|
||||
"INITIAL_TITLE": "Αναζήτηση περιεχομένου",
|
||||
"INITIAL_DESCRIPTION": "Εισαγάγετε τουλάχιστον 3 χαρακτήρες για αναζήτηση σε ζωντανές τηλεοπτικές μεταδόσεις, ταινίες και σειρές."
|
||||
"INITIAL_DESCRIPTION": "Εισαγάγετε τουλάχιστον 3 χαρακτήρες για αναζήτηση σε ζωντανές τηλεοπτικές μεταδόσεις, ταινίες και σειρές.",
|
||||
"RESULTS_FOUND": "Βρέθηκαν {{count}} αποτελέσματα",
|
||||
"NO_RESULTS_FOR": "Δεν βρέθηκαν αποτελέσματα για \"{{term}}\""
|
||||
},
|
||||
"STREAM_URL_COPIED": "Η διεύθυνση URL ροής αντιγράφηκε στο πρόχειρο",
|
||||
"COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής",
|
||||
|
||||
@@ -442,7 +442,11 @@
|
||||
},
|
||||
"SEARCH_VIEW": {
|
||||
"INITIAL_TITLE": "Search for content",
|
||||
"INITIAL_DESCRIPTION": "Enter at least 3 characters to search across live TV, movies and series."
|
||||
"INITIAL_DESCRIPTION": "Enter at least 3 characters to search across live TV, movies and series.",
|
||||
"RESULTS_FOUND": "Found {{count}} results",
|
||||
"NO_RESULTS_FOR": "No results found for \"{{term}}\"",
|
||||
"GROUP_BY_PLAYLIST": "Group results by playlist",
|
||||
"XTREAM_ONLY_NOTE": "Global search is currently available for Xtream playlists only."
|
||||
},
|
||||
"STREAM_URL_COPIED": "Stream URL copied to clipboard",
|
||||
"COPY_STREAM_URL": "Copy Stream URL",
|
||||
|
||||
@@ -271,7 +271,11 @@
|
||||
"NO_SEARCH_RESULTS": "No se encontró nada, intenta cambiar tu solicitud de búsqueda."
|
||||
},
|
||||
"REMOVED_FROM_FAVORITES": "Eliminado de favoritos",
|
||||
"ADDED_TO_FAVORITES": "Añadido a favoritos"
|
||||
"ADDED_TO_FAVORITES": "Añadido a favoritos",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "Se encontraron {{count}} resultados",
|
||||
"NO_RESULTS_FOR": "No se encontraron resultados para \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "Acerca de IPTVnator",
|
||||
|
||||
@@ -347,7 +347,11 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL du flux copiée dans le presse-papiers",
|
||||
"COPY_STREAM_URL": "Copier l'URL du flux",
|
||||
"SELECT_CATEGORY": "Sélectionner une catégorie"
|
||||
"SELECT_CATEGORY": "Sélectionner une catégorie",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "{{count}} résultats trouvés",
|
||||
"NO_RESULTS_FOR": "Aucun résultat trouvé pour \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Information",
|
||||
"DOWNLOADS": {
|
||||
|
||||
@@ -304,7 +304,11 @@
|
||||
"NO_SEARCH_RESULTS": "Nessun risultato, prova a modificare la richiesta di ricerca"
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL streaming copiato negli appunti",
|
||||
"COPY_STREAM_URL": "Copia URL streaming"
|
||||
"COPY_STREAM_URL": "Copia URL streaming",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "Trovati {{count}} risultati",
|
||||
"NO_RESULTS_FOR": "Nessun risultato trovato per \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Informazioni",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -294,7 +294,11 @@
|
||||
"NO_SEARCH_RESULTS": "何も見つかりませんでした。検索リクエストを変更してみてください"
|
||||
},
|
||||
"STREAM_URL_COPIED": "ストリームURLがクリップボードにコピーされました",
|
||||
"COPY_STREAM_URL": "ストリームURLをコピー"
|
||||
"COPY_STREAM_URL": "ストリームURLをコピー",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "{{count}}件の結果が見つかりました",
|
||||
"NO_RESULTS_FOR": "「{{term}}」に一致する結果は見つかりませんでした"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "情報",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -242,7 +242,11 @@
|
||||
"NO_SEARCH_RESULTS": "아무것도 찾지 못했습니다. 검색 요청을 변경해 보세요."
|
||||
},
|
||||
"REMOVED_FROM_FAVORITES": "즐겨찾기에서 삭제됨",
|
||||
"ADDED_TO_FAVORITES": "즐겨찾기에 추가됨"
|
||||
"ADDED_TO_FAVORITES": "즐겨찾기에 추가됨",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "{{count}}개의 결과를 찾았습니다",
|
||||
"NO_RESULTS_FOR": "\"{{term}}\"에 대한 결과를 찾을 수 없습니다"
|
||||
}
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "IPTVnator 소개",
|
||||
|
||||
@@ -306,7 +306,11 @@
|
||||
"NO_SEARCH_RESULTS": "Niks gevonden, verander je zoekopdracht"
|
||||
},
|
||||
"STREAM_URL_COPIED": "Stream URL Gekopieërd",
|
||||
"COPY_STREAM_URL": "Kopieër Stream URL"
|
||||
"COPY_STREAM_URL": "Kopieër Stream URL",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "{{count}} resultaten gevonden",
|
||||
"NO_RESULTS_FOR": "Geen resultaten gevonden voor \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Informatie",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -306,7 +306,11 @@
|
||||
"NO_SEARCH_RESULTS": "Nic nie znaleziono, spróbuj zmienić zapytanie wyszukiwania"
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL strumienia skopiowany do schowka",
|
||||
"COPY_STREAM_URL": "Kopiuj URL strumienia"
|
||||
"COPY_STREAM_URL": "Kopiuj URL strumienia",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "Znaleziono {{count}} wyników",
|
||||
"NO_RESULTS_FOR": "Brak wyników dla „{{term}}”"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Informacje",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -344,7 +344,11 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL do stream copiado para a área de transferência",
|
||||
"COPY_STREAM_URL": "Copiar URL do Stream",
|
||||
"SELECT_CATEGORY": "Selecione uma categoria"
|
||||
"SELECT_CATEGORY": "Selecione uma categoria",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "{{count}} resultados encontrados",
|
||||
"NO_RESULTS_FOR": "Nenhum resultado encontrado para \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Informação",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -391,7 +391,11 @@
|
||||
"REMOVED_FROM_FAVORITES": "Удалено из избранного",
|
||||
"ADDED_TO_FAVORITES": "Добавлено в избранное",
|
||||
"SELECT_CATEGORY": "Выберите категорию",
|
||||
"PAGE": "Страница"
|
||||
"PAGE": "Страница",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "Найдено {{count}} результатов",
|
||||
"NO_RESULTS_FOR": "Нет результатов по запросу \"{{term}}\""
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Инфо",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -294,7 +294,11 @@
|
||||
"NO_SEARCH_RESULTS": "Arama sonuçları yok, lütfen arama talebinizi değiştirin"
|
||||
},
|
||||
"STREAM_URL_COPIED": "Yayın URL'si panoya kopyalandı",
|
||||
"COPY_STREAM_URL": "Yayın URL'sini kopyala"
|
||||
"COPY_STREAM_URL": "Yayın URL'sini kopyala",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "{{count}} sonuç bulundu",
|
||||
"NO_RESULTS_FOR": "\"{{term}}\" için sonuç bulunamadı"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Bilgi",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -241,7 +241,11 @@
|
||||
"NO_SEARCH_RESULTS": "未找到任何内容,请尝试更改您的搜索请求"
|
||||
},
|
||||
"REMOVED_FROM_FAVORITES": "已从收藏夹中删除",
|
||||
"ADDED_TO_FAVORITES": "已添加至收藏夹"
|
||||
"ADDED_TO_FAVORITES": "已添加至收藏夹",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "找到 {{count}} 条结果",
|
||||
"NO_RESULTS_FOR": "未找到与“{{term}}”匹配的结果"
|
||||
}
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "关于 IPTVnator",
|
||||
|
||||
@@ -324,7 +324,11 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "串流網址已複製到剪貼簿",
|
||||
"COPY_STREAM_URL": "複製串流網址",
|
||||
"SELECT_CATEGORY": "選擇類別"
|
||||
"SELECT_CATEGORY": "選擇類別",
|
||||
"SEARCH_VIEW": {
|
||||
"RESULTS_FOUND": "找到 {{count}} 筆結果",
|
||||
"NO_RESULTS_FOR": "找不到與「{{term}}」相符的結果"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "資訊",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
@@ -57,6 +57,10 @@ export class PlaylistEffects {
|
||||
this.store.select(selectFavorites),
|
||||
this.store.select(selectActivePlaylistId)
|
||||
),
|
||||
filter(
|
||||
([, , playlistId]) =>
|
||||
playlistId !== GLOBAL_FAVORITES_PLAYLIST_ID
|
||||
),
|
||||
switchMap(([, favorites, playlistId]) =>
|
||||
this.playlistsService.updateFavorites(playlistId, favorites)
|
||||
),
|
||||
|
||||
@@ -6,4 +6,6 @@ export enum StalkerPortalActions {
|
||||
Favorites = 'favorites',
|
||||
Handshake = 'handshake',
|
||||
DoAuth = 'do_auth',
|
||||
GetShortEpg = 'get_short_epg',
|
||||
GetEpgInfo = 'get_epg_info',
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export function createFavoritesPlaylist(
|
||||
playlist: {
|
||||
items: channels,
|
||||
},
|
||||
favorites: channels.map((channel) => channel.url),
|
||||
filename: 'Global favorites',
|
||||
};
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
@if (_channelList) {
|
||||
@if (displayedChannels().length) {
|
||||
<mat-tab-group color="primary" backgroundColor="primary">
|
||||
<!-- All Channels Tab -->
|
||||
<mat-tab>
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
<app-all-channels-tab
|
||||
[channels]="_channelList"
|
||||
[channels]="displayedChannels()"
|
||||
[channelEpgMap]="channelEpgMap()"
|
||||
[progressTick]="progressTick()"
|
||||
[shouldShowEpg]="shouldShowEpg()"
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
<app-groups-tab
|
||||
[groupedChannels]="groupedChannels"
|
||||
[groupedChannels]="groupedChannels()"
|
||||
[channelEpgMap]="channelEpgMap()"
|
||||
[progressTick]="progressTick()"
|
||||
[shouldShowEpg]="shouldShowEpg()"
|
||||
|
||||
+30
-16
@@ -10,12 +10,11 @@ import {
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import * as _ from 'lodash';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import {
|
||||
ChannelActions,
|
||||
FavoritesActions,
|
||||
@@ -25,7 +24,13 @@ import {
|
||||
} from 'm3u-state';
|
||||
import { BehaviorSubject, combineLatest, map, skipWhile } from 'rxjs';
|
||||
import { EpgService } from 'services';
|
||||
import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces';
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
GLOBAL_FAVORITES_PLAYLIST_ID,
|
||||
Settings,
|
||||
STORE_KEY,
|
||||
} from 'shared-interfaces';
|
||||
import { AllChannelsTabComponent } from './all-channels-tab/all-channels-tab.component';
|
||||
import { FavoritesTabComponent } from './favorites-tab/favorites-tab.component';
|
||||
import { GroupsTabComponent } from './groups-tab/groups-tab.component';
|
||||
@@ -47,10 +52,8 @@ import { GroupsTabComponent } from './groups-tab/groups-tab.component';
|
||||
})
|
||||
export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly storage = inject(StorageMap);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
/** Map of channel ID to current EPG program */
|
||||
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
|
||||
@@ -72,6 +75,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Channels array */
|
||||
_channelList: Channel[] = [];
|
||||
private readonly channelListSignal = signal<Channel[]>([]);
|
||||
private channelList$ = new BehaviorSubject<Channel[]>([]);
|
||||
|
||||
get channelList(): Channel[] {
|
||||
@@ -81,13 +85,29 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
@Input()
|
||||
set channelList(value: Channel[]) {
|
||||
this._channelList = value;
|
||||
this.channelListSignal.set(value);
|
||||
this.channelList$.next(value);
|
||||
this.groupedChannels = _.default.groupBy(value, 'group.title');
|
||||
this.fetchEpgForChannels(value);
|
||||
}
|
||||
|
||||
/** Active playlist ID as signal */
|
||||
private readonly activePlaylistIdSignal = this.store.selectSignal(
|
||||
selectActivePlaylistId
|
||||
);
|
||||
|
||||
/** Displayed channels - filters out unfavorited channels in global favorites view */
|
||||
readonly displayedChannels = computed(() => {
|
||||
const channels = this.channelListSignal();
|
||||
if (this.activePlaylistIdSignal() === GLOBAL_FAVORITES_PLAYLIST_ID) {
|
||||
return channels.filter((ch) => this.favoriteIds().has(ch.url));
|
||||
}
|
||||
return channels;
|
||||
});
|
||||
|
||||
/** Object with channels sorted by groups */
|
||||
groupedChannels: { [key: string]: Channel[] } = {};
|
||||
readonly groupedChannels = computed(() =>
|
||||
groupBy(this.displayedChannels(), 'group.title')
|
||||
);
|
||||
|
||||
/** Selected channel */
|
||||
readonly activeChannel = this.store.selectSignal(selectActive);
|
||||
@@ -105,7 +125,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
/** Set of favorite channel URLs for quick lookup */
|
||||
readonly favoriteIds = signal<Set<string>>(new Set());
|
||||
private readonly _favorites = this.store.selectSignal(selectFavorites);
|
||||
readonly favoriteIds = computed(() => new Set(this._favorites()));
|
||||
|
||||
/** List with favorites */
|
||||
favorites$ = combineLatest([
|
||||
@@ -113,8 +134,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
this.channelList$,
|
||||
]).pipe(
|
||||
map(([favoriteChannelIds, channelList]) => {
|
||||
// Update the favoriteIds signal for quick lookup
|
||||
this.favoriteIds.set(new Set(favoriteChannelIds));
|
||||
return favoriteChannelIds
|
||||
.map((favoriteChannelId) =>
|
||||
channelList.find(
|
||||
@@ -205,11 +224,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
onFavoriteToggled(event: { channel: Channel; event: MouseEvent }): void {
|
||||
event.event.stopPropagation();
|
||||
this.snackBar.open(
|
||||
this.translateService.instant('CHANNELS.FAVORITES_UPDATED'),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
this.store.dispatch(
|
||||
FavoritesActions.updateFavorites({ channel: event.channel })
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user