Merge pull request #758 from 4gray/feat/search-feature-improvements

Feat/search feature improvements
This commit is contained in:
4gray authored and GitHub committed 2026-02-08 20:40:55 +01:00
commit 856f529b9a
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;
@@ -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%;
@@ -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>
@@ -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;
}
}
@@ -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';
}
+3 -3
View File
@@ -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',
+65 -3
View File
@@ -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
*/
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": "Інфармацыя"
}
+5 -1
View File
@@ -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",
+3 -1
View File
@@ -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 ροής",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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 소개",
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": {
+4
View File
@@ -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',
};
}
@@ -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()"
@@ -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 })
);