feat(xtream): sort and filter the VOD/series catalog by IMDb rating

Adds IMDb rating sort and minimum-rating filtering for Xtream VOD/series catalogs, consolidates refinement controls, and guards rating controls away from Live TV.
This commit is contained in:
4gray authored and GitHub committed 2026-06-20 13:50:56 +02:00
1 parent 2f27895e6b
commit ffc07146fe
29 files changed
+1004 -76

No files matched your search

@@ -193,7 +193,10 @@ test.describe('Electron Catalog Sorting', () => {
await waitForStalkerCatalog(app.mainWindow);
await expect(
app.mainWindow.getByRole('button', { name: 'Sort content' })
app.mainWindow.getByRole('button', {
name: 'Refine',
exact: true,
})
).toHaveCount(0);
await expect(
app.mainWindow.getByRole('button', { name: 'Sort channels' })
@@ -201,12 +204,18 @@ test.describe('Electron Catalog Sorting', () => {
await openWorkspaceSection(app.mainWindow, 'Movies');
await expect(
app.mainWindow.getByRole('button', { name: 'Sort content' })
app.mainWindow.getByRole('button', {
name: 'Refine',
exact: true,
})
).toHaveCount(0);
await openWorkspaceSection(app.mainWindow, 'Series');
await expect(
app.mainWindow.getByRole('button', { name: 'Sort content' })
app.mainWindow.getByRole('button', {
name: 'Refine',
exact: true,
})
).toHaveCount(0);
} finally {
await closeElectronApp(app);
@@ -371,7 +380,7 @@ async function setContentSortMode(
| 'Name A-Z'
| 'Name Z-A'
): Promise<void> {
await page.getByRole('button', { name: 'Sort content' }).click();
await page.getByRole('button', { name: 'Refine', exact: true }).click();
await page.getByRole('menuitem', { name: label, exact: true }).click();
}
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "تاريخ الإضافة (الأقدم أولاً)",
"SORT_NAME_ASC": "الاسم (أ-ي)",
"SORT_NAME_DESC": "الاسم (ي-أ)",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "ترتيب: ",
"SORT_SERVER": "ترتيب الخادم",
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "تاريخ الإضافة (الأقدم أولاً)",
"SORT_NAME_ASC": "الاسم أ-ي",
"SORT_NAME_DESC": "الاسم ي-أ",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "ترتيب: ",
"SORT_SERVER": "ترتيب السيرفر",
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Дата дадання (спачатку старыя)",
"SORT_NAME_ASC": "Назва А-Я",
"SORT_NAME_DESC": "Назва Я-А",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Сартаваць: ",
"SORT_SERVER": "Парадак сервера",
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Datum (Älteste zuerst)",
"SORT_NAME_ASC": "Name A-Z",
"SORT_NAME_DESC": "Name Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Sortierung: ",
"SORT_SERVER": "Serverreihenfolge",
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Ημερομηνία προσθήκης (Πρώτα τα παλαιότερα)",
"SORT_NAME_ASC": "Όνομα A-Z",
"SORT_NAME_DESC": "Όνομα Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Ταξινόμηση: ",
"SORT_SERVER": "Σειρά διακομιστή",
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Date Added (Oldest First)",
"SORT_NAME_ASC": "Name A-Z",
"SORT_NAME_DESC": "Name Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Sort: ",
"SORT_SERVER": "Server sorting",
"CATEGORY_SORT_ARIA": "Sort categories",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Fecha de adición (más antiguas primero)",
"SORT_NAME_ASC": "Nombre A-Z",
"SORT_NAME_DESC": "Nombre Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Ordenar: ",
"SORT_SERVER": "Orden del servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorías",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Date d'ajout (plus anciens en premier)",
"SORT_NAME_ASC": "Nom A-Z",
"SORT_NAME_DESC": "Nom Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Trier : ",
"SORT_SERVER": "Ordre du serveur",
"CATEGORY_SORT_ARIA": "Trier les catégories",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Data aggiunta (prima i più vecchi)",
"SORT_NAME_ASC": "Nome A-Z",
"SORT_NAME_DESC": "Nome Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Ordina: ",
"SORT_SERVER": "Ordine del server",
"CATEGORY_SORT_ARIA": "Ordina categorie",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "追加日(古い順)",
"SORT_NAME_ASC": "名前 A-Z",
"SORT_NAME_DESC": "名前 Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "並び替え:",
"SORT_SERVER": "サーバー順",
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "추가 날짜 (오래된순)",
"SORT_NAME_ASC": "이름 A-Z",
"SORT_NAME_DESC": "이름 Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "정렬: ",
"SORT_SERVER": "서버 순서",
"CATEGORY_SORT_ARIA": "카테고리 정렬",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Datum toegevoegd (oudste eerst)",
"SORT_NAME_ASC": "Naam A-Z",
"SORT_NAME_DESC": "Naam Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Sorteren: ",
"SORT_SERVER": "Servervolgorde",
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Data dodania (najstarsze pierwsze)",
"SORT_NAME_ASC": "Nazwa A-Z",
"SORT_NAME_DESC": "Nazwa Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Sortuj: ",
"SORT_SERVER": "Kolejność serwera",
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Data de adição (mais antigo primeiro)",
"SORT_NAME_ASC": "Nome A-Z",
"SORT_NAME_DESC": "Nome Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Ordenar: ",
"SORT_SERVER": "Ordem do servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorias",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Дата добавления (Сначала старые)",
"SORT_NAME_ASC": "Имя (А-Я)",
"SORT_NAME_DESC": "Имя (Я-А)",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Сортировка: ",
"SORT_SERVER": "Порядок сервера",
"CATEGORY_SORT_ARIA": "Сортировать категории",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "Eklenme tarihi (En eski önce)",
"SORT_NAME_ASC": "İsim A-Z",
"SORT_NAME_DESC": "İsim Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "Sırala: ",
"SORT_SERVER": "Sunucu sıralaması",
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "添加日期(最旧优先)",
"SORT_NAME_ASC": "名称 A-Z",
"SORT_NAME_DESC": "名称 Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "排序:",
"SORT_SERVER": "服务器排序",
"CATEGORY_SORT_ARIA": "排序分类",
+9
View File
@@ -875,6 +875,15 @@
"SORT_DATE_ASC": "新增日期(最舊優先)",
"SORT_NAME_ASC": "名稱 A-Z",
"SORT_NAME_DESC": "名稱 Z-A",
"SORT_TOP_RATED": "Top rated",
"SORT_LOWEST_RATED": "Lowest rated first",
"REFINE_ACTION": "Refine",
"REFINE_SORT_SECTION": "Sort by",
"REFINE_RATING_SECTION": "Minimum rating",
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
"FILTER_RATING": "Filter by rating",
"FILTER_RATING_ANY": "Any rating",
"FILTER_RATING_MIN": "{{value}}.0 and higher",
"SORT_LABEL": "排序:",
"SORT_SERVER": "伺服器排序",
"CATEGORY_SORT_ARIA": "排序分類",
@@ -12,62 +12,182 @@
{{ categoryItemSubtitle() }}
</div>
</div>
@if (canSortContent()) {
@if (hasRefineControls()) {
<div class="active-refinements" aria-live="polite">
@if (canSortContent()) {
<div
class="refinement-chip sort-refinement-chip"
[attr.aria-label]="
('WORKSPACE.SORT_LABEL' | translate) +
(activeSortLabelKey() | translate)
"
>
<mat-icon>sort</mat-icon>
<span
class="refinement-chip-label refinement-chip-label-full"
>
{{ 'WORKSPACE.SORT_LABEL' | translate
}}{{ activeSortLabelKey() | translate }}
</span>
<span
class="refinement-chip-label refinement-chip-label-compact"
>
{{ activeSortLabelKey() | translate }}
</span>
</div>
}
@if (minRating(); as rating) {
<button
type="button"
mat-button
class="refinement-chip rating-refinement-chip"
(click)="setMinRating(null)"
[attr.aria-label]="
'WORKSPACE.REFINE_CLEAR_RATING'
| translate: { value: rating }
"
>
<mat-icon>star</mat-icon>
<span
class="refinement-chip-label refinement-chip-label-full"
>
{{ 'WORKSPACE.FILTER_RATING' | translate }}:
{{ rating }}.0+
</span>
<span
class="refinement-chip-label refinement-chip-label-compact"
>
{{ rating }}.0+
</span>
<mat-icon>close</mat-icon>
</button>
}
</div>
<button
type="button"
mat-icon-button
class="sort-action"
[matMenuTriggerFor]="sortContentMenu"
matTooltip="Sort content"
aria-label="Sort content"
mat-button
class="refine-action"
[class.refine-action-active]="activeRefinementCount() > 0"
[matMenuTriggerFor]="refineMenu"
[matTooltip]="'WORKSPACE.REFINE_ACTION' | translate"
[attr.aria-label]="'WORKSPACE.REFINE_ACTION' | translate"
>
<mat-icon>sort</mat-icon>
<mat-icon>tune</mat-icon>
<span>{{ 'WORKSPACE.REFINE_ACTION' | translate }}</span>
@if (activeRefinementCount() > 0) {
<span class="refine-count">{{
activeRefinementCount()
}}</span>
}
</button>
<mat-menu #sortContentMenu="matMenu">
<button
mat-menu-item
(click)="setContentSortMode('date-desc')"
>
<span>{{
'WORKSPACE.SORT_DATE_DESC' | translate
}}</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
<mat-menu #refineMenu="matMenu" class="refine-menu">
@if (canSortContent()) {
<div class="refine-menu-section-title">
{{ 'WORKSPACE.REFINE_SORT_SECTION' | translate }}
</div>
<button
mat-menu-item
(click)="setContentSortMode('date-desc')"
>
<span>{{
'WORKSPACE.SORT_DATE_DESC' | translate
}}</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('date-asc')"
>
<span>{{
'WORKSPACE.SORT_DATE_ASC' | translate
}}</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-asc')"
>
<span>{{
'WORKSPACE.SORT_NAME_ASC' | translate
}}</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-desc')"
>
<span>{{
'WORKSPACE.SORT_NAME_DESC' | translate
}}</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
@if (supportsRatingSort()) {
<button
mat-menu-item
(click)="setContentSortMode('rating-desc')"
>
<span>{{
'WORKSPACE.SORT_TOP_RATED' | translate
}}</span>
@if (contentSortMode() === 'rating-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('rating-asc')"
>
<span>{{
'WORKSPACE.SORT_LOWEST_RATED'
| translate
}}</span>
@if (contentSortMode() === 'rating-asc') {
<mat-icon>check</mat-icon>
}
</button>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('date-asc')"
>
<span>{{
'WORKSPACE.SORT_DATE_ASC' | translate
}}</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
@if (canFilterByRating()) {
@if (canSortContent()) {
<div class="refine-menu-divider"></div>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-asc')"
>
<span>{{
'WORKSPACE.SORT_NAME_ASC' | translate
}}</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
<div class="refine-menu-section-title">
{{
'WORKSPACE.REFINE_RATING_SECTION'
| translate
}}
</div>
<button mat-menu-item (click)="setMinRating(null)">
<span>{{
'WORKSPACE.FILTER_RATING_ANY' | translate
}}</span>
@if (minRating() === null) {
<mat-icon>check</mat-icon>
}
</button>
@for (threshold of ratingThresholds; track threshold) {
<button
mat-menu-item
(click)="setMinRating(threshold)"
>
<span>{{
'WORKSPACE.FILTER_RATING_MIN'
| translate: { value: threshold }
}}</span>
@if (minRating() === threshold) {
<mat-icon>check</mat-icon>
}
</button>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-desc')"
>
<span>{{
'WORKSPACE.SORT_NAME_DESC' | translate
}}</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
}
</mat-menu>
}
@if (categoryItemCount() > 0) {
@@ -14,16 +14,27 @@
display: flex;
flex-direction: column;
overflow: hidden;
container: category-content / inline-size;
}
// ─── Sticky header bar — styled like a widget header ─────────────────────────
.category-content-header {
@include panel.standard-panel-header($sticky: true);
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, auto) auto auto;
grid-template-areas: 'meta refinements refine paginator';
align-items: center;
justify-content: stretch;
gap: 8px 12px;
}
// ─── Category meta (title + subtitle) ────────────────────────────────────────
.category-meta {
@include panel.standard-panel-meta();
grid-area: meta;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.category-title {
@@ -32,18 +43,200 @@
.category-subtitle {
@include panel.standard-panel-subtitle();
padding: 0;
border-radius: 0;
line-height: 1.2;
background: transparent;
}
// ─── Refine controls — show current sort/filter state compactly ──────────────
.active-refinements {
grid-area: refinements;
justify-self: end;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex: 0 1 auto;
min-width: 0;
max-width: min(430px, 36cqi);
flex-wrap: wrap;
app-region: no-drag;
}
.refinement-chip,
.refine-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-shrink: 0;
min-height: 32px;
border-radius: 999px;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
--mdc-text-button-label-text-color: currentColor;
--mat-text-button-state-layer-color: currentColor;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
}
.refinement-chip {
max-width: 220px;
padding: 0 10px;
background: var(--mat-sys-surface-container);
border: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.78rem;
font-weight: 600;
}
.refinement-chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.refinement-chip-label-compact {
display: none;
}
@container category-content (max-width: 1120px) {
.refinement-chip-label-full {
display: none;
}
.refinement-chip-label-compact {
display: inline;
}
}
.sort-refinement-chip {
color: var(--mat-sys-on-surface-variant);
cursor: default;
user-select: none;
}
.rating-refinement-chip,
.refine-action.refine-action-active {
color: var(--mat-sys-primary);
}
.refine-action {
grid-area: refine;
justify-self: end;
padding: 0 12px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
app-region: no-drag;
}
.refine-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
line-height: 1;
color: var(--mat-sys-on-primary);
background: var(--mat-sys-primary);
}
.refine-menu-section-title {
padding: 8px 16px 4px;
font-size: 0.72rem;
font-weight: 700;
line-height: 1.2;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.refine-menu-divider {
height: 1px;
margin: 6px 12px;
background: rgba(255, 255, 255, 0.08);
}
// ─── Paginator — flush right, no ghost background ────────────────────────────
mat-paginator {
grid-area: paginator;
justify-self: end;
max-width: 100%;
background: transparent !important;
flex-shrink: 0;
// Shrink paginator text to fit header height comfortably
--mat-paginator-container-size: 40px;
--mat-paginator-enabled-icon-color: var(--app-body-color, var(--mat-sys-on-surface-variant));
--mat-paginator-enabled-icon-color: var(
--app-body-color,
var(--mat-sys-on-surface-variant)
);
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
}
@container category-content (max-width: 920px) {
.category-content-header {
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas:
'meta paginator paginator'
'refinements refinements refine';
padding: 8px 16px;
min-height: unset;
}
.active-refinements {
justify-self: stretch;
flex: 1 1 auto;
justify-content: flex-start;
max-width: none;
}
.refinement-chip {
max-width: min(220px, 100%);
}
.refine-action {
justify-self: end;
}
}
@container category-content (max-width: 640px) {
.category-content-header {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'meta meta'
'refinements refine'
'paginator paginator';
}
.category-meta {
width: 100%;
}
.active-refinements {
width: 100%;
}
.refinement-chip {
max-width: min(220px, calc(100cqi - 160px));
}
.refine-action {
justify-self: end;
}
mat-paginator {
justify-self: start;
}
}
// ─── Grid scroll area ─────────────────────────────────────────────────────────
app-grid-list {
flex: 1;
@@ -56,19 +249,16 @@ app-grid-list {
}
@media (max-width: 768px) {
.category-content-header {
flex-direction: column;
align-items: flex-start;
padding: 10px 16px;
min-height: unset;
}
.category-meta {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.active-refinements {
justify-content: flex-start;
}
app-grid-list {
padding: 12px 16px 16px;
}
@@ -2,7 +2,7 @@ import { Component, input, output, signal } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatIconButton } from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
@@ -61,6 +61,7 @@ describe('CategoryContentViewComponent', () => {
const isPaginatedContentLoading = signal(true);
const categoryItemCount = signal(0);
const contentSortMode = signal<PortalCatalogSortMode | null>(null);
const minRating = signal<number | null>(null);
const catalog = {
provider: 'xtream' as const,
pageSizeOptions: [10, 25, 50],
@@ -74,6 +75,8 @@ describe('CategoryContentViewComponent', () => {
selectedItem: signal(null),
totalPages: signal(0),
contentSortMode,
supportsRatingSort: true,
minRating,
playlist: signal(null),
isPaginatedContentLoading,
initialize: jest.fn(),
@@ -82,6 +85,7 @@ describe('CategoryContentViewComponent', () => {
setPage: jest.fn(),
setLimit: jest.fn(),
setContentSortMode: jest.fn(),
setMinRating: jest.fn(),
selectItem: jest.fn().mockReturnValue(null),
getItemProgress: jest.fn().mockReturnValue({}),
};
@@ -90,10 +94,14 @@ describe('CategoryContentViewComponent', () => {
isPaginatedContentLoading.set(true);
categoryItemCount.set(0);
contentSortMode.set(null);
catalog.supportsRatingSort = true;
minRating.set(null);
catalog.initialize.mockClear();
catalog.setSearchQuery.mockClear();
catalog.setPage.mockClear();
catalog.setLimit.mockClear();
catalog.setContentSortMode.mockClear();
catalog.setMinRating.mockClear();
catalog.selectItem.mockClear();
catalog.selectItem.mockReturnValue(null);
router = {
@@ -162,7 +170,7 @@ describe('CategoryContentViewComponent', () => {
MockGridListComponent,
MockPlaylistErrorViewComponent,
MatIcon,
MatIconButton,
MatButtonModule,
MatMenuModule,
MatPaginatorModule,
MatTooltip,
@@ -201,6 +209,107 @@ describe('CategoryContentViewComponent', () => {
expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix');
});
it('groups catalog sort and rating filters behind one refine menu trigger', () => {
contentSortMode.set('date-desc');
categoryItemCount.set(12);
fixture.detectChanges();
const refineButton = fixture.nativeElement.querySelector(
'.refine-action'
) as HTMLButtonElement | null;
const sortChip = fixture.nativeElement.querySelector(
'.sort-refinement-chip'
) as HTMLElement | null;
expect(refineButton).not.toBeNull();
expect(sortChip).not.toBeNull();
expect(sortChip?.tagName).not.toBe('BUTTON');
expect(
fixture.nativeElement.querySelector('.sort-action')
).toBeNull();
expect(
fixture.nativeElement.querySelector('.rating-filter-action')
).toBeNull();
refineButton?.click();
fixture.detectChanges();
const overlayText = document.body.textContent ?? '';
expect(overlayText).toContain('WORKSPACE.REFINE_SORT_SECTION');
expect(overlayText).toContain('WORKSPACE.SORT_DATE_DESC');
expect(overlayText).toContain('WORKSPACE.REFINE_RATING_SECTION');
expect(overlayText).toContain('WORKSPACE.FILTER_RATING_ANY');
});
it('shows active sort and rating chips and lets the rating chip clear the threshold', () => {
contentSortMode.set('rating-desc');
minRating.set(8);
categoryItemCount.set(12);
fixture.detectChanges();
const sortChip = fixture.nativeElement.querySelector(
'.sort-refinement-chip'
) as HTMLElement | null;
const ratingChip = fixture.nativeElement.querySelector(
'.rating-refinement-chip'
) as HTMLButtonElement | null;
expect(sortChip?.textContent).toContain('WORKSPACE.SORT_TOP_RATED');
expect(ratingChip?.textContent).toContain('8');
ratingChip?.click();
expect(catalog.setMinRating).toHaveBeenCalledWith(null);
});
it('hides rating refinements when the catalog facade does not support rating sorting', () => {
catalog.supportsRatingSort = false;
contentSortMode.set('date-desc');
minRating.set(9);
categoryItemCount.set(12);
fixture.detectChanges();
expect(fixture.componentInstance.supportsRatingSort()).toBe(false);
expect(fixture.componentInstance.canFilterByRating()).toBe(false);
expect(fixture.componentInstance.minRating()).toBeNull();
expect(
fixture.nativeElement.querySelector('.rating-refinement-chip')
).toBeNull();
});
it('renders full and compact refinement chip labels for responsive layouts', () => {
contentSortMode.set('name-asc');
minRating.set(9);
categoryItemCount.set(12);
fixture.detectChanges();
const sortChip = fixture.nativeElement.querySelector(
'.sort-refinement-chip'
) as HTMLElement | null;
const ratingChip = fixture.nativeElement.querySelector(
'.rating-refinement-chip'
) as HTMLElement | null;
expect(
sortChip?.querySelector('.refinement-chip-label-full')?.textContent
).toContain('WORKSPACE.SORT_LABEL');
expect(
sortChip?.querySelector('.refinement-chip-label-compact')
?.textContent
).toContain('WORKSPACE.SORT_NAME_ASC');
expect(
ratingChip?.querySelector('.refinement-chip-label-full')?.textContent
).toContain('WORKSPACE.FILTER_RATING');
expect(
ratingChip?.querySelector('.refinement-chip-label-compact')
?.textContent
).toContain('9.0+');
});
it('restores the zero-based catalog page from the one-based page query param', () => {
fixture.detectChanges();
catalog.setPage.mockClear();
@@ -10,7 +10,7 @@ import {
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { MatIconButton } from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
@@ -48,8 +48,8 @@ interface CategoryContentItem {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GridListComponent,
MatButtonModule,
MatIcon,
MatIconButton,
MatMenuModule,
MatPaginatorModule,
MatTooltip,
@@ -103,6 +103,42 @@ export class CategoryContentViewComponent implements OnInit {
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
});
readonly canSortContent = computed(() => this.contentSortMode() !== null);
readonly supportsRatingSort = computed(
() => this.catalog.supportsRatingSort === true
);
readonly canFilterByRating = computed(
() =>
this.supportsRatingSort() &&
typeof this.catalog.setMinRating === 'function'
);
readonly minRating = computed(() =>
this.canFilterByRating() ? (this.catalog.minRating?.() ?? null) : null
);
readonly ratingThresholds = [9, 8, 7, 6, 5] as const;
readonly hasRefineControls = computed(
() => this.canSortContent() || this.canFilterByRating()
);
readonly activeRefinementCount = computed(() =>
this.minRating() !== null ? 1 : 0
);
readonly activeSortLabelKey = computed(() => {
switch (this.contentSortMode()) {
case 'date-desc':
return 'WORKSPACE.SORT_DATE_DESC';
case 'date-asc':
return 'WORKSPACE.SORT_DATE_ASC';
case 'name-asc':
return 'WORKSPACE.SORT_NAME_ASC';
case 'name-desc':
return 'WORKSPACE.SORT_NAME_DESC';
case 'rating-desc':
return 'WORKSPACE.SORT_TOP_RATED';
case 'rating-asc':
return 'WORKSPACE.SORT_LOWEST_RATED';
default:
return 'WORKSPACE.SORT_CUSTOM';
}
});
readonly searchTerm = toSignal(
this.activatedRoute.queryParamMap.pipe(map((p) => p.get('q') ?? '')),
{ initialValue: '' }
@@ -121,6 +157,10 @@ export class CategoryContentViewComponent implements OnInit {
this.catalog.setContentSortMode(mode);
}
setMinRating(value: number | null): void {
this.catalog.setMinRating?.(value);
}
ngOnInit(): void {
this.activatedRoute.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -7,7 +7,9 @@ export type PortalCatalogSortMode =
| 'date-desc'
| 'date-asc'
| 'name-asc'
| 'name-desc';
| 'name-desc'
| 'rating-desc'
| 'rating-asc';
export interface PortalCatalogPlaylistMeta {
id: string;
@@ -44,6 +46,13 @@ export interface PortalCatalogFacade<
readonly categoryItemCount: Signal<number>;
readonly contentSortMode: Signal<PortalCatalogSortMode | null>;
readonly playlist: Signal<PortalCatalogPlaylistMeta | null>;
/**
* Optional IMDb-rating capability (Xtream VOD/series). Providers without
* structured ratings (e.g. Stalker) leave these undefined, and the rating
* sort options + minimum-rating filter hide themselves in the UI.
*/
readonly supportsRatingSort?: boolean;
readonly minRating?: Signal<number | null>;
initialize(categoryId?: string | null): void;
setSearchQuery?(query: string): void;
@@ -51,6 +60,7 @@ export interface PortalCatalogFacade<
setPage(page: number): void;
setLimit(limit: number): void;
setContentSortMode(mode: PortalCatalogSortMode): void;
setMinRating?(value: number | null): void;
selectItem(item: TItem): string[] | null;
getItemProgress(item: TItem): PortalCatalogItemProgress;
}
@@ -0,0 +1,60 @@
import { filterByMinRating, getNumericRating } from './with-selection.feature';
type RatingItem = Parameters<typeof getNumericRating>[0];
const item = (fields: Record<string, unknown>): RatingItem =>
fields as RatingItem;
describe('with-selection rating helpers', () => {
describe('getNumericRating', () => {
it('prefers rating_imdb over the generic rating', () => {
expect(
getNumericRating(item({ rating_imdb: '7.4', rating: '6.5' }))
).toBe(7.4);
});
it('falls back to the generic rating and parses numbers + strings', () => {
expect(getNumericRating(item({ rating: '6.5' }))).toBe(6.5);
expect(getNumericRating(item({ rating: 8 }))).toBe(8);
});
it('reads the nested info object when the list shape lacks a rating', () => {
expect(getNumericRating(item({ info: { rating: '5.5' } }))).toBe(
5.5
);
});
it('returns null for missing or unparseable ratings', () => {
expect(getNumericRating(item({ rating: 'N/A' }))).toBeNull();
expect(getNumericRating(item({ rating: '' }))).toBeNull();
expect(getNumericRating(item({}))).toBeNull();
});
});
describe('filterByMinRating', () => {
const items = [
item({ name: 'A', rating: '8.5' }),
item({ name: 'B', rating: '6.0' }),
item({ name: 'C' }),
];
it('keeps only items at or above the threshold and drops unrated ones', () => {
expect(
filterByMinRating(items, 7).map((i) => (i as { name: string }).name)
).toEqual(['A']);
});
it('is inclusive of the exact threshold value', () => {
expect(
filterByMinRating(items, 6).map(
(i) => (i as { name: string }).name
)
).toEqual(['A', 'B']);
});
it('returns the original array when the threshold is null or non-positive', () => {
expect(filterByMinRating(items, null)).toBe(items);
expect(filterByMinRating(items, 0)).toBe(items);
});
});
});
@@ -247,4 +247,25 @@ describe('withSelection', () => {
store.selectItemsFromSelectedCategory().map((item) => item.title)
).toEqual(['World News', 'World Sports']);
});
it('clears the rating filter when the category changes', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(10);
store.setMinRating(7);
expect(store.minRating()).toBe(7);
store.setSelectedCategory(20);
expect(store.minRating()).toBeNull();
});
it('keeps the rating filter when the same category is re-selected', () => {
store.setSelectedContentType('vod');
store.setSelectedCategory(10);
store.setMinRating(7);
store.setSelectedCategory(10);
expect(store.minRating()).toBe(7);
});
});
@@ -21,7 +21,9 @@ export type XtreamCategorySortMode =
| 'date-desc'
| 'date-asc'
| 'name-asc'
| 'name-desc';
| 'name-desc'
| 'rating-desc'
| 'rating-asc';
/**
* Selection state for managing UI selection and pagination
@@ -34,6 +36,7 @@ export interface SelectionState {
limit: number;
contentSortMode: XtreamCategorySortMode;
categorySearchTerm: string;
minRating: number | null;
isLoadingDetails: boolean;
detailsError: string | null;
}
@@ -49,6 +52,7 @@ const initialSelectionState: SelectionState = {
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
contentSortMode: 'date-desc',
categorySearchTerm: '',
minRating: null,
isLoadingDetails: false,
detailsError: null,
};
@@ -114,6 +118,61 @@ type ParentSelectionStoreLike = {
vodStreams?: () => XtreamSelectionItem[];
};
const parseRatingValue = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string') {
const parsed = Number.parseFloat(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
/**
* Numeric IMDb-style rating used for sorting/filtering. Mirrors the grid badge
* (prefers `rating_imdb`, falls back to the generic `rating`), checking the
* stream-list shape first and the nested `info` object as a fallback. Returns
* null when no parseable rating exists.
*/
export const getNumericRating = (
item: XtreamSelectionItem
): number | null => {
const info =
item.info && !Array.isArray(item.info)
? (item.info as Record<string, unknown>)
: null;
const record = item as Record<string, unknown>;
for (const field of ['rating_imdb', 'rating'] as const) {
const direct = parseRatingValue(record[field]);
if (direct !== null) {
return direct;
}
const nested = info ? parseRatingValue(info[field]) : null;
if (nested !== null) {
return nested;
}
}
return null;
};
/**
* Filter VOD/series items by a minimum IMDb rating. Unrated items are excluded
* while a threshold is active.
*/
export const filterByMinRating = (
items: XtreamSelectionItem[],
minRating: number | null
): XtreamSelectionItem[] => {
if (minRating === null || minRating <= 0) {
return items;
}
return items.filter((item) => {
const rating = getNumericRating(item);
return rating !== null && rating >= minRating;
});
};
/**
* Selection feature store for managing UI selection and pagination.
* Handles:
@@ -157,6 +216,29 @@ export function withSelection() {
);
}
if (
sortMode === 'rating-desc' ||
sortMode === 'rating-asc'
) {
const ratingA = getNumericRating(a);
const ratingB = getNumericRating(b);
// Unrated items always sink to the bottom, regardless
// of sort direction.
if (ratingA === null || ratingB === null) {
if (ratingA !== ratingB) {
return ratingA === null ? 1 : -1;
}
} else if (ratingA !== ratingB) {
return sortMode === 'rating-desc'
? ratingB - ratingA
: ratingA - ratingB;
}
// Equal ratings (or both unrated): alphabetical tiebreak.
const ratingTitleA = a.title ?? a.name ?? '';
const ratingTitleB = b.title ?? b.name ?? '';
return COLLATOR.compare(ratingTitleA, ratingTitleB);
}
const titleA = a.title ?? a.name ?? '';
const titleB = b.title ?? b.name ?? '';
const byName = COLLATOR.compare(titleA, titleB);
@@ -269,6 +351,7 @@ export function withSelection() {
: storeAny.serialStreams?.() || [];
if (categoryType === 'vod' || categoryType === 'series') {
const minRating = store.minRating();
let filtered = categoryId
? content.filter(
(item) => Number(item.category_id) === categoryId
@@ -276,7 +359,8 @@ export function withSelection() {
: sortedContent();
filtered = filterBySearchTerm(filtered, searchTerm);
return categoryId || searchTerm
filtered = filterByMinRating(filtered, minRating);
return categoryId || searchTerm || minRating
? sortByMode(filtered, sortMode, categoryType)
: filtered;
}
@@ -436,6 +520,7 @@ export function withSelection() {
selectedCategoryId: null,
page: 0,
categorySearchTerm: '',
minRating: null,
});
},
@@ -454,6 +539,10 @@ export function withSelection() {
selectedCategoryId: newCategoryId,
page: 0,
categorySearchTerm: '',
// Clear the rating filter on category change too, mirroring
// categorySearchTerm and setSelectedContentType — otherwise a
// stale threshold stays silently applied in the new category.
minRating: null,
});
}
},
@@ -507,6 +596,21 @@ export function withSelection() {
});
},
/**
* Set the minimum IMDb rating filter for VOD/series content.
* A null or non-positive value clears the filter.
*/
setMinRating(value: number | null): void {
const normalized = value && value > 0 ? value : null;
if (store.minRating() === normalized) {
return;
}
patchState(store, {
minRating: normalized,
page: 0,
});
},
/**
* Set selected category search term
*/
@@ -47,6 +47,7 @@ describe('XtreamCatalogFacadeService', () => {
const totalPages = signal(1);
const isPaginatedContentLoading = signal(false);
const contentSortMode = signal<PortalCatalogSortMode>('date-desc');
const minRating = signal<number | null>(null);
const currentPlaylist = signal<XtreamPlaylistData | null>(PLAYLIST_ONE);
const xtreamStore = {
@@ -61,6 +62,7 @@ describe('XtreamCatalogFacadeService', () => {
getTotalPages: totalPages,
isPaginatedContentLoading,
contentSortMode,
minRating,
currentPlaylist,
loadAllPositions: jest.fn(),
setCategorySearchTerm: jest.fn(),
@@ -79,6 +81,9 @@ describe('XtreamCatalogFacadeService', () => {
setContentSortMode: jest.fn((mode: PortalCatalogSortMode) => {
contentSortMode.set(mode);
}),
setMinRating: jest.fn((value: number | null) => {
minRating.set(value);
}),
hasSeriesProgress: jest.fn().mockReturnValue(false),
getProgressPercent: jest.fn().mockReturnValue(40),
isWatched: jest.fn().mockReturnValue(false),
@@ -100,6 +105,7 @@ describe('XtreamCatalogFacadeService', () => {
totalPages.set(1);
isPaginatedContentLoading.set(false);
contentSortMode.set('date-desc');
minRating.set(null);
currentPlaylist.set(PLAYLIST_ONE);
xtreamStore.loadAllPositions.mockClear();
@@ -109,6 +115,7 @@ describe('XtreamCatalogFacadeService', () => {
xtreamStore.setPage.mockClear();
xtreamStore.setLimit.mockClear();
xtreamStore.setContentSortMode.mockClear();
xtreamStore.setMinRating.mockClear();
xtreamStore.hasSeriesProgress.mockClear();
xtreamStore.getProgressPercent.mockClear();
xtreamStore.isWatched.mockClear();
@@ -190,4 +197,67 @@ describe('XtreamCatalogFacadeService', () => {
'name-desc'
);
});
it('keeps rating sort modes out of live content', () => {
contentSortMode.set('rating-desc');
contentType.set('live');
expect(service.supportsRatingSort).toBe(false);
expect(service.contentSortMode()).toBe('date-desc');
service.setContentSortMode('rating-asc');
expect(xtreamStore.setContentSortMode).not.toHaveBeenCalled();
expect(localStorage.getItem('xtream-category-sort-mode')).toBeNull();
contentType.set('vod');
expect(service.supportsRatingSort).toBe(true);
expect(service.contentSortMode()).toBe('rating-desc');
service.setContentSortMode('rating-asc');
expect(xtreamStore.setContentSortMode).toHaveBeenCalledWith(
'rating-asc'
);
expect(localStorage.getItem('xtream-category-sort-mode')).toBe(
'rating-asc'
);
});
it('does not restore saved rating sort modes for live content', () => {
contentType.set('live');
localStorage.setItem('xtream-category-sort-mode', 'rating-desc');
service.initialize('42');
expect(xtreamStore.setContentSortMode).not.toHaveBeenCalled();
expect(service.contentSortMode()).toBe('date-desc');
});
it('exposes rating refinements only for VOD and series content', () => {
minRating.set(8);
expect(service.supportsRatingSort).toBe(true);
expect(service.minRating?.()).toBe(8);
service.setMinRating(7);
expect(xtreamStore.setMinRating).toHaveBeenCalledWith(7);
contentType.set('series');
expect(service.supportsRatingSort).toBe(true);
expect(service.minRating?.()).toBe(7);
xtreamStore.setMinRating.mockClear();
contentType.set('live');
expect(service.supportsRatingSort).toBe(false);
expect(service.minRating?.()).toBeNull();
service.setMinRating(9);
expect(xtreamStore.setMinRating).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,19 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
const SORT_STORAGE_KEY = 'xtream-category-sort-mode';
const isValidSortMode = (
mode: string | null
): mode is PortalCatalogSortMode =>
mode === 'date-desc' ||
mode === 'date-asc' ||
mode === 'name-asc' ||
mode === 'name-desc' ||
mode === 'rating-desc' ||
mode === 'rating-asc';
const isRatingSortMode = (mode: PortalCatalogSortMode): boolean =>
mode === 'rating-desc' || mode === 'rating-asc';
@Injectable()
export class XtreamCatalogFacadeService implements PortalCatalogFacade<
Record<string, unknown>,
@@ -37,7 +50,20 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
readonly categoryItemCount = computed(
() => this.xtreamStore.selectItemsFromSelectedCategory().length
);
readonly contentSortMode = this.xtreamStore.contentSortMode;
readonly contentSortMode = computed<PortalCatalogSortMode>(() => {
const mode = this.xtreamStore.contentSortMode();
return !this.supportsRatingSort && isRatingSortMode(mode)
? 'date-desc'
: mode;
});
get supportsRatingSort(): boolean {
const type = this.contentType();
return type === 'vod' || type === 'series';
}
readonly minRating = computed(() =>
this.supportsRatingSort ? this.xtreamStore.minRating() : null
);
readonly playlist = computed<PortalCatalogPlaylistMeta | null>(() => {
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
@@ -52,13 +78,8 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
initialize(categoryId?: string | null): void {
const savedSortMode = localStorage.getItem(SORT_STORAGE_KEY);
if (
savedSortMode === 'date-desc' ||
savedSortMode === 'date-asc' ||
savedSortMode === 'name-asc' ||
savedSortMode === 'name-desc'
) {
this.xtreamStore.setContentSortMode(savedSortMode);
if (isValidSortMode(savedSortMode)) {
this.setContentSortMode(savedSortMode);
}
const playlistId = this.xtreamStore.currentPlaylist()?.id;
@@ -93,10 +114,22 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
}
setContentSortMode(mode: PortalCatalogSortMode): void {
if (!this.supportsRatingSort && isRatingSortMode(mode)) {
return;
}
this.xtreamStore.setContentSortMode(mode);
localStorage.setItem(SORT_STORAGE_KEY, mode);
}
setMinRating(value: number | null): void {
if (!this.supportsRatingSort) {
return;
}
this.xtreamStore.setMinRating(value);
}
selectItem(item: Record<string, unknown>): string[] | null {
const xtreamId = item['xtream_id'];
if (xtreamId === undefined || xtreamId === null) {