From f5edba7d4c4570bc53b54d0af0049846af312483 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 18:39:49 +0100 Subject: [PATCH 01/10] feat: add search view localization for multiple languages --- apps/web/src/assets/i18n/ar.json | 6 +++++- apps/web/src/assets/i18n/ary.json | 6 +++++- apps/web/src/assets/i18n/by.json | 6 +++++- apps/web/src/assets/i18n/de.json | 6 +++++- apps/web/src/assets/i18n/el.json | 4 +++- apps/web/src/assets/i18n/en.json | 6 +++++- apps/web/src/assets/i18n/es.json | 6 +++++- apps/web/src/assets/i18n/fr.json | 6 +++++- apps/web/src/assets/i18n/it.json | 6 +++++- apps/web/src/assets/i18n/ja.json | 6 +++++- apps/web/src/assets/i18n/ko.json | 6 +++++- apps/web/src/assets/i18n/nl.json | 6 +++++- apps/web/src/assets/i18n/pl.json | 6 +++++- apps/web/src/assets/i18n/pt.json | 6 +++++- apps/web/src/assets/i18n/ru.json | 6 +++++- apps/web/src/assets/i18n/tr.json | 6 +++++- apps/web/src/assets/i18n/zh.json | 6 +++++- 17 files changed, 83 insertions(+), 17 deletions(-) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8e906be91..ddfb2b9f0 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -314,7 +314,11 @@ "NO_SEARCH_RESULTS": "لم يتم العثور على نتائج، حاول تغيير طلب البحث" }, "STREAM_URL_COPIED": "تم نسخ رابط البث إلى الحافظة", - "COPY_STREAM_URL": "نسخ رابط البث" + "COPY_STREAM_URL": "نسخ رابط البث", + "SEARCH_VIEW": { + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "معلومات", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 2bb801e40..037b6ae57 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -306,7 +306,11 @@ "NO_SEARCH_RESULTS": "ما تلقى والو، حاول تبدل طلب البحث ديالك" }, "STREAM_URL_COPIED": "رابط البث تنقل للحافظة", - "COPY_STREAM_URL": "نسخ رابط البث" + "COPY_STREAM_URL": "نسخ رابط البث", + "SEARCH_VIEW": { + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "معلومات", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 59d1e02ba..874b14772 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -281,7 +281,11 @@ "NO_SEARCH_RESULTS": "Нічога не знойдзена, паспрабуйце змяніць пошукавы запыт" }, "REMOVED_FROM_FAVORITES": "Выдалена з выбранага", - "ADDED_TO_FAVORITES": "Дададзена ў выбранае" + "ADDED_TO_FAVORITES": "Дададзена ў выбранае", + "SEARCH_VIEW": { + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "Інфармацыя" } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 96b37273d..cdceda05a 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "ABOUT": { "TITLE": "Über IPTVnator", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 0ae785d21..4f2b8d968 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -426,7 +426,9 @@ }, "SEARCH_VIEW": { "INITIAL_TITLE": "Αναζήτηση περιεχομένου", - "INITIAL_DESCRIPTION": "Εισαγάγετε τουλάχιστον 3 χαρακτήρες για αναζήτηση σε ζωντανές τηλεοπτικές μεταδόσεις, ταινίες και σειρές." + "INITIAL_DESCRIPTION": "Εισαγάγετε τουλάχιστον 3 χαρακτήρες για αναζήτηση σε ζωντανές τηλεοπτικές μεταδόσεις, ταινίες και σειρές.", + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" }, "STREAM_URL_COPIED": "Η διεύθυνση URL ροής αντιγράφηκε στο πρόχειρο", "COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index f93bfe454..d05d3a16b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c0e2ad7da..fda3cc5ad 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "ABOUT": { "TITLE": "Acerca de IPTVnator", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 0ec656b2f..bda08056b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "Information", "DOWNLOADS": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0fd8e1e1e..e0fe90df5 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "Informazioni", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index fac596d8f..185814827 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -294,7 +294,11 @@ "NO_SEARCH_RESULTS": "何も見つかりませんでした。検索リクエストを変更してみてください" }, "STREAM_URL_COPIED": "ストリームURLがクリップボードにコピーされました", - "COPY_STREAM_URL": "ストリームURLをコピー" + "COPY_STREAM_URL": "ストリームURLをコピー", + "SEARCH_VIEW": { + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "情報", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 9ffccae6d..69fd4dd45 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -242,7 +242,11 @@ "NO_SEARCH_RESULTS": "아무것도 찾지 못했습니다. 검색 요청을 변경해 보세요." }, "REMOVED_FROM_FAVORITES": "즐겨찾기에서 삭제됨", - "ADDED_TO_FAVORITES": "즐겨찾기에 추가됨" + "ADDED_TO_FAVORITES": "즐겨찾기에 추가됨", + "SEARCH_VIEW": { + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "ABOUT": { "TITLE": "IPTVnator 소개", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index e6011c8f1..cd9fab042 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "Informatie", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d018dafbe..9e28f36a5 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "Informacje", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 767efe922..27d970fe5 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "Informação", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 65c6d1cf4..57da9f489 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -391,7 +391,11 @@ "REMOVED_FROM_FAVORITES": "Удалено из избранного", "ADDED_TO_FAVORITES": "Добавлено в избранное", "SELECT_CATEGORY": "Выберите категорию", - "PAGE": "Страница" + "PAGE": "Страница", + "SEARCH_VIEW": { + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "Инфо", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index abfcaeea6..1c4348bc1 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "Bilgi", "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 8ebe3bfda..f68db0fcf 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -241,7 +241,11 @@ "NO_SEARCH_RESULTS": "未找到任何内容,请尝试更改您的搜索请求" }, "REMOVED_FROM_FAVORITES": "已从收藏夹中删除", - "ADDED_TO_FAVORITES": "已添加至收藏夹" + "ADDED_TO_FAVORITES": "已添加至收藏夹", + "SEARCH_VIEW": { + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "ABOUT": { "TITLE": "关于 IPTVnator", From c253db4cef8a3da9332e186833659a78e3de5d1d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 19:18:05 +0100 Subject: [PATCH 02/10] feat: enhance search functionality with improved filtering and UI updates --- .../src/app/events/database/content.events.ts | 44 +++--- .../search-form/search-form.component.html | 14 +- .../search-form/search-form.component.ts | 7 + .../search-layout.component.html | 15 +- .../search-layout.component.scss | 7 +- .../search-result-item.component.ts | 139 ------------------ .../search-results.component.html | 99 ++++++++----- .../search-results.component.scss | 27 ++++ .../search-results.component.ts | 35 ++++- .../stores/features/with-search.feature.ts | 8 + 10 files changed, 192 insertions(+), 203 deletions(-) delete mode 100644 apps/web/src/app/shared/components/search-result-item/search-result-item.component.ts diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 571b4392f..78687e51c 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -243,12 +243,12 @@ 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 escapedTerm = escapeLikePattern(searchTerm); + const likePattern = `%${escapedTerm}%`; + // 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,11 +271,14 @@ ipcMain.handle( inArray( schema.content.type, types as Array<'live' | 'movie' | 'series'> - ) + ), + sql`${schema.content.title} LIKE ${likePattern}` ) - ); + ) + .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)) .slice(0, 50); @@ -296,12 +299,14 @@ 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 escapedTerm = escapeLikePattern(searchTerm); + const likePattern = `%${escapedTerm}%`; + // 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 +330,21 @@ 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'> + ), + sql`${schema.content.title} LIKE ${likePattern}` ) - ); + ) + .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)) .slice(0, 50); return result; diff --git a/apps/web/src/app/shared/components/search-form/search-form.component.html b/apps/web/src/app/shared/components/search-form/search-form.component.html index 87706979c..950999011 100644 --- a/apps/web/src/app/shared/components/search-form/search-form.component.html +++ b/apps/web/src/app/shared/components/search-form/search-form.component.html @@ -1,5 +1,9 @@
- + {{ placeholder }} - search + @if (searchTerm()) { + + } @else { + search + } @if (filterConfig.length > 0) { diff --git a/apps/web/src/app/shared/components/search-form/search-form.component.ts b/apps/web/src/app/shared/components/search-form/search-form.component.ts index dfca06ee7..6be4c3e4e 100644 --- a/apps/web/src/app/shared/components/search-form/search-form.component.ts +++ b/apps/web/src/app/shared/components/search-form/search-form.component.ts @@ -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(); } diff --git a/apps/web/src/app/shared/components/search-layout/search-layout.component.html b/apps/web/src/app/shared/components/search-layout/search-layout.component.html index 9a7d8dd54..5552bd103 100644 --- a/apps/web/src/app/shared/components/search-layout/search-layout.component.html +++ b/apps/web/src/app/shared/components/search-layout/search-layout.component.html @@ -17,7 +17,10 @@
@if (showResultsCount() && resultsCount() > 0) {
- Found {{ resultsCount() }} results + {{ + 'PORTALS.SEARCH_VIEW.RESULTS_FOUND' + | translate: { count: resultsCount() } + }}
} @if (showCloseButton()) { @@ -36,6 +39,9 @@ (searchTermChange)="onSearchTermChange($event)" />
+ + +
@@ -52,7 +58,12 @@

{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}

{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}

-

No results found for "{{ searchTerm() }}"

+

+ {{ + 'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR' + | translate: { term: searchTerm() } + }} +

} @else if (showInitialState) { diff --git a/apps/web/src/app/shared/components/search-layout/search-layout.component.scss b/apps/web/src/app/shared/components/search-layout/search-layout.component.scss index 21062809e..98c6901cf 100644 --- a/apps/web/src/app/shared/components/search-layout/search-layout.component.scss +++ b/apps/web/src/app/shared/components/search-layout/search-layout.component.scss @@ -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; diff --git a/apps/web/src/app/shared/components/search-result-item/search-result-item.component.ts b/apps/web/src/app/shared/components/search-result-item/search-result-item.component.ts deleted file mode 100644 index 9a914738c..000000000 --- a/apps/web/src/app/shared/components/search-result-item/search-result-item.component.ts +++ /dev/null @@ -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: ` - - @if (posterUrl) { - - } @else { -
- movie -
- } -

{{ title }}

- @if (description) { -

{{ description }}

- } - @if (showPlaylistInfo && playlistName) { -
- playlist_play - {{ playlistName }} -
- } -
- `, - 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(); -} \ No newline at end of file diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.html b/apps/web/src/app/xtream-tauri/search-results/search-results.component.html index 396682bc1..bd548cef8 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.html +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.html @@ -3,11 +3,11 @@ [resultsCount]="xtreamStore.searchResults().length" [isLoading]="xtreamStore.isSearching()" [showCloseButton]="isGlobalSearch" - [showResultsCount]="!isGlobalSearch" + [showResultsCount]="!isGlobalSearch || !groupByPlaylist()" (searchTermChange)="updateSearchTerm($event)" (closeClick)="onCloseDialog()" > - + @for (filter of filterConfig; track filter.key) { + + @if (isGlobalSearch) { +
+
+ info_outline + {{ 'PORTALS.SEARCH_VIEW.XTREAM_ONLY_NOTE' | translate }} +
+ + {{ 'PORTALS.SEARCH_VIEW.GROUP_BY_PLAYLIST' | translate }} + +
+ } +
+ @if (isGlobalSearch) { -
-

- Found {{ xtreamStore.searchResults().length }} items across - {{ (getGroupedResults() | keyvalue).length }} playlists -

- @for ( - group of getGroupedResults() | keyvalue; - track group.key - ) { -
-

- playlist_play - {{ group.key }} - ({{ $any(group).value.length }} items) -

-
- @for (item of $any(group).value; track item.id) { - - } + @if (groupByPlaylist()) { + +
+

+ Found {{ xtreamStore.searchResults().length }} items across + {{ (groupedResults() | keyvalue).length }} playlists +

+ @for ( + group of groupedResults() | keyvalue; + track group.key + ) { +
+

+ playlist_play + {{ group.key }} + ({{ $any(group).value.length }} items) +

+
+ @for (item of $any(group).value; track item.id) { + + } +
-
- } -
+ } +
+ } @else { + +
+ @for (item of xtreamStore.searchResults(); track item.id) { + + } +
+ } } @else {
diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss b/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss index d9aa5794e..3cfe3a269 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.scss @@ -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; diff --git a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts index 2cf9b967a..c1387c6cb 100644 --- a/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts +++ b/apps/web/src/app/xtream-tauri/search-results/search-results.component.ts @@ -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 ) { 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(); } } diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts index f7469da03..aa11df16e 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts @@ -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 */ From edcd7cb148c27fb3e8a2e5da5a22eddcaecede19 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 19:18:56 +0100 Subject: [PATCH 03/10] feat: improve global favorites handling and channel display logic in the UI --- .../favorites-layout.component.ts | 2 + libs/m3u-state/src/lib/effects.ts | 4 ++ .../m3u-utils/src/lib/playlist.utils.ts | 1 + .../channel-list-container.component.html | 6 +-- .../channel-list-container.component.ts | 43 ++++++++++++------- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts index 4750186c0..7e164f09f 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts @@ -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, diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 7f3610ebf..7a7097930 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -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) ), diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.ts index 7a7c7127f..ff82c3899 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.ts @@ -44,6 +44,7 @@ export function createFavoritesPlaylist( playlist: { items: channels, }, + favorites: channels.map((channel) => channel.url), filename: 'Global favorites', }; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 068b9c9b7..d165d02f5 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -1,4 +1,4 @@ -@if (_channelList) { +@if (displayedChannels().length) { @@ -9,7 +9,7 @@
()); @@ -72,6 +75,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Channels array */ _channelList: Channel[] = []; + private readonly channelListSignal = signal([]); private channelList$ = new BehaviorSubject([]); get channelList(): Channel[] { @@ -81,13 +85,28 @@ 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(() => + _.default.groupBy(this.displayedChannels(), 'group.title') + ); /** Selected channel */ readonly activeChannel = this.store.selectSignal(selectActive); @@ -105,7 +124,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ); /** Set of favorite channel URLs for quick lookup */ - readonly favoriteIds = signal>(new Set()); + private readonly _favorites = this.store.selectSignal(selectFavorites); + readonly favoriteIds = computed(() => new Set(this._favorites())); /** List with favorites */ favorites$ = combineLatest([ @@ -113,8 +133,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 +223,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 }) ); From d603c46a5a9822a1e64b65717d4b1fee70df4a11 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 19:19:15 +0100 Subject: [PATCH 04/10] feat: implement stalker live stream layout with EPG support - Added new component for live stream layout with sidebar and content area. - Integrated playlist switcher and category view for better navigation. - Implemented channel list with loading indicators and favorite functionality. - Added EPG (Electronic Program Guide) support for channels with loading and pagination. - Enhanced styles for responsive design and improved user experience. - Updated routing to use the new live stream layout component. - Modified stalker store to handle EPG fetching and search functionality. - Introduced new actions in the StalkerPortalActions enum for EPG operations. --- .../stalker-live-stream-layout.component.html | 168 ++++++++ .../stalker-live-stream-layout.component.scss | 275 ++++++++++++ .../stalker-live-stream-layout.component.ts | 390 ++++++++++++++++++ .../stalker-main-container.component.html | 113 ++--- .../stalker-main-container.component.ts | 253 +----------- apps/web/src/app/stalker/stalker.routes.ts | 6 +- apps/web/src/app/stalker/stalker.store.ts | 68 ++- .../src/lib/stalker-portal-actions.enum.ts | 2 + 8 files changed, 937 insertions(+), 338 deletions(-) create mode 100644 apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html create mode 100644 apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss create mode 100644 apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html new file mode 100644 index 000000000..9f4828f77 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -0,0 +1,168 @@ +@let isEmbeddedPlayer = + player() === 'videojs' || player() === 'html5' || player() === 'artplayer'; + + + +
+ @if (streamUrl && isEmbeddedPlayer) { +
+ +
+ } + @if (stalkerStore.selectedItem()) { +
+
+ + @if (isLoadingEpg()) { +
+ +
+ } + @if (hasMoreEpg() && !isLoadingEpg()) { +
+ +
+ } +
+
+ } @else { +
+ + {{ 'PORTALS.SELECT_CATEGORY' | translate }} +
+ } +
diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss new file mode 100644 index 000000000..7e266fb3d --- /dev/null +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts new file mode 100644 index 000000000..efa0cff30 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -0,0 +1,390 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + effect, + ElementRef, + inject, + NgZone, + OnDestroy, + signal, + viewChild, +} from '@angular/core'; +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([]); + 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(); + readonly currentProgramsProgress = new Map(); + readonly programTimings = new Map(); + private readonly requestedEpgChannels = new Set(); + private readonly cdr = inject(ChangeDetectorRef); + private readonly ngZone = inject(NgZone); + + /** Favorites */ + readonly favorites = new Map(); + + /** Scroll */ + readonly scrollContainer = viewChild('scrollContainer'); + private scrollListener: (() => void) | null = null; + private searchDebounceTimer: ReturnType | null = null; + + constructor() { + // Load favorites for current playlist + this.playlistService + .getPortalFavorites(this.stalkerStore.currentPlaylist()?._id) + .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; + } + } +} diff --git a/apps/web/src/app/stalker/stalker-main-container.component.html b/apps/web/src/app/stalker/stalker-main-container.component.html index 3bfd43f62..522c8811e 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.html +++ b/apps/web/src/app/stalker/stalker-main-container.component.html @@ -7,82 +7,34 @@ storageKey="sidebar-width" > - @if (isItvCategorySelected) { -
- - @for (item of itvChannels(); track $index) { - - logo - {{ item.o_name || item.name }} - - - } - - - - @if (isLoadingMore()) { -
- -
- } -
+ @if (isCategoryLoading()) { + + } @else if (isCategoryFailed()) { + } @else { - @if (isCategoryLoading()) { - - } @else if (isCategoryFailed()) { - - } @else { - - } + }
@@ -90,15 +42,11 @@ @if (!stalkerStore.selectedItem()) {

- @if (!isItvCategorySelected) { - - } + - @if (!isItvCategorySelected) { - {{ getContentLabel() }} - } + {{ getContentLabel() }} @if (!contentItems() && !isContentLoading()) {

@@ -115,5 +63,4 @@ >

- diff --git a/apps/web/src/app/stalker/stalker-main-container.component.ts b/apps/web/src/app/stalker/stalker-main-container.component.ts index 625679626..0b264f153 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.ts +++ b/apps/web/src/app/stalker/stalker-main-container.component.ts @@ -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(); - - /* load more functionality */ - readonly hasMoreItems = this.stalkerStore.hasMoreChannels; - readonly itvChannels = this.stalkerStore.itvChannels; - - /** Scrollable container for ITV channels */ - readonly itvScrollContainer = viewChild('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'; } diff --git a/apps/web/src/app/stalker/stalker.routes.ts b/apps/web/src/app/stalker/stalker.routes.ts index 20c5f2355..ea0bb9e63 100644 --- a/apps/web/src/app/stalker/stalker.routes.ts +++ b/apps/web/src/app/stalker/stalker.routes.ts @@ -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', diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index ba9f08add..897488678 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -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 = { 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 { + const playlist = store.currentPlaylist() as Playlist; + if (!playlist) return []; + + const queryParams: Record = { + 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 ?? '' + ), + })); + }, }) ) ); diff --git a/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts b/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts index 905c72f30..73d5a2f60 100644 --- a/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts +++ b/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts @@ -6,4 +6,6 @@ export enum StalkerPortalActions { Favorites = 'favorites', Handshake = 'handshake', DoAuth = 'do_auth', + GetShortEpg = 'get_short_epg', + GetEpgInfo = 'get_epg_info', } From 007317dacf03b339800f76832485026e4d338fa6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 19:19:25 +0100 Subject: [PATCH 05/10] feat: add search view localization with results found and no results messages --- apps/web/src/assets/i18n/zhtw.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index c7c45f317..02b8d1583 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -324,7 +324,11 @@ }, "STREAM_URL_COPIED": "串流網址已複製到剪貼簿", "COPY_STREAM_URL": "複製串流網址", - "SELECT_CATEGORY": "選擇類別" + "SELECT_CATEGORY": "選擇類別", + "SEARCH_VIEW": { + "RESULTS_FOUND": "Found {{count}} results", + "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + } }, "INFORMATION": "資訊", "REMOTE_CONTROL": { From c98644dfe4b1c72e72cd611056c8c40bec2d3d6c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 19:19:39 +0100 Subject: [PATCH 06/10] style: enhance thumbnail wrapper styles with border radius and overflow properties --- apps/web/src/app/shared/styles/_content-grid.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/app/shared/styles/_content-grid.scss b/apps/web/src/app/shared/styles/_content-grid.scss index 0b94e1e3c..eff0a43ba 100644 --- a/apps/web/src/app/shared/styles/_content-grid.scss +++ b/apps/web/src/app/shared/styles/_content-grid.scss @@ -23,6 +23,8 @@ @mixin thumbnail-wrapper { position: relative; + border-radius: 8px; + overflow: hidden; .poster { width: 100%; From 90e4602530d69c0a1683a441ed1f464b07b90108 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 20:23:41 +0100 Subject: [PATCH 07/10] feat: update search view localization for multiple languages with results found and no results messages --- apps/web/src/assets/i18n/ar.json | 4 ++-- apps/web/src/assets/i18n/ary.json | 4 ++-- apps/web/src/assets/i18n/by.json | 4 ++-- apps/web/src/assets/i18n/de.json | 4 ++-- apps/web/src/assets/i18n/el.json | 4 ++-- apps/web/src/assets/i18n/es.json | 4 ++-- apps/web/src/assets/i18n/fr.json | 4 ++-- apps/web/src/assets/i18n/it.json | 4 ++-- apps/web/src/assets/i18n/ja.json | 4 ++-- apps/web/src/assets/i18n/ko.json | 4 ++-- apps/web/src/assets/i18n/nl.json | 4 ++-- apps/web/src/assets/i18n/pl.json | 4 ++-- apps/web/src/assets/i18n/pt.json | 4 ++-- apps/web/src/assets/i18n/ru.json | 4 ++-- apps/web/src/assets/i18n/tr.json | 4 ++-- apps/web/src/assets/i18n/zh.json | 4 ++-- apps/web/src/assets/i18n/zhtw.json | 4 ++-- 17 files changed, 34 insertions(+), 34 deletions(-) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ddfb2b9f0..48ce4a72b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -316,8 +316,8 @@ "STREAM_URL_COPIED": "تم نسخ رابط البث إلى الحافظة", "COPY_STREAM_URL": "نسخ رابط البث", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "تم العثور على {{count}} نتيجة", + "NO_RESULTS_FOR": "لم يتم العثور على نتائج لـ \"{{term}}\"" } }, "INFORMATION": "معلومات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 037b6ae57..2ffeaf4ae 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -308,8 +308,8 @@ "STREAM_URL_COPIED": "رابط البث تنقل للحافظة", "COPY_STREAM_URL": "نسخ رابط البث", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "تلقاو {{count}} ديال النتايج", + "NO_RESULTS_FOR": "ما تلقاو حتى نتيجة لـ \"{{term}}\"" } }, "INFORMATION": "معلومات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 874b14772..525d59ef5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -283,8 +283,8 @@ "REMOVED_FROM_FAVORITES": "Выдалена з выбранага", "ADDED_TO_FAVORITES": "Дададзена ў выбранае", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "Знойдзена {{count}} вынікаў", + "NO_RESULTS_FOR": "Не знойдзена вынікаў для \"{{term}}\"" } }, "INFORMATION": "Інфармацыя" diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index cdceda05a..899af8e6f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -383,8 +383,8 @@ "SELECT_CATEGORY": "Wählen Sie eine Kategorie aus", "PAGE": "Seite", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "{{count}} Ergebnisse gefunden", + "NO_RESULTS_FOR": "Keine Ergebnisse für \"{{term}}\" gefunden" } }, "ABOUT": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 4f2b8d968..8a65f6060 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -427,8 +427,8 @@ "SEARCH_VIEW": { "INITIAL_TITLE": "Αναζήτηση περιεχομένου", "INITIAL_DESCRIPTION": "Εισαγάγετε τουλάχιστον 3 χαρακτήρες για αναζήτηση σε ζωντανές τηλεοπτικές μεταδόσεις, ταινίες και σειρές.", - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "Βρέθηκαν {{count}} αποτελέσματα", + "NO_RESULTS_FOR": "Δεν βρέθηκαν αποτελέσματα για \"{{term}}\"" }, "STREAM_URL_COPIED": "Η διεύθυνση URL ροής αντιγράφηκε στο πρόχειρο", "COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index fda3cc5ad..14e8b2d0a 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -273,8 +273,8 @@ "REMOVED_FROM_FAVORITES": "Eliminado de favoritos", "ADDED_TO_FAVORITES": "Añadido a favoritos", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "Se encontraron {{count}} resultados", + "NO_RESULTS_FOR": "No se encontraron resultados para \"{{term}}\"" } }, "ABOUT": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index bda08056b..525a76111 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -349,8 +349,8 @@ "COPY_STREAM_URL": "Copier l'URL du flux", "SELECT_CATEGORY": "Sélectionner une catégorie", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "{{count}} résultats trouvés", + "NO_RESULTS_FOR": "Aucun résultat trouvé pour \"{{term}}\"" } }, "INFORMATION": "Information", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e0fe90df5..c8778c4a9 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -306,8 +306,8 @@ "STREAM_URL_COPIED": "URL streaming copiato negli appunti", "COPY_STREAM_URL": "Copia URL streaming", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "Trovati {{count}} risultati", + "NO_RESULTS_FOR": "Nessun risultato trovato per \"{{term}}\"" } }, "INFORMATION": "Informazioni", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 185814827..f58601345 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -296,8 +296,8 @@ "STREAM_URL_COPIED": "ストリームURLがクリップボードにコピーされました", "COPY_STREAM_URL": "ストリームURLをコピー", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "{{count}}件の結果が見つかりました", + "NO_RESULTS_FOR": "「{{term}}」に一致する結果は見つかりませんでした" } }, "INFORMATION": "情報", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 69fd4dd45..fbdcc2828 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -244,8 +244,8 @@ "REMOVED_FROM_FAVORITES": "즐겨찾기에서 삭제됨", "ADDED_TO_FAVORITES": "즐겨찾기에 추가됨", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "{{count}}개의 결과를 찾았습니다", + "NO_RESULTS_FOR": "\"{{term}}\"에 대한 결과를 찾을 수 없습니다" } }, "ABOUT": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index cd9fab042..017d768bb 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -308,8 +308,8 @@ "STREAM_URL_COPIED": "Stream URL Gekopieërd", "COPY_STREAM_URL": "Kopieër Stream URL", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "{{count}} resultaten gevonden", + "NO_RESULTS_FOR": "Geen resultaten gevonden voor \"{{term}}\"" } }, "INFORMATION": "Informatie", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 9e28f36a5..678b5732f 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -308,8 +308,8 @@ "STREAM_URL_COPIED": "URL strumienia skopiowany do schowka", "COPY_STREAM_URL": "Kopiuj URL strumienia", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "Znaleziono {{count}} wyników", + "NO_RESULTS_FOR": "Brak wyników dla „{{term}}”" } }, "INFORMATION": "Informacje", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 27d970fe5..73ce9191f 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -346,8 +346,8 @@ "COPY_STREAM_URL": "Copiar URL do Stream", "SELECT_CATEGORY": "Selecione uma categoria", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "{{count}} resultados encontrados", + "NO_RESULTS_FOR": "Nenhum resultado encontrado para \"{{term}}\"" } }, "INFORMATION": "Informação", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 57da9f489..0fb0cb522 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -393,8 +393,8 @@ "SELECT_CATEGORY": "Выберите категорию", "PAGE": "Страница", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "Найдено {{count}} результатов", + "NO_RESULTS_FOR": "Нет результатов по запросу \"{{term}}\"" } }, "INFORMATION": "Инфо", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 1c4348bc1..36d5991ca 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -296,8 +296,8 @@ "STREAM_URL_COPIED": "Yayın URL'si panoya kopyalandı", "COPY_STREAM_URL": "Yayın URL'sini kopyala", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "{{count}} sonuç bulundu", + "NO_RESULTS_FOR": "\"{{term}}\" için sonuç bulunamadı" } }, "INFORMATION": "Bilgi", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index f68db0fcf..675944921 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -243,8 +243,8 @@ "REMOVED_FROM_FAVORITES": "已从收藏夹中删除", "ADDED_TO_FAVORITES": "已添加至收藏夹", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "找到 {{count}} 条结果", + "NO_RESULTS_FOR": "未找到与“{{term}}”匹配的结果" } }, "ABOUT": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 02b8d1583..b1f9769ec 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -326,8 +326,8 @@ "COPY_STREAM_URL": "複製串流網址", "SELECT_CATEGORY": "選擇類別", "SEARCH_VIEW": { - "RESULTS_FOUND": "Found {{count}} results", - "NO_RESULTS_FOR": "No results found for \"{{term}}\"" + "RESULTS_FOUND": "找到 {{count}} 筆結果", + "NO_RESULTS_FOR": "找不到與「{{term}}」相符的結果" } }, "INFORMATION": "資訊", From 3e7c58b0491b629bacf349bcc9fdd41d05dd9c1e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 20:23:48 +0100 Subject: [PATCH 08/10] refactor: replace lodash import with specific groupBy function for improved performance --- .../channel-list-container.component.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index baf337d88..654b15558 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -14,7 +14,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe } from '@ngx-translate/core'; -import * as _ from 'lodash'; +import groupBy from 'lodash/groupBy'; import { ChannelActions, FavoritesActions, @@ -91,8 +91,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { } /** Active playlist ID as signal */ - private readonly activePlaylistIdSignal = - this.store.selectSignal(selectActivePlaylistId); + private readonly activePlaylistIdSignal = this.store.selectSignal( + selectActivePlaylistId + ); /** Displayed channels - filters out unfavorited channels in global favorites view */ readonly displayedChannels = computed(() => { @@ -105,7 +106,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Object with channels sorted by groups */ readonly groupedChannels = computed(() => - _.default.groupBy(this.displayedChannels(), 'group.title') + groupBy(this.displayedChannels(), 'group.title') ); /** Selected channel */ From 8900ee18346dc4a4af3f82343d7b6709b78773da Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 20:24:01 +0100 Subject: [PATCH 09/10] refactor: implement automatic unsubscription for favorites loading in StalkerLiveStreamLayout --- .../stalker-live-stream-layout.component.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index efa0cff30..c0fc84f76 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -10,6 +10,7 @@ import { 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'; @@ -111,6 +112,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { // 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); From e04ae0e738fae9ceb83d80a3450fab62b4c674cc Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 20:24:18 +0100 Subject: [PATCH 10/10] feat: enhance search functionality with improved LIKE pattern matching for non-ASCII characters --- .../src/app/events/database/content.events.ts | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 78687e51c..e863a65e7 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -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(); + 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,9 +262,11 @@ ipcMain.handle( async (event, playlistId: string, searchTerm: string, types: string[]) => { try { const db = await getDatabase(); - const searchTermLower = searchTerm.toLowerCase(); - const escapedTerm = escapeLikePattern(searchTerm); - const likePattern = `%${escapedTerm}%`; + 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. @@ -272,7 +293,7 @@ ipcMain.handle( schema.content.type, types as Array<'live' | 'movie' | 'series'> ), - sql`${schema.content.title} LIKE ${likePattern}` + or(...likeConditions) ) ) .limit(200); @@ -280,7 +301,9 @@ ipcMain.handle( // 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; @@ -299,9 +322,11 @@ ipcMain.handle( async (event, searchTerm: string, types: string[]) => { try { const db = await getDatabase(); - const searchTermLower = searchTerm.toLowerCase(); - const escapedTerm = escapeLikePattern(searchTerm); - const likePattern = `%${escapedTerm}%`; + 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. @@ -335,7 +360,7 @@ ipcMain.handle( schema.content.type, types as Array<'live' | 'movie' | 'series'> ), - sql`${schema.content.title} LIKE ${likePattern}` + or(...likeConditions) ) ) .orderBy(schema.content.title) @@ -344,7 +369,9 @@ ipcMain.handle( // 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;