Merge pull request #911 from 4gray/codex/stalker-radio

feat(stalker): add radio support
This commit is contained in:
4gray authored and GitHub committed 2026-05-09 15:50:10 +02:00
commit 134b5fb779
59 files changed
+1062 -181

No files matched your search

@@ -22,6 +22,18 @@ export interface RawChannel {
xmltv_id: string;
}
export interface RawRadioStation {
id: string;
name: string;
o_name: string;
cmd: string;
logo: string;
category_id: string;
tv_genre_id: string;
number: string;
radio: true;
}
export interface RawVodItem {
id: string;
name: string;
@@ -102,7 +114,9 @@ export interface GeneratedPortalData {
itvCategories: RawCategory[];
vodCategories: RawCategory[];
seriesCategories: RawCategory[];
radioCategories: RawCategory[];
channels: Map<string, RawChannel[]>; // categoryId -> channels
radio: Map<string, RawRadioStation[]>; // categoryId -> radio stations
vod: Map<string, RawVodItem[]>; // categoryId -> items
series: Map<string, RawSeriesItem[]>; // categoryId -> items
seasons: Map<string, RawSeason[]>; // seriesItemId -> seasons
@@ -146,7 +160,9 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
itvCategories: [],
vodCategories: [],
seriesCategories: [],
radioCategories: [],
channels: new Map(),
radio: new Map(),
vod: new Map(),
series: new Map(),
seasons: new Map(),
@@ -165,6 +181,22 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
channelIndex += config.itemsPerCategory;
}
// ------ Radio categories + stations ------
data.radioCategories = generateCategories(
'radio',
config.categoryCount.radio
);
let radioIndex = 0;
for (const cat of data.radioCategories) {
const stations = generateRadioStations(
cat.id,
config.itemsPerCategory,
radioIndex
);
data.radio.set(cat.id, stations);
radioIndex += config.itemsPerCategory;
}
// ------ VOD categories + items ------
data.vodCategories = generateCategories('vod', config.categoryCount.vod);
let vodIndex = 0;
@@ -223,16 +255,35 @@ const SERIES_GENRE_NAMES = [
'Thriller Series', 'Horror Series', 'Western Series',
];
function getGenreNames(type: 'itv' | 'vod' | 'series'): string[] {
const RADIO_GENRE_NAMES = [
'News Radio', 'Talk Radio', 'Jazz', 'Classical', 'Rock',
'Pop Hits', 'Electronic', 'World Music', 'Sports Radio', 'Public Radio',
'Local Stations', 'Latin', 'Hip Hop', 'Ambient', 'Oldies',
'Country', 'Reggae', 'Soul', 'Dance', 'Weather',
];
function getGenreNames(type: 'itv' | 'vod' | 'series' | 'radio'): string[] {
if (type === 'itv') return ITV_GENRE_NAMES;
if (type === 'vod') return VOD_GENRE_NAMES;
if (type === 'radio') return RADIO_GENRE_NAMES;
return SERIES_GENRE_NAMES;
}
function generateCategories(type: 'itv' | 'vod' | 'series', count: number): RawCategory[] {
function generateCategories(
type: 'itv' | 'vod' | 'series' | 'radio',
count: number
): RawCategory[] {
const names = getGenreNames(type);
return Array.from({ length: count }, (_, i) => {
const id = String((type === 'itv' ? 1000 : type === 'vod' ? 2000 : 3000) + i + 1);
const idBase =
type === 'itv'
? 1000
: type === 'vod'
? 2000
: type === 'series'
? 3000
: 4000;
const id = String(idBase + i + 1);
const title = names[i % names.length];
return { id, title, alias: title.toLowerCase().replace(/\s+/g, '_') };
});
@@ -260,6 +311,29 @@ function generateChannels(categoryId: string, count: number, startIndex: number)
});
}
function generateRadioStations(
categoryId: string,
count: number,
startIndex: number
): RawRadioStation[] {
return Array.from({ length: count }, (_, i) => {
const globalIndex = startIndex + i;
const id = String(40000 + globalIndex);
const name = `${faker.music.genre()} ${faker.word.noun()} Radio`;
return {
id,
name,
o_name: name,
cmd: `ffrt4://radio/${id}/index.mp3`,
logo: logoUrl(`radio-${id}`),
category_id: categoryId,
tv_genre_id: categoryId,
number: String(globalIndex + 1),
radio: true,
};
});
}
// ---------------------------------------------------------------------------
// VOD generators
// ---------------------------------------------------------------------------
@@ -7,7 +7,7 @@ import { extractMac } from './get-categories.handler.js';
*
* Query params:
* cmd: the ffrt4:// or similar command from the content item
* type: itv | vod | series
* type: itv | vod | series | radio
*/
export function handleCreateLink(req: Request, res: Response): void {
const mac = extractMac(req);
@@ -6,7 +6,12 @@ import {
removeFavorite,
} from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js';
import {
RawChannel,
RawRadioStation,
RawSeriesItem,
RawVodItem,
} from '../data-generator.js';
/**
* Stalker favorites endpoint.
@@ -15,7 +20,7 @@ import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js';
* action: favorites
* fav_action: get | add | remove (some portals use set/unset)
* item_id: the item id to add/remove
* type: itv | vod | series
* type: itv | vod | series | radio
*/
export function handleFavorites(req: Request, res: Response): void {
const mac = extractMac(req);
@@ -38,7 +43,7 @@ export function handleFavorites(req: Request, res: Response): void {
const favIds = getFavorites(mac);
const data = getPortalData(mac);
type AnyItem = RawChannel | RawVodItem | RawSeriesItem;
type AnyItem = RawChannel | RawRadioStation | RawVodItem | RawSeriesItem;
const result: AnyItem[] = [];
for (const id of favIds) {
@@ -58,11 +63,15 @@ export function handleFavorites(req: Request, res: Response): void {
function findItemById(
data: ReturnType<typeof getPortalData>,
id: string
): RawChannel | RawVodItem | RawSeriesItem | undefined {
): RawChannel | RawRadioStation | RawVodItem | RawSeriesItem | undefined {
for (const items of data.channels.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
}
for (const items of data.radio.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
}
for (const items of data.vod.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
/**
* Stalker get_categories — returns category list filtered by type (itv/vod/series).
* Stalker get_categories — returns category list filtered by type.
*/
export function handleGetCategories(req: Request, res: Response): void {
const mac = extractMac(req);
@@ -12,6 +12,8 @@ export function handleGetCategories(req: Request, res: Response): void {
let categories;
if (type === 'itv') {
categories = data.itvCategories;
} else if (type === 'radio') {
categories = data.radioCategories;
} else if (type === 'series') {
categories = data.seriesCategories;
} else {
@@ -14,6 +14,8 @@ export function handleGetGenres(req: Request, res: Response): void {
let categories;
if (type === 'itv') {
categories = data.itvCategories;
} else if (type === 'radio') {
categories = data.radioCategories;
} else if (type === 'series') {
categories = data.seriesCategories;
} else {
@@ -1,17 +1,22 @@
import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js';
import {
RawChannel,
RawRadioStation,
RawSeriesItem,
RawVodItem,
} from '../data-generator.js';
const PAGE_SIZE = 14;
type AnyItem = RawChannel | RawVodItem | RawSeriesItem;
type AnyItem = RawChannel | RawRadioStation | RawVodItem | RawSeriesItem;
/**
* Stalker get_ordered_list — returns paginated content for a category.
*
* Query params:
* type: itv | vod | series
* type: itv | vod | series | radio
* category: category_id (or "*" for all)
* genre: same as category for itv
* p: page number (1-based)
@@ -35,6 +40,14 @@ export function handleGetOrderedList(req: Request, res: Response): void {
} else {
allItems = data.channels.get(categoryId) ?? [];
}
} else if (type === 'radio') {
if (categoryId === '*') {
for (const items of data.radio.values()) {
allItems.push(...items);
}
} else {
allItems = data.radio.get(categoryId) ?? [];
}
} else if (type === 'series') {
if (categoryId === '*') {
for (const items of data.series.values()) {
@@ -4,6 +4,7 @@ export interface ScenarioConfig {
seed: number;
categoryCount: {
itv: number;
radio: number;
vod: number;
series: number;
};
@@ -25,7 +26,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'default',
description: 'Balanced portal — 8 categories, 40 items each',
seed: 1001,
categoryCount: { itv: 8, vod: 8, series: 8 },
categoryCount: { itv: 8, radio: 8, vod: 8, series: 8 },
itemsPerCategory: 40,
seasonsPerSeries: 3,
episodesPerSeason: 8,
@@ -36,7 +37,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'large',
description: 'Large catalog — 20 categories, 200 items each',
seed: 9999,
categoryCount: { itv: 20, vod: 20, series: 20 },
categoryCount: { itv: 20, radio: 20, vod: 20, series: 20 },
itemsPerCategory: 200,
seasonsPerSeries: 5,
episodesPerSeason: 12,
@@ -47,7 +48,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'series-heavy',
description: 'Series-heavy portal — many series with deep seasons',
seed: 2002,
categoryCount: { itv: 3, vod: 5, series: 15 },
categoryCount: { itv: 3, radio: 3, vod: 5, series: 15 },
itemsPerCategory: 30,
seasonsPerSeries: 6,
episodesPerSeason: 10,
@@ -58,7 +59,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'minimal',
description: 'Minimal portal — 2 categories, 5 items (edge case testing)',
seed: 3003,
categoryCount: { itv: 2, vod: 2, series: 2 },
categoryCount: { itv: 2, radio: 2, vod: 2, series: 2 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 3,
@@ -69,7 +70,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'is-series',
description: 'VOD with is_series=1 flag (Ministra plugin flow testing)',
seed: 4004,
categoryCount: { itv: 4, vod: 6, series: 4 },
categoryCount: { itv: 4, radio: 4, vod: 6, series: 4 },
itemsPerCategory: 20,
seasonsPerSeries: 3,
episodesPerSeason: 6,
@@ -80,7 +81,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'embedded-series',
description: 'VOD with embedded series[] arrays',
seed: 5005,
categoryCount: { itv: 4, vod: 6, series: 4 },
categoryCount: { itv: 4, radio: 4, vod: 6, series: 4 },
itemsPerCategory: 20,
seasonsPerSeries: 2,
episodesPerSeason: 5,
@@ -111,7 +112,7 @@ export function getScenario(mac: string): ScenarioConfig {
name: 'auto',
description: `Auto-generated from MAC ${mac}`,
seed: macToSeed(mac),
categoryCount: { itv: 6, vod: 6, series: 6 },
categoryCount: { itv: 6, radio: 6, vod: 6, series: 6 },
itemsPerCategory: 30,
seasonsPerSeries: 3,
episodesPerSeason: 8,
+111 -3
View File
@@ -1,4 +1,10 @@
import { expect, Locator, Page, test } from '@playwright/test';
import {
expect,
type APIRequestContext,
type Locator,
type Page,
test,
} from '@playwright/test';
/**
* Stalker Portal E2E Tests
@@ -8,7 +14,7 @@ import { expect, Locator, Page, test } from '@playwright/test';
* dev server when running e2e tests (see playwright.config.ts).
*
* Default scenario MAC (00:1A:79:00:00:01) provides:
* - 8 categories per content type (VOD / Series / ITV)
* - 8 categories per content type (VOD / Series / ITV / Radio)
* - 40 items per category
* - 3 seasons × 8 episodes per series item
*
@@ -62,6 +68,31 @@ async function setInputValue(input: Locator, value: string): Promise<void> {
await expect(input).toHaveValue(value);
}
async function resetMockServer(request: APIRequestContext): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const response = await request.post(`${MOCK_SERVER}/reset`);
if (response.ok()) {
return;
}
lastError = new Error(
`Reset failed with status ${response.status()}`
);
} catch (error) {
lastError = error;
}
await new Promise((resolve) =>
setTimeout(resolve, 250 * (attempt + 1))
);
}
throw lastError;
}
/**
* Add a Stalker portal via the UI:
* 1. Click the "add playlist" button to open the unified dialog
@@ -97,7 +128,7 @@ async function addStalkerPortal(
test.beforeEach(async ({ page, request }) => {
// Reset mock server state (clears in-memory favorites and cache)
await request.post(`${MOCK_SERVER}/reset`);
await resetMockServer(request);
// Playwright creates a fresh browser context per test, so extra
// IndexedDB cleanup here only risks racing with app-managed DB handles.
@@ -208,6 +239,58 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => {
).toBeVisible({ timeout: 20_000 });
});
test('@stalker radio — stations use the inline audio player without EPG', async ({
page,
}) => {
await addStalkerPortal(page);
const radioListRequests: string[] = [];
const epgRequests: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (
url.includes('type=radio') &&
url.includes('action=get_ordered_list')
) {
radioListRequests.push(url);
}
if (
url.includes('action=get_epg_info') ||
url.includes('action=get_short_epg')
) {
epgRequests.push(url);
}
});
await page.getByRole('link', { name: /radio/i }).click();
await page.waitForURL(/stalker.*radio/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const stations = page.locator('[data-test-id="channel-item"]');
await expect(stations.first()).toBeVisible({ timeout: 20_000 });
await expect.poll(() => radioListRequests.length).toBeGreaterThan(0);
await stations.first().click();
await expect(stations.first()).toHaveClass(/active/, { timeout: 20_000 });
await expect(page.locator('app-audio-player')).toBeVisible({
timeout: 20_000,
});
await page.getByRole('button', { name: 'Hide channels list' }).click();
const restoreButton = page.getByRole('button', {
name: 'Show channels list',
});
await expect(restoreButton).toBeVisible();
await restoreButton.click();
await expect(stations.first()).toBeVisible();
await expect(page.locator('app-epg-list')).toHaveCount(0);
expect(epgRequests).toHaveLength(0);
});
test('@stalker bulk EPG is fetched once and reused across channel switches', async ({
page,
}) => {
@@ -284,6 +367,31 @@ test('@stalker create_link returns a playable stream URL', async ({
expect(streamUrl).toMatch(/\.m3u8$/);
});
test('@stalker mock server returns radio categories and stations', async ({
request,
}) => {
const categoriesResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_categories&type=radio&macAddress=${DEFAULT_MAC}`
);
expect(categoriesResponse.ok()).toBeTruthy();
const categoriesBody = await categoriesResponse.json();
expect(categoriesBody.payload.js.length).toBeGreaterThan(0);
const firstCategory = categoriesBody.payload.js[0].id;
const stationsResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=radio&category=${firstCategory}&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml`
);
expect(stationsResponse.ok()).toBeTruthy();
const stationsBody = await stationsResponse.json();
const firstStation = stationsBody.payload.js.data[0];
expect(firstStation).toEqual(
expect.objectContaining({
category_id: firstCategory,
radio: true,
})
);
});
test('@stalker series — seasons load for a series item', async ({
request,
}) => {
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم",
"PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل",
"LOADING_SEASONS": "جارٍ تحميل المواسم...",
"NO_EPISODES": "لا توجد حلقات متاحة"
"NO_EPISODES": "لا توجد حلقات متاحة",
"ALL_RADIO": "كل الراديو"
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "جارٍ إزالة البث المخزّن مؤقتًا...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "جارٍ إزالة الفئات المخزّنة مؤقتًا...",
"XTREAM_REFRESH_DETAIL_LOCAL": "نحتفظ بالمفضلة وسجل المشاهدة والفئات المخفية وتقدم التشغيل لاستعادتها بعد المزامنة.",
"XTREAM_REFRESH_PROGRESS": "السجلات المحلية المعالجة: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "السجلات المحلية المعالجة: {{current}} / {{total}}",
"RAIL_RADIO": "راديو"
},
"SOURCES": {
"TITLE": "المصادر",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "عنصر واحد",
"ITEM_COUNT_OTHER": "{{count}} عنصر",
"SEARCH": "بحث",
"SEARCH_CATEGORIES": "البحث في الفئات"
"SEARCH_CATEGORIES": "البحث في الفئات",
"RADIO_CATEGORIES": "تصنيفات الراديو"
},
"COMMAND_PALETTE": {
"TITLE": "لوحة الأوامر",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر",
"PLAYBACK_ERROR": "فشل جلب رابط التشغيل",
"LOADING_SEASONS": "جاري تشريج المواسم...",
"NO_EPISODES": "ما كاينش حلقات متوفرة"
"NO_EPISODES": "ما كاينش حلقات متوفرة",
"ALL_RADIO": "All radio"
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "كنحيدو الستريمات المخزنة...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "كنحيدو التصنيفات المخزنة...",
"XTREAM_REFRESH_DETAIL_LOCAL": "كنخليو المفضلات، تاريخ المشاهدة، التصنيفات المخفية، وتقدم التشغيل واجدين باش يرجعو من بعد المزامنة.",
"XTREAM_REFRESH_PROGRESS": "السجلات المحلية اللي تجهزات: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "السجلات المحلية اللي تجهزات: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "المصادر",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "عنصر واحد",
"ITEM_COUNT_OTHER": "{{count}} ديال العناصر",
"SEARCH": "بحث",
"SEARCH_CATEGORIES": "قلب على فئات"
"SEARCH_CATEGORIES": "قلب على فئات",
"RADIO_CATEGORIES": "Radio Categories"
},
"COMMAND_PALETTE": {
"TITLE": "لوحة الأوامر",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы",
"PLAYBACK_ERROR": "Не ўдалося атрымаць URL для прайгравання",
"LOADING_SEASONS": "Загрузка сезонаў...",
"NO_EPISODES": "Няма даступных эпізодаў"
"NO_EPISODES": "Няма даступных эпізодаў",
"ALL_RADIO": "Усе радыё"
},
"INFORMATION": "Інфармацыя",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Выдаленне кэшаваных патокаў...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Выдаленне кэшаваных катэгорый...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Выбранае, гісторыя прагляду, схаваныя катэгорыі і прагрэс прайгравання будуць падрыхтаваны для аднаўлення пасля сінхранізацыі.",
"XTREAM_REFRESH_PROGRESS": "Апрацавана лакальных запісаў: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Апрацавана лакальных запісаў: {{current}} / {{total}}",
"RAIL_RADIO": "Радыё"
},
"SOURCES": {
"TITLE": "Крыніцы",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 элемент",
"ITEM_COUNT_OTHER": "{{count}} элементаў",
"SEARCH": "Пошук",
"SEARCH_CATEGORIES": "Пошук катэгорый"
"SEARCH_CATEGORIES": "Пошук катэгорый",
"RADIO_CATEGORIES": "Катэгорыі радыё"
},
"COMMAND_PALETTE": {
"TITLE": "Палітра каманд",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar",
"PLAYBACK_ERROR": "Wiedergabe-URL konnte nicht abgerufen werden",
"LOADING_SEASONS": "Staffeln werden geladen …",
"NO_EPISODES": "Keine Episoden verfügbar"
"NO_EPISODES": "Keine Episoden verfügbar",
"ALL_RADIO": "Alle Radiosender"
},
"INFORMATION": "Information",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Zwischengespeicherte Streams werden entfernt...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Zwischengespeicherte Kategorien werden entfernt...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Favoriten, Wiedergabeverlauf, ausgeblendete Kategorien und Wiedergabefortschritt werden für die Wiederherstellung nach der Synchronisierung vorbereitet.",
"XTREAM_REFRESH_PROGRESS": "Lokale Einträge verarbeitet: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Lokale Einträge verarbeitet: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Quellen",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 Element",
"ITEM_COUNT_OTHER": "{{count}} Elemente",
"SEARCH": "Suchen",
"SEARCH_CATEGORIES": "Kategorien suchen"
"SEARCH_CATEGORIES": "Kategorien suchen",
"RADIO_CATEGORIES": "Radio-Kategorien"
},
"COMMAND_PALETTE": {
"TITLE": "Befehlspalette",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή",
"PLAYBACK_ERROR": "Αποτυχία λήψης διεύθυνσης URL αναπαραγωγής",
"LOADING_SEASONS": "Φόρτωση σεζόν...",
"NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια"
"NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια",
"ALL_RADIO": "Όλα τα ραδιόφωνα"
},
"INFORMATION": "Πληροφορίες",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Αφαίρεση αποθηκευμένων ροών...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Αφαίρεση αποθηκευμένων κατηγοριών...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Τα αγαπημένα, το ιστορικό προβολής, οι κρυφές κατηγορίες και η πρόοδος αναπαραγωγής ετοιμάζονται για επαναφορά μετά τον συγχρονισμό.",
"XTREAM_REFRESH_PROGRESS": "Τοπικές εγγραφές που επεξεργάστηκαν: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Τοπικές εγγραφές που επεξεργάστηκαν: {{current}} / {{total}}",
"RAIL_RADIO": "Ραδιόφωνο"
},
"SOURCES": {
"TITLE": "Πηγές",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 στοιχείο",
"ITEM_COUNT_OTHER": "{{count}} στοιχεία",
"SEARCH": "Αναζήτηση",
"SEARCH_CATEGORIES": "Αναζήτηση κατηγοριών"
"SEARCH_CATEGORIES": "Αναζήτηση κατηγοριών",
"RADIO_CATEGORIES": "Κατηγορίες ραδιοφώνου"
},
"COMMAND_PALETTE": {
"TITLE": "Παλέτα εντολών",
+6 -3
View File
@@ -694,7 +694,8 @@
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
"PLAYBACK_ERROR": "Failed to get playback URL",
"LOADING_SEASONS": "Loading seasons...",
"NO_EPISODES": "No episodes available"
"NO_EPISODES": "No episodes available",
"ALL_RADIO": "All radio"
},
"INFORMATION": "Information",
"DOWNLOADS": {
@@ -903,7 +904,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Removing cached streams...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Removing cached categories...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Keeping favorites, watch history, hidden categories, and playback progress ready for restore after sync.",
"XTREAM_REFRESH_PROGRESS": "Local records processed: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Local records processed: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Sources",
@@ -934,7 +936,8 @@
"ITEM_COUNT_ONE": "1 item",
"ITEM_COUNT_OTHER": "{{count}} items",
"SEARCH": "Search",
"SEARCH_CATEGORIES": "Search categories"
"SEARCH_CATEGORIES": "Search categories",
"RADIO_CATEGORIES": "Radio Categories"
},
"COMMAND_PALETTE": {
"TITLE": "Command palette",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor",
"PLAYBACK_ERROR": "No se pudo obtener la URL de reproducción",
"LOADING_SEASONS": "Cargando temporadas…",
"NO_EPISODES": "No hay episodios disponibles"
"NO_EPISODES": "No hay episodios disponibles",
"ALL_RADIO": "Todas las radios"
},
"INFORMATION": "Información",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Eliminando streams en caché...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Eliminando categorías en caché...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Guardamos favoritos, historial de reproducción, categorías ocultas y progreso para restaurarlos después de la sincronización.",
"XTREAM_REFRESH_PROGRESS": "Registros locales procesados: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Registros locales procesados: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Fuentes",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 elemento",
"ITEM_COUNT_OTHER": "{{count}} elementos",
"SEARCH": "Buscar",
"SEARCH_CATEGORIES": "Buscar categorías"
"SEARCH_CATEGORIES": "Buscar categorías",
"RADIO_CATEGORIES": "Categorías de radio"
},
"COMMAND_PALETTE": {
"TITLE": "Paleta de comandos",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur",
"PLAYBACK_ERROR": "Échec de l'obtention de l'URL de lecture",
"LOADING_SEASONS": "Chargement des saisons…",
"NO_EPISODES": "Aucun épisode disponible"
"NO_EPISODES": "Aucun épisode disponible",
"ALL_RADIO": "Toutes les radios"
},
"INFORMATION": "Information",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Suppression des flux en cache...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Suppression des catégories en cache...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Les favoris, l’historique de lecture, les catégories masquées et la progression sont préparés pour être restaurés après la synchronisation.",
"XTREAM_REFRESH_PROGRESS": "Enregistrements locaux traités : {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Enregistrements locaux traités : {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Sources",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 élément",
"ITEM_COUNT_OTHER": "{{count}} éléments",
"SEARCH": "Rechercher",
"SEARCH_CATEGORIES": "Rechercher des catégories"
"SEARCH_CATEGORIES": "Rechercher des catégories",
"RADIO_CATEGORIES": "Catégories radio"
},
"COMMAND_PALETTE": {
"TITLE": "Palette de commandes",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server",
"PLAYBACK_ERROR": "Impossibile ottenere l'URL di riproduzione",
"LOADING_SEASONS": "Caricamento stagioni...",
"NO_EPISODES": "Nessun episodio disponibile"
"NO_EPISODES": "Nessun episodio disponibile",
"ALL_RADIO": "Tutte le radio"
},
"INFORMATION": "Informazioni",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Rimozione degli stream in cache...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Rimozione delle categorie in cache...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Preferiti, cronologia di visione, categorie nascoste e avanzamento di riproduzione vengono preparati per il ripristino dopo la sincronizzazione.",
"XTREAM_REFRESH_PROGRESS": "Record locali elaborati: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Record locali elaborati: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Sorgenti",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 elemento",
"ITEM_COUNT_OTHER": "{{count}} elementi",
"SEARCH": "Cerca",
"SEARCH_CATEGORIES": "Cerca categorie"
"SEARCH_CATEGORIES": "Cerca categorie",
"RADIO_CATEGORIES": "Categorie radio"
},
"COMMAND_PALETTE": {
"TITLE": "Tavolozza comandi",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません",
"PLAYBACK_ERROR": "再生URLの取得に失敗しました",
"LOADING_SEASONS": "シーズンを読み込み中...",
"NO_EPISODES": "利用可能なエピソードはありません"
"NO_EPISODES": "利用可能なエピソードはありません",
"ALL_RADIO": "すべてのラジオ"
},
"INFORMATION": "情報",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "キャッシュ済みストリームを削除しています...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "キャッシュ済みカテゴリを削除しています...",
"XTREAM_REFRESH_DETAIL_LOCAL": "お気に入り、視聴履歴、非表示カテゴリ、再生進捗を同期後に復元できるよう準備しています。",
"XTREAM_REFRESH_PROGRESS": "処理済みローカルレコード: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "処理済みローカルレコード: {{current}} / {{total}}",
"RAIL_RADIO": "ラジオ"
},
"SOURCES": {
"TITLE": "ソース",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "{{count}}件",
"ITEM_COUNT_OTHER": "{{count}}件",
"SEARCH": "検索",
"SEARCH_CATEGORIES": "カテゴリーを検索"
"SEARCH_CATEGORIES": "カテゴリーを検索",
"RADIO_CATEGORIES": "ラジオカテゴリ"
},
"COMMAND_PALETTE": {
"TITLE": "コマンドパレット",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다",
"PLAYBACK_ERROR": "재생 URL을 가져오지 못했습니다",
"LOADING_SEASONS": "시즌을 불러오는 중...",
"NO_EPISODES": "사용 가능한 에피소드가 없습니다"
"NO_EPISODES": "사용 가능한 에피소드가 없습니다",
"ALL_RADIO": "모든 라디오"
},
"INFORMATION": "정보",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "캐시된 스트림을 제거하는 중...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "캐시된 카테고리를 제거하는 중...",
"XTREAM_REFRESH_DETAIL_LOCAL": "즐겨찾기, 시청 기록, 숨긴 카테고리, 재생 진행률을 동기화 후 복원할 수 있도록 준비합니다.",
"XTREAM_REFRESH_PROGRESS": "처리된 로컬 레코드: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "처리된 로컬 레코드: {{current}} / {{total}}",
"RAIL_RADIO": "라디오"
},
"SOURCES": {
"TITLE": "소스",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "{{count}}개 항목",
"ITEM_COUNT_OTHER": "{{count}}개 항목",
"SEARCH": "검색",
"SEARCH_CATEGORIES": "카테고리 검색"
"SEARCH_CATEGORIES": "카테고리 검색",
"RADIO_CATEGORIES": "라디오 카테고리"
},
"COMMAND_PALETTE": {
"TITLE": "명령어 팔레트",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server",
"PLAYBACK_ERROR": "Ophalen van afspeel-URL mislukt",
"LOADING_SEASONS": "Seizoenen laden...",
"NO_EPISODES": "Geen afleveringen beschikbaar"
"NO_EPISODES": "Geen afleveringen beschikbaar",
"ALL_RADIO": "Alle radio"
},
"INFORMATION": "Informatie",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Gecachte streams worden verwijderd...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Gecachte categorieën worden verwijderd...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Favorieten, kijkgeschiedenis, verborgen categorieën en afspeelvoortgang worden klaargezet voor herstel na de synchronisatie.",
"XTREAM_REFRESH_PROGRESS": "Lokale records verwerkt: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Lokale records verwerkt: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Bronnen",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 item",
"ITEM_COUNT_OTHER": "{{count}} items",
"SEARCH": "Zoeken",
"SEARCH_CATEGORIES": "Zoek categorieën"
"SEARCH_CATEGORIES": "Zoek categorieën",
"RADIO_CATEGORIES": "Radiocategorieën"
},
"COMMAND_PALETTE": {
"TITLE": "Opdrachtenpalet",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze",
"PLAYBACK_ERROR": "Nie udało się uzyskać URL odtwarzania",
"LOADING_SEASONS": "Ładowanie sezonów...",
"NO_EPISODES": "Brak dostępnych odcinków"
"NO_EPISODES": "Brak dostępnych odcinków",
"ALL_RADIO": "Wszystkie radia"
},
"INFORMATION": "Informacje",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Usuwanie strumieni z pamięci podręcznej...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Usuwanie kategorii z pamięci podręcznej...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Ulubione, historia oglądania, ukryte kategorie i postęp odtwarzania są przygotowywane do przywrócenia po synchronizacji.",
"XTREAM_REFRESH_PROGRESS": "Przetworzone rekordy lokalne: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Przetworzone rekordy lokalne: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Źródła",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 element",
"ITEM_COUNT_OTHER": "{{count}} elementów",
"SEARCH": "Szukaj",
"SEARCH_CATEGORIES": "Szukaj kategorii"
"SEARCH_CATEGORIES": "Szukaj kategorii",
"RADIO_CATEGORIES": "Kategorie radia"
},
"COMMAND_PALETTE": {
"TITLE": "Paleta poleceń",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor",
"PLAYBACK_ERROR": "Falha ao obter URL de reprodução",
"LOADING_SEASONS": "Carregando temporadas...",
"NO_EPISODES": "Nenhum episódio disponível"
"NO_EPISODES": "Nenhum episódio disponível",
"ALL_RADIO": "Todas as rádios"
},
"INFORMATION": "Informação",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Removendo streams em cache...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Removendo categorias em cache...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Favoritos, histórico de visualização, categorias ocultas e progresso de reprodução serão mantidos para restauração após a sincronização.",
"XTREAM_REFRESH_PROGRESS": "Registros locais processados: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Registros locais processados: {{current}} / {{total}}",
"RAIL_RADIO": "Rádio"
},
"SOURCES": {
"TITLE": "Fontes",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 item",
"ITEM_COUNT_OTHER": "{{count}} itens",
"SEARCH": "Pesquisar",
"SEARCH_CATEGORIES": "Pesquisar categorias"
"SEARCH_CATEGORIES": "Pesquisar categorias",
"RADIO_CATEGORIES": "Categorias de rádio"
},
"COMMAND_PALETTE": {
"TITLE": "Paleta de comandos",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере",
"PLAYBACK_ERROR": "Не удалось получить URL для воспроизведения",
"LOADING_SEASONS": "Загрузка сезонов…",
"NO_EPISODES": "Нет доступных эпизодов"
"NO_EPISODES": "Нет доступных эпизодов",
"ALL_RADIO": "Все радио"
},
"INFORMATION": "Инфо",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Удаляем кэшированные потоки...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Удаляем кэшированные категории...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Избранное, история просмотра, скрытые категории и прогресс воспроизведения будут подготовлены для восстановления после синхронизации.",
"XTREAM_REFRESH_PROGRESS": "Обработано локальных записей: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "Обработано локальных записей: {{current}} / {{total}}",
"RAIL_RADIO": "Радио"
},
"SOURCES": {
"TITLE": "Источники",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 элемент",
"ITEM_COUNT_OTHER": "{{count}} элементов",
"SEARCH": "Поиск",
"SEARCH_CATEGORIES": "Поиск категорий"
"SEARCH_CATEGORIES": "Поиск категорий",
"RADIO_CATEGORIES": "Категории радио"
},
"COMMAND_PALETTE": {
"TITLE": "Палитра команд",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil",
"PLAYBACK_ERROR": "Oynatma URL'si alınamadı",
"LOADING_SEASONS": "Sezonlar yükleniyor...",
"NO_EPISODES": "Bölüm mevcut değil"
"NO_EPISODES": "Bölüm mevcut değil",
"ALL_RADIO": "Tüm radyolar"
},
"INFORMATION": "Bilgi",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "Önbelleğe alınmış yayınlar kaldırılıyor...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Önbelleğe alınmış kategoriler kaldırılıyor...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Favoriler, izleme geçmişi, gizli kategoriler ve oynatma ilerlemesi eşitlemeden sonra geri yüklenmek üzere hazırlanıyor.",
"XTREAM_REFRESH_PROGRESS": "İşlenen yerel kayıtlar: {{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "İşlenen yerel kayıtlar: {{current}} / {{total}}",
"RAIL_RADIO": "Radyo"
},
"SOURCES": {
"TITLE": "Kaynaklar",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 öğe",
"ITEM_COUNT_OTHER": "{{count}} öğe",
"SEARCH": "Ara",
"SEARCH_CATEGORIES": "Kategorileri ara"
"SEARCH_CATEGORIES": "Kategorileri ara",
"RADIO_CATEGORIES": "Radyo kategorileri"
},
"COMMAND_PALETTE": {
"TITLE": "Komut paleti",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "此服务器上没有此内容",
"PLAYBACK_ERROR": "获取播放 URL 失败",
"LOADING_SEASONS": "正在加载季…",
"NO_EPISODES": "没有可用的剧集"
"NO_EPISODES": "没有可用的剧集",
"ALL_RADIO": "所有广播"
},
"INFORMATION": "信息",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "正在移除缓存的流...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "正在移除缓存的分类...",
"XTREAM_REFRESH_DETAIL_LOCAL": "收藏、观看历史、隐藏分类和播放进度会保留,以便同步后恢复。",
"XTREAM_REFRESH_PROGRESS": "已处理本地记录:{{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "已处理本地记录:{{current}} / {{total}}",
"RAIL_RADIO": "广播"
},
"SOURCES": {
"TITLE": "源",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "1 项",
"ITEM_COUNT_OTHER": "{{count}} 项",
"SEARCH": "搜索",
"SEARCH_CATEGORIES": "搜索分类"
"SEARCH_CATEGORIES": "搜索分类",
"RADIO_CATEGORIES": "广播分类"
},
"COMMAND_PALETTE": {
"TITLE": "命令面板",
+6 -3
View File
@@ -692,7 +692,8 @@
"CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容",
"PLAYBACK_ERROR": "無法取得播放網址",
"LOADING_SEASONS": "正在載入季數...",
"NO_EPISODES": "沒有可用的集數"
"NO_EPISODES": "沒有可用的集數",
"ALL_RADIO": "所有廣播"
},
"INFORMATION": "資訊",
"DOWNLOADS": {
@@ -901,7 +902,8 @@
"XTREAM_REFRESH_DELETING_CONTENT": "正在移除快取的串流...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "正在移除快取的分類...",
"XTREAM_REFRESH_DETAIL_LOCAL": "我的最愛、觀看記錄、隱藏分類和播放進度會保留,以便同步後還原。",
"XTREAM_REFRESH_PROGRESS": "已處理本機記錄:{{current}} / {{total}}"
"XTREAM_REFRESH_PROGRESS": "已處理本機記錄:{{current}} / {{total}}",
"RAIL_RADIO": "廣播"
},
"SOURCES": {
"TITLE": "來源",
@@ -932,7 +934,8 @@
"ITEM_COUNT_ONE": "{{count}} 個項目",
"ITEM_COUNT_OTHER": "{{count}} 個項目",
"SEARCH": "搜尋",
"SEARCH_CATEGORIES": "搜尋類別"
"SEARCH_CATEGORIES": "搜尋類別",
"RADIO_CATEGORIES": "廣播分類"
},
"COMMAND_PALETTE": {
"TITLE": "命令選擇區",
+2 -2
View File
@@ -151,8 +151,8 @@ The shared row should be reused instead of rebuilding channel markup per view.
shell context sidebar (the "Live Categories" rail rendered by
`WorkspaceShellContextSidebarComponent`), matching M3U's "everything quiets"
behaviour. The shell categories rail only collapses when the active section
is `live` (Xtream) or `itv` (Stalker); movies, series, favorites, and recent
routes leave it untouched.
is `live` (Xtream) or `itv`/`radio` (Stalker); movies, series, favorites,
and recent routes leave it untouched.
- Collapsed state is owned by `LiveLayoutSidebarStateService`
(`providedIn: 'root'`) in `@iptvnator/portal/shared/util`. Every surface that
participates injects the service and reads `isCollapsed`; any toggle calls
+8 -1
View File
@@ -47,6 +47,9 @@ faker.seed(macToNumber(mac))
│ └── generateChannels() → channels Map<categoryId, channel[]>
│ └── generateEpg() → epg Map<channelId, program[]>
│
├── generateCategories('radio', N) → radioCategories[]
│ └── generateRadioStations() → radio Map<categoryId, station[]>
│
├── generateCategories('vod', N) → vodCategories[]
│ └── generateVodItems() → vod Map<categoryId, item[]>
│ ├── normal VOD items
@@ -107,6 +110,10 @@ All responses follow the Stalker `portal.php` envelope:
}
```
For `type=radio`, items use the same paginated response envelope as live TV
channels, but each item is generated as a radio station with a `radio: true`
marker and an `ffrt4://radio/...` command.
### `get_ordered_list` (seasons — when `movie_id` is present)
```json
@@ -204,7 +211,7 @@ interface ScenarioConfig {
name: string;
description: string;
seed: number;
categoryCount: { itv: number; vod: number; series: number };
categoryCount: { itv: number; radio: number; vod: number; series: number };
itemsPerCategory: number;
seasonsPerSeries: number;
episodesPerSeason: number;
+25 -1
View File
@@ -18,6 +18,7 @@ This document describes the Stalker portal implementation in IPTVnator and where
Stalker support covers:
- Live TV (`itv`)
- Radio (`radio`)
- VOD (`vod`)
- Series (`series`)
- VOD-as-series flows (`is_series=1` and embedded `series[]`)
@@ -33,6 +34,7 @@ Primary route tree lives in `/Users/4gray/Code/iptvnator/libs/portal/stalker/fea
- `/stalker/:id/vod`
- `/stalker/:id/series`
- `/stalker/:id/itv`
- `/stalker/:id/radio`
- `/stalker/:id/favorites`
- `/stalker/:id/recent`
- `/stalker/:id/search`
@@ -51,7 +53,9 @@ Primary route tree lives in `/Users/4gray/Code/iptvnator/libs/portal/stalker/fea
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts`
- Category + content layout for `vod` and `series`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
- ITV live playback, channel navigation, EPG panel integration
- ITV live playback, radio playback, channel/station navigation, EPG panel integration
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
- Shared inline audio player used by M3U radio channels and Stalker radio stations
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts`
- Season/episode UI for all Stalker series modes
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts`
@@ -71,6 +75,7 @@ Important store responsibilities:
- Selected content/category/item state
- Category and paginated content resources
- ITV channel list + pagination
- Radio category/station list + pagination
- Regular series seasons resource
- VOD-series (`is_series=1`) seasons + episodes resources
- Playback link creation (`create_link` flow)
@@ -98,6 +103,25 @@ Failure-handling rule:
not `undefined` collections or renderer exceptions. The workspace Stalker
context panel and live layout rely on this guarantee.
## Live TV and Radio
The Stalker live route and radio route intentionally share
`StalkerLiveStreamLayoutComponent`:
- `itv` uses `type=itv&action=get_ordered_list`, stores results in
`itvChannels`, resolves playback through `resolveItvPlayback(...)`, and keeps
the EPG panel visible.
- `radio` uses `type=radio&action=get_ordered_list`, stores results in
`radioChannels`, resolves playback through `resolveRadioPlayback(...)`, and
renders `AudioPlayerComponent` instead of a video player.
- Radio hides the EPG panel and must not call Stalker EPG endpoints because
radio stations do not have EPG data.
- Radio always uses the inline audio player. External player settings are
ignored for Stalker radio, matching M3U radio behavior.
- Some Stalker portals do not expose radio categories. Radio category loading
falls back to a synthetic `PORTALS.ALL_RADIO` category with
`category_id: '*'` so the station list can still be loaded.
## VOD/Series Modes
Stalker has multiple real-world data shapes. The current implementation supports all three:
@@ -13,7 +13,7 @@ Goal: keep this public surface stable while splitting to feature stores.
Direct signal properties currently exposed by `signalStore`:
- `selectedContentType: 'vod' | 'itv' | 'series'`
- `selectedContentType: 'vod' | 'itv' | 'radio' | 'series'`
- `selectedCategoryId: string | null | undefined`
- `selectedVodId: string | undefined`
- `selectedSerialId: string | undefined`
@@ -27,8 +27,10 @@ Direct signal properties currently exposed by `signalStore`:
- `vodCategories: StalkerCategoryItem[]`
- `seriesCategories: StalkerCategoryItem[]`
- `itvCategories: StalkerCategoryItem[]`
- `radioCategories: StalkerCategoryItem[]`
- `hasMoreChannels: boolean`
- `itvChannels: StalkerItvChannel[]`
- `radioChannels: StalkerItvChannel[]`
- `vodSeriesSeasons: StalkerVodSeriesSeason[]`
- `vodSeriesEpisodes: StalkerVodSeriesEpisode[]`
- `selectedVodSeriesSeasonId: string | undefined`
@@ -66,7 +68,7 @@ During refactor:
## Public Methods (Compatibility Contract)
- `setSelectedContentType(type: 'vod' | 'itv' | 'series'): void`
- `setSelectedContentType(type: 'vod' | 'itv' | 'radio' | 'series'): void`
- `setSelectedCategory(id: string | number | null): void`
- `setSelectedSerialId(id: string): void`
- `setSelectedVodId(id: string): void`
@@ -76,15 +78,18 @@ During refactor:
- `setCurrentPlaylist(playlist: PlaylistMeta | undefined): Promise<void>`
- `setSelectedItem(selectedItem: StalkerVodSource | null | undefined): void`
- `clearSelectedItem(): void`
- `setCategories(type: 'vod' | 'series' | 'itv', categories: StalkerCategoryItem[]): void`
- `setCategories(type: 'vod' | 'series' | 'itv' | 'radio', categories: StalkerCategoryItem[]): void`
- `resetCategories(): void`
- `setItvChannels(channels: StalkerItvChannel[]): void`
- `setRadioChannels(channels: StalkerItvChannel[]): void`
- `setSearchPhrase(phrase: string): void`
- `fetchVodSeriesEpisodes(videoId: string, seasonId: string): Promise<StalkerVodSeriesEpisode[]>`
- `getSelectedCategory(): { id: string | number; name: string; type: 'vod' | 'itv' | 'series' }`
- `getSelectedCategory(): { id: string | number; name: string; type: 'vod' | 'itv' | 'radio' | 'series' }`
Backed by `withComputed` for compatibility, not by `withMethods`.
- `fetchLinkToPlay(portalUrl: string, macAddress: string, cmd: string, series?: number): Promise<string>`
- `getExpireDate(): Promise<string>`
- `resolveItvPlayback(item: StalkerPortalItem): Promise<ResolvedPortalPlayback>`
- `resolveRadioPlayback(item: StalkerPortalItem): Promise<ResolvedPortalPlayback>`
- `addToFavorites(item: any, onDone?: () => void): void`
- `removeFromFavorites(favoriteId: string, onDone?: () => void): void`
- `fetchMovieFileId(movieId: string): Promise<string | null>`
@@ -122,6 +127,9 @@ Consumer directories sampled:
- `setSelectedCategory(...)` resets `page` to `0`.
- `getPaginatedContent()` and `getCategoryResource()` always return arrays,
even when the underlying request fails.
- Radio stores stations separately in `radioChannels` and falls back to a
synthetic all-radio category when a portal does not support radio category
responses.
- Request failures must surface through `isPaginatedContentFailed()` and
`isCategoryResourceFailed()` rather than resource reads that throw.
- `createLinkToPlayVod(...)` continues to:
+1 -1
View File
@@ -90,7 +90,7 @@ The shell decides which secondary panel to show from the current route:
1. `WorkspaceSourcesFiltersPanelComponent`
2. Xtream category sections (`live`, `vod`, `series`)
1. `WorkspaceContextPanelComponent`
3. Stalker category sections (`itv`, `vod`, `series`)
3. Stalker category sections (`itv`, `radio`, `vod`, `series`)
1. `WorkspaceContextPanelComponent`
4. `/workspace/settings`
1. `WorkspaceSettingsContextPanelComponent`
@@ -55,11 +55,13 @@ describe('buildPortalRailLinks', () => {
expect(links.primary.map((link) => link.section)).toEqual([
'vod',
'itv',
'radio',
'series',
]);
expect(links.secondary.map((link) => link.section)).toEqual(['search']);
expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)');
expect(links.primary[2]?.tooltip).toBe('Radio (this playlist)');
expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)');
});
@@ -9,6 +9,7 @@ export type PortalRailSection =
| 'live'
| 'recent'
| 'recently-added'
| 'radio'
| 'search'
| 'series'
| 'vod';
@@ -118,6 +119,12 @@ export function buildPortalRailLinks(
path: [...root, 'itv'],
section: 'itv',
},
{
icon: 'radio',
tooltip: 'Radio (this playlist)',
path: [...root, 'radio'],
section: 'radio',
},
{
icon: 'tv',
tooltip: 'Series (this playlist)',
@@ -23,10 +23,10 @@ export const StalkerContentTypes = {
getCategoryAction: StalkerPortalActions.GetCategories,
getLink: StalkerPortalActions.CreateLink,
},
/* radio: {
radio: {
title: 'Radio',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetCategories,
getLink: StalkerPortalActions.CreateLink,
}, */
},
};
@@ -29,7 +29,7 @@ const PLAYLIST = {
const TestContentStore = signalStore(
withState({
currentPlaylist: undefined as PlaylistMeta | undefined,
selectedContentType: 'vod' as 'vod' | 'series' | 'itv',
selectedContentType: 'vod' as 'vod' | 'series' | 'itv' | 'radio',
selectedCategoryId: undefined as string | null | undefined,
searchPhrase: '',
page: 0,
@@ -39,7 +39,7 @@ const TestContentStore = signalStore(
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
},
setSelectedContentType(type: 'vod' | 'series' | 'itv') {
setSelectedContentType(type: 'vod' | 'series' | 'itv' | 'radio') {
patchState(store, { selectedContentType: type });
},
setSelectedCategory(id: string | null | undefined) {
@@ -297,6 +297,88 @@ describe('withStalkerContent failure states', () => {
expect(store.hasMoreChannels()).toBe(false);
});
it('falls back to a synthetic all-radio category when radio categories are unavailable', async () => {
dataService.sendIpcEvent.mockRejectedValue(
new Error('radio categories unsupported')
);
store.setSelectedContentType('radio');
store.setCurrentPlaylist(PLAYLIST);
void store.isCategoryResourceLoading();
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
await flushResources();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.GetCategories,
type: 'radio',
}),
})
);
expect(store.getCategoryResource()).toEqual([
{
category_id: '*',
category_name: 'PORTALS.ALL_RADIO',
},
]);
expect(store.radioCategories()).toEqual([
{
category_id: '*',
category_name: 'PORTALS.ALL_RADIO',
},
]);
expect(store.isCategoryResourceFailed()).toBeNull();
});
it('loads radio stations separately from live TV channels', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [
{
id: 'radio-1',
name: 'Jazz FM',
cmd: 'ifm https://stream.example/jazz.mp3',
},
],
total_items: 1,
},
});
store.setSelectedContentType('radio');
store.setCategories('radio', [
{
category_id: 'radio-all',
category_name: 'Radio',
},
]);
store.setSelectedCategory('radio-all');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.radioChannels().length === 1);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
params: expect.objectContaining({
action: StalkerPortalActions.GetOrderedList,
type: 'radio',
category: 'radio-all',
p: 1,
}),
})
);
expect(store.radioChannels().map((channel) => channel.name)).toEqual([
'Jazz FM',
]);
expect(store.itvChannels()).toEqual([]);
});
it('ignores stale content responses after the selected page changes', async () => {
const pendingByPage = new Map<
number,
@@ -38,8 +38,10 @@ export interface StalkerContentState {
vodCategories: StalkerCategoryItem[];
seriesCategories: StalkerCategoryItem[];
itvCategories: StalkerCategoryItem[];
radioCategories: StalkerCategoryItem[];
hasMoreChannels: boolean;
itvChannels: StalkerItvChannel[];
radioChannels: StalkerItvChannel[];
paginatedContent: StalkerContentItem[];
categoryError: unknown;
contentError: unknown;
@@ -50,8 +52,10 @@ const initialContentState: StalkerContentState = {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
radioCategories: [],
hasMoreChannels: false,
itvChannels: [],
radioChannels: [],
paginatedContent: [],
categoryError: null,
contentError: null,
@@ -89,6 +93,8 @@ function getCategoriesByType(
return store.seriesCategories();
case 'itv':
return store.itvCategories();
case 'radio':
return store.radioCategories();
}
}
@@ -103,10 +109,27 @@ function buildCategoryPatch(
return { seriesCategories: categories };
case 'itv':
return { itvCategories: categories };
case 'radio':
return { radioCategories: categories };
}
}
function buildAllCategory(
contentType: StalkerContentType,
translateService: TranslateService
): StalkerCategoryItem {
return {
category_name: translateService.instant(
contentType === 'radio'
? 'PORTALS.ALL_RADIO'
: 'PORTALS.ALL_CATEGORIES'
),
category_id: '*',
};
}
function prependAllCategory(
contentType: StalkerContentType,
categories: StalkerCategoryItem[],
translateService: TranslateService
): StalkerCategoryItem[] {
@@ -124,15 +147,18 @@ function prependAllCategory(
categories.length > 0 &&
!categories.some((category) => String(category.category_id) === '*')
) {
categories.unshift({
category_name: translateService.instant('PORTALS.ALL_CATEGORIES'),
category_id: '*',
});
categories.unshift(buildAllCategory(contentType, translateService));
}
return categories;
}
function fallbackRadioCategories(
translateService: TranslateService
): StalkerCategoryItem[] {
return [buildAllCategory('radio', translateService)];
}
function buildEmptyContentPatch(
contentType: StalkerContentType,
error: unknown
@@ -143,9 +169,13 @@ function buildEmptyContentPatch(
contentError: error,
};
if (contentType === 'itv') {
if (contentType === 'itv' || contentType === 'radio') {
patch.hasMoreChannels = false;
patch.itvChannels = [];
if (contentType === 'itv') {
patch.itvChannels = [];
} else {
patch.radioChannels = [];
}
}
return patch;
@@ -214,6 +244,17 @@ export function withStalkerContent() {
'Invalid categories response',
response
);
if (params.contentType === 'radio') {
const fallback =
fallbackRadioCategories(
translateService
);
patchState(store, {
radioCategories: fallback,
categoryError: null,
});
return fallback;
}
patchState(store, {
...buildCategoryPatch(
params.contentType,
@@ -224,19 +265,26 @@ export function withStalkerContent() {
return [];
}
const categories = prependAllCategory(
response.js
.map(
(item): StalkerCategoryItem => ({
category_name: item.title ?? '',
category_id: String(item.id),
})
const normalizedCategories = response.js
.map(
(item): StalkerCategoryItem => ({
category_name: item.title ?? '',
category_id: String(item.id),
})
)
.sort((left, right) =>
left.category_name.localeCompare(
right.category_name
)
.sort((left, right) =>
left.category_name.localeCompare(
right.category_name
)
),
);
const categories = prependAllCategory(
params.contentType,
params.contentType === 'radio' &&
normalizedCategories.length === 0
? fallbackRadioCategories(
translateService
)
: normalizedCategories,
translateService
);
@@ -254,6 +302,16 @@ export function withStalkerContent() {
contentType: params.contentType,
error,
});
if (params.contentType === 'radio') {
const fallback = fallbackRadioCategories(
translateService
);
patchState(store, {
radioCategories: fallback,
categoryError: null,
});
return fallback;
}
patchState(store, {
...buildCategoryPatch(
params.contentType,
@@ -365,9 +423,12 @@ export function withStalkerContent() {
queryParams['category'] = categoryParam;
} else if (params.contentType === 'series') {
queryParams['category'] = categoryParam;
} else {
} else if (params.contentType === 'itv') {
queryParams['category'] = categoryParam;
queryParams['genre'] = categoryParam;
} else {
queryParams['category'] = categoryParam;
queryParams['sortby'] = 'number';
}
try {
@@ -411,14 +472,21 @@ export function withStalkerContent() {
)
);
if (params.contentType === 'itv') {
if (
params.contentType === 'itv' ||
params.contentType === 'radio'
) {
const channels =
newItems.map(toStalkerItvChannel);
const existingChannels =
params.contentType === 'itv'
? store.itvChannels()
: store.radioChannels();
const nextChannels =
params.pageIndex === 1
? channels
: [
...store.itvChannels(),
...existingChannels,
...channels,
];
@@ -427,7 +495,9 @@ export function withStalkerContent() {
response.js.total_items ?? 0,
paginatedContent: newItems,
contentError: null,
itvChannels: nextChannels,
...(params.contentType === 'itv'
? { itvChannels: nextChannels }
: { radioChannels: nextChannels }),
hasMoreChannels:
nextChannels.length <
(response.js.total_items ?? 0),
@@ -547,12 +617,16 @@ export function withStalkerContent() {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
radioCategories: [],
categoryError: null,
});
},
setItvChannels(channels: StalkerItvChannel[]) {
patchState(store, { itvChannels: channels });
},
setRadioChannels(channels: StalkerItvChannel[]) {
patchState(store, { radioChannels: channels });
},
}))
);
}
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStore, withState } from '@ngrx/signals';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
@@ -34,15 +34,41 @@ const PLAYLIST = {
const TestPlayerStore = signalStore(
withState({
currentPlaylist: PLAYLIST,
selectedContentType: 'vod' as 'vod' | 'series' | 'itv',
selectedContentType: 'vod' as 'vod' | 'series' | 'itv' | 'radio',
selectedItem: {
id: '22',
cmd: '/media/source_22.mpg',
has_files: true,
title: 'Original Title',
category_id: 'vod',
} as {
id: string;
cmd: string;
has_files?: boolean;
title?: string;
name?: string;
o_name?: string;
logo?: string;
category_id?: string;
cover?: string;
},
}),
withMethods((store) => ({
setSelectedContentType(type: 'vod' | 'series' | 'itv' | 'radio') {
patchState(store, { selectedContentType: type });
},
setSelectedItem(item: {
id: string;
cmd: string;
title?: string;
name?: string;
o_name?: string;
logo?: string;
category_id?: string;
}) {
patchState(store, { selectedItem: item });
},
})),
withStalkerPlayer()
);
@@ -175,4 +201,44 @@ describe('withStalkerPlayer', () => {
seriesXtreamId: undefined,
});
});
it('resolves direct radio stream commands without external player side effects', async () => {
store.setSelectedContentType('radio');
store.setSelectedItem({
id: 'radio-1',
cmd: 'ifm https://stream.example/jazz.mp3',
name: 'Jazz FM',
o_name: 'Jazz FM',
logo: 'jazz.png',
category_id: '4001',
});
const playback = await store.resolveRadioPlayback({
id: 'radio-1',
cmd: 'ifm https://stream.example/jazz.mp3',
name: 'Jazz FM',
o_name: 'Jazz FM',
logo: 'jazz.png',
category_id: '4001',
});
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith(
PLAYLIST._id,
expect.objectContaining({
id: 'radio-1',
title: 'Jazz FM',
category_id: '4001',
cover: 'jazz.png',
added_at: expect.any(Number),
})
);
expect(playback).toEqual(
expect.objectContaining({
streamUrl: 'https://stream.example/jazz.mp3',
title: 'Jazz FM',
thumbnail: 'jazz.png',
})
);
});
});
@@ -31,6 +31,7 @@ import {
fetchStalkerExpireDate,
fetchStalkerMovieFileId,
fetchStalkerPlaybackLink,
normalizeStalkerPlaybackCommand,
shouldResolveMovieFileId,
} from '../utils';
@@ -277,6 +278,56 @@ export function withStalkerPlayer() {
};
};
const resolveRadioPlaybackInternal = async (
item: StalkerPlayableItem
): Promise<ResolvedPortalPlayback> => {
const playlist = storeState.currentPlaylist();
if (
!playlist?.portalUrl ||
!playlist.macAddress ||
!item.cmd
) {
throw new Error('nothing_to_play');
}
let streamUrl = normalizeStalkerPlaybackCommand(item.cmd);
if (
!streamUrl.startsWith('http://') &&
!streamUrl.startsWith('https://')
) {
streamUrl = await fetchStalkerPlaybackLink(
requestDeps,
{
playlist,
selectedContentType:
storeState.selectedContentType(),
cmd: item.cmd,
forcedContentType: 'radio',
}
);
}
if (!streamUrl) {
throw new Error('nothing_to_play');
}
recordRecentlyViewed(
item,
item.cmd,
item.logo ?? item.cover,
item.o_name || item.name || item.title
);
return {
streamUrl,
title: item.o_name || item.name || item.title || '',
thumbnail: item.logo ?? item.cover ?? null,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
};
};
return {
async fetchLinkToPlay(
portalUrl: string,
@@ -347,6 +398,11 @@ export function withStalkerPlayer() {
): Promise<ResolvedPortalPlayback> {
return resolveItvPlaybackInternal(item);
},
async resolveRadioPlayback(
item: StalkerPlayableItem
): Promise<ResolvedPortalPlayback> {
return resolveRadioPlaybackInternal(item);
},
async createLinkToPlayVod(
cmd?: string,
title?: string,
@@ -11,7 +11,7 @@ import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
* Selection/pagination/search feature state.
*/
export interface StalkerSelectionState {
selectedContentType: 'vod' | 'itv' | 'series';
selectedContentType: 'vod' | 'itv' | 'series' | 'radio';
selectedCategoryId: string | null | undefined;
selectedVodId: string | undefined;
selectedSerialId: string | undefined;
@@ -38,7 +38,9 @@ export function withStalkerSelection() {
return signalStoreFeature(
withState<StalkerSelectionState>(initialSelectionState),
withMethods((store) => ({
setSelectedContentType(type: 'vod' | 'itv' | 'series') {
setSelectedContentType(
type: 'vod' | 'itv' | 'series' | 'radio'
) {
patchState(store, { selectedContentType: type });
},
setSelectedCategory(id: string | number | null) {
@@ -1,3 +1,3 @@
export * from './features';
export * from './stalker-store.contracts';
export * from './utils';
@@ -7,7 +7,7 @@ import {
StalkerVodSource,
} from '../models';
export type StalkerContentType = 'vod' | 'series' | 'itv';
export type StalkerContentType = 'vod' | 'series' | 'itv' | 'radio';
export interface ResourceState<T> {
value(): T;
@@ -35,6 +35,7 @@ export interface StalkerCategorySliceContract {
vodCategories(): StalkerCategoryItem[];
seriesCategories(): StalkerCategoryItem[];
itvCategories(): StalkerCategoryItem[];
radioCategories(): StalkerCategoryItem[];
}
export interface StalkerContentFeatureStoreContract
@@ -24,6 +24,7 @@ import {
createStalkerInlineDetailState,
isSelectedStalkerVodFavorite,
isStalkerSeriesFlag,
StalkerContentType,
StalkerSelectedVodItem,
StalkerStore,
toggleStalkerVodFavorite,
@@ -35,7 +36,7 @@ import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-in
interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
selectedContentType: 'vod' | 'itv' | 'series';
selectedContentType: StalkerContentType;
selectedCategoryId: string | null | undefined;
selectedItem: unknown;
}
@@ -74,6 +74,10 @@ export function createStalkerRoutes(): Route[] {
path: 'itv',
loadComponent: loadStalkerLiveStreamLayoutComponent,
},
{
path: 'radio',
loadComponent: loadStalkerLiveStreamLayoutComponent,
},
{
path: 'series',
providers: [
@@ -108,48 +108,64 @@
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
} @else if (stalkerStore.selectedItem()) {
@if (streamUrl() && isEmbeddedPlayer) {
@if (streamUrl() && (isEmbeddedPlayer || isRadioMode())) {
<div class="video-player">
<app-web-player-view
[streamUrl]="streamUrl()"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
/>
@if (isRadioMode()) {
<app-audio-player
[url]="streamUrl()"
[icon]="activePlaybackArtwork()"
[channelName]="activePlaybackTitle()"
[dispatchAdjacentChannelAction]="false"
(channelSwitchRequested)="
handleRadioChannelSwitch($event)
"
/>
} @else {
<app-web-player-view
[streamUrl]="streamUrl()"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
/>
}
</div>
}
@let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer;
<div
class="epg"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
>
@if (canCollapseEpgPanel) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
@if (!isRadioMode()) {
@let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer;
<div
class="epg"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
>
@if (canCollapseEpgPanel) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
"
/>
</app-live-epg-panel>
} @else {
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
context: { unifiedDateNavigator: false }
"
/>
</app-live-epg-panel>
} @else {
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: false }
"
/>
}
</div>
}
</div>
}
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -1,5 +1,6 @@
import { Component, Directive, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
@@ -10,6 +11,7 @@ import {
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { MockPipe } from 'ng-mocks';
@@ -53,6 +55,19 @@ class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
}
@Component({
selector: 'app-audio-player',
standalone: true,
template: '',
})
class StubAudioPlayerComponent {
readonly url = input.required<string>();
readonly icon = input('');
readonly channelName = input('');
readonly dispatchAdjacentChannelAction = input(true);
readonly channelSwitchRequested = output<'next' | 'previous'>();
}
@Component({
selector: 'app-epg-list',
standalone: true,
@@ -108,6 +123,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
let fetchChannelEpg: jest.Mock;
let ensureBulkItvEpg: jest.Mock;
let resolveItvPlayback: jest.Mock;
let resolveRadioPlayback: jest.Mock;
const playlist = signal({
_id: 'playlist-1',
@@ -131,6 +147,22 @@ describe('StalkerLiveStreamLayoutComponent', () => {
logo: 'beta.png',
},
]);
const radioChannels = signal([
{
id: 'radio-1',
cmd: 'ifm https://stream.example/jazz.mp3',
name: 'Jazz FM',
o_name: 'Jazz FM',
logo: 'jazz.png',
},
{
id: 'radio-2',
cmd: 'ifm https://stream.example/news.mp3',
name: 'News Radio',
o_name: 'News Radio',
logo: 'news-radio.png',
},
]);
const selectedItvId = signal<string | undefined>('10001');
const selectedItem = signal<{
id: string;
@@ -151,6 +183,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
const stalkerStore = {
getSelectedCategoryName: signal('News'),
itvChannels,
radioChannels,
searchPhrase,
hasMoreChannels,
selectedItvId,
@@ -163,9 +196,10 @@ describe('StalkerLiveStreamLayoutComponent', () => {
isLoadingBulkItvEpg,
selectedCategoryId,
selectedItem,
selectedContentType: signal<'itv' | 'vod' | 'series'>('itv'),
selectedContentType: signal<'itv' | 'vod' | 'series' | 'radio'>('itv'),
page,
setItvChannels: jest.fn(),
setRadioChannels: jest.fn(),
setPage: jest.fn(),
setSelectedItem: jest.fn((item) => {
selectedItem.set(item);
@@ -175,6 +209,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
);
}),
resolveItvPlayback: jest.fn(),
resolveRadioPlayback: jest.fn(),
fetchChannelEpg: jest.fn(),
ensureBulkItvEpg: jest.fn(),
clearBulkItvEpgCache: jest.fn(() => {
@@ -203,9 +238,11 @@ describe('StalkerLiveStreamLayoutComponent', () => {
fetchChannelEpg = stalkerStore.fetchChannelEpg;
ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg;
resolveItvPlayback = stalkerStore.resolveItvPlayback;
resolveRadioPlayback = stalkerStore.resolveRadioPlayback;
selectedCategoryId.set('1001');
searchPhrase.set('');
stalkerStore.selectedContentType.set('itv');
selectedItvId.set('10001');
selectedItem.set(itvChannels()[0]);
selectedItvEpgPrograms.set([]);
@@ -223,6 +260,12 @@ describe('StalkerLiveStreamLayoutComponent', () => {
resolveItvPlayback.mockResolvedValue({
streamUrl: 'https://example.com/alpha.m3u8',
});
resolveRadioPlayback.mockReset();
resolveRadioPlayback.mockResolvedValue({
streamUrl: 'https://stream.example/jazz.mp3',
title: 'Jazz FM',
thumbnail: 'jazz.png',
});
portalPlayer.isEmbeddedPlayer.mockReset();
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
portalPlayer.openResolvedPlayback.mockClear();
@@ -243,6 +286,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
);
});
stalkerStore.setItvChannels.mockClear();
stalkerStore.setRadioChannels.mockClear();
stalkerStore.setPage.mockReset();
stalkerStore.setPage.mockImplementation((nextPage: number) => {
if (page() === nextPage) {
@@ -279,6 +323,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
remove: {
imports: [
ChannelListItemComponent,
AudioPlayerComponent,
EpgListComponent,
LiveEpgPanelComponent,
PortalEmptyStateComponent,
@@ -290,6 +335,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
add: {
imports: [
StubChannelListItemComponent,
StubAudioPlayerComponent,
StubEpgListComponent,
StubLiveEpgPanelComponent,
StubPortalEmptyStateComponent,
@@ -524,6 +570,36 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(ensureBulkItvEpg).toHaveBeenCalledTimes(1);
expect(ensureBulkItvEpg).toHaveBeenCalledWith(168);
});
it('renders inline audio playback and no EPG for radio stations', async () => {
stalkerStore.selectedContentType.set('radio');
selectedCategoryId.set('radio-all');
selectedItem.set(null);
selectedItvId.set(undefined);
fixture.detectChanges();
await component.playChannel(radioChannels()[0]);
await fixture.whenStable();
fixture.detectChanges();
expect(resolveRadioPlayback).toHaveBeenCalledWith(radioChannels()[0]);
expect(resolveItvPlayback).not.toHaveBeenCalled();
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
expect(fetchChannelEpg).not.toHaveBeenCalled();
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-audio-player')
).not.toBeNull();
const audioPlayer = fixture.debugElement.query(
By.directive(StubAudioPlayerComponent)
).componentInstance as StubAudioPlayerComponent;
expect(audioPlayer.url()).toBe('https://stream.example/jazz.mp3');
expect(audioPlayer.icon()).toBe('jazz.png');
expect(audioPlayer.channelName()).toBe('Jazz FM');
expect(audioPlayer.dispatchAdjacentChannelAction()).toBe(false);
});
});
function buildProgram(channelId: string, title: string): EpgProgram {
@@ -38,6 +38,7 @@ import {
getTodayEpgDateKey,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
import {
LiveEpgPanelComponent,
LiveEpgPanelSummary,
@@ -67,6 +68,7 @@ import {
templateUrl: './stalker-live-stream-layout.component.html',
styleUrls: ['./stalker-live-stream-layout.component.scss'],
imports: [
AudioPlayerComponent,
ChannelListItemComponent,
ChannelListSkeletonComponent,
EpgListComponent,
@@ -97,12 +99,19 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
/** Channels */
readonly isRadioMode = computed(
() => this.stalkerStore.selectedContentType() === 'radio'
);
readonly itvChannels = this.stalkerStore.itvChannels;
readonly radioChannels = this.stalkerStore.radioChannels;
readonly channels = computed(() =>
this.isRadioMode() ? this.radioChannels() : this.itvChannels()
);
readonly searchTerm = computed(() =>
this.stalkerStore.searchPhrase().trim().toLowerCase()
);
readonly visibleChannels = computed(() => {
const channels = this.itvChannels();
const channels = this.channels();
const term = this.searchTerm();
if (!term) {
@@ -120,7 +129,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly isInitialChannelsLoading = computed(
() =>
!!this.stalkerStore.selectedCategoryId() &&
this.itvChannels().length === 0 &&
this.channels().length === 0 &&
!this.searchTerm()
);
@@ -136,6 +145,20 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? '');
readonly activePlaybackTitle = computed(
() =>
this.activePlayback()?.title ||
this.stalkerStore.selectedItem()?.o_name ||
this.stalkerStore.selectedItem()?.name ||
''
);
readonly activePlaybackArtwork = computed(
() =>
this.activePlayback()?.thumbnail ||
this.stalkerStore.selectedItem()?.logo ||
this.stalkerStore.selectedItem()?.cover ||
''
);
/** EPG */
readonly fallbackEpgPrograms = signal<EpgProgram[]>([]);
@@ -238,9 +261,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
// Reset channels/page on category change
effect(() => {
const contentType = this.stalkerStore.selectedContentType();
this.stalkerStore.selectedCategoryId();
untracked(() => {
this.stalkerStore.setItvChannels([]);
if (contentType === 'radio') {
this.stalkerStore.setRadioChannels([]);
} else {
this.stalkerStore.setItvChannels([]);
}
this.stalkerStore.setPage(0);
this.clearEpgPreviewMaps();
this.epgLoadRequestId += 1;
@@ -259,6 +287,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
}
if (this.isRadioMode()) {
this.clearEpgPreviewMaps();
this.cdr.markForCheck();
return;
}
this.syncBulkEpgPreviews(channels);
});
@@ -361,6 +395,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.stalkerStore.setSelectedItem(item);
try {
const isRadioMode = this.isRadioMode();
const playback = await this.resolvePlaybackForChannel(
item,
channelId
@@ -372,6 +407,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return;
}
if (isRadioMode) {
this.activePlayback.set(playback);
return;
}
void this.loadEpgForChannel(item);
if (this.usesEmbeddedPlayer()) {
@@ -397,12 +437,18 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
item: StalkerItvChannel,
channelId: string
): Promise<ResolvedPortalPlayback> {
if (this.playbackResolution?.channelId === channelId) {
const playbackChannelId = [
this.stalkerStore.selectedContentType(),
channelId,
].join(':');
if (this.playbackResolution?.channelId === playbackChannelId) {
return this.playbackResolution.promise;
}
const promise = this.stalkerStore.resolveItvPlayback(item);
this.playbackResolution = { channelId, promise };
const promise = this.isRadioMode()
? this.stalkerStore.resolveRadioPlayback(item)
: this.stalkerStore.resolveItvPlayback(item);
this.playbackResolution = { channelId: playbackChannelId, promise };
const cleanup = () => {
if (this.playbackResolution?.promise === promise) {
@@ -423,7 +469,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
} else {
this.stalkerStore.addToFavorites({
...item,
category_id: 'itv',
category_id: this.isRadioMode() ? 'radio' : 'itv',
title: item.o_name || item.name,
cover: item.logo,
added_at: new Date().toISOString(),
@@ -449,6 +495,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.liveSidebarStateService.toggle();
}
handleRadioChannelSwitch(direction: 'next' | 'previous'): void {
this.handleAdjacentChannelChange(
direction === 'next' ? 'down' : 'up'
);
}
@HostListener('document:keydown', ['$event'])
handleSidebarShortcut(event: KeyboardEvent): void {
if (
@@ -715,6 +767,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
private handleRemoteChannelChange(direction: 'up' | 'down'): void {
this.handleAdjacentChannelChange(direction);
}
private handleAdjacentChannelChange(direction: 'up' | 'down'): void {
const activeItem = this.stalkerStore.selectedItem();
if (!activeItem?.id) {
return;
@@ -33,7 +33,9 @@ function getStalkerSectionFromUrl(url: string): string | null {
describe('StalkerWorkspaceRouteSession', () => {
const routerEvents = new Subject<NavigationEnd>();
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
const selectedContentType = signal<'vod' | 'itv' | 'series'>('vod');
const selectedContentType = signal<'vod' | 'itv' | 'series' | 'radio'>(
'vod'
);
const playlistContext = {
activePlaylist,
@@ -45,9 +47,11 @@ describe('StalkerWorkspaceRouteSession', () => {
setSelectedCategory: jest.fn(),
clearSelectedItem: jest.fn(),
setCurrentPlaylist: jest.fn().mockResolvedValue(undefined),
setSelectedContentType: jest.fn((type: 'vod' | 'itv' | 'series') => {
selectedContentType.set(type);
}),
setSelectedContentType: jest.fn(
(type: 'vod' | 'itv' | 'series' | 'radio') => {
selectedContentType.set(type);
}
),
setSearchPhrase: jest.fn(),
};
@@ -72,6 +76,7 @@ describe('StalkerWorkspaceRouteSession', () => {
section: getStalkerSectionFromUrl(url) as
| 'favorites'
| 'itv'
| 'radio'
| 'recent'
| 'search'
| 'series'
@@ -128,4 +133,19 @@ describe('StalkerWorkspaceRouteSession', () => {
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
);
});
it('keeps the radio route selection after playlist bootstrap', async () => {
router.url = `/workspace/stalker/${PLAYLIST_ID}/radio`;
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith(
'radio'
);
expect(selectedContentType()).toBe('radio');
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith(null);
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(stalkerStore.setSearchPhrase).toHaveBeenCalledWith('');
});
});
@@ -11,11 +11,12 @@ import { NavigationEnd, Router } from '@angular/router';
import { filter, firstValueFrom } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
StalkerContentType,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
type StalkerContentType = 'vod' | 'series' | 'itv';
@Injectable()
export class StalkerWorkspaceRouteSession {
private readonly destroyRef = inject(DestroyRef);
@@ -85,13 +86,21 @@ export class StalkerWorkspaceRouteSession {
this.currentSection.set(section);
}
if (section === 'vod' || section === 'series' || section === 'itv') {
if (
section === 'vod' ||
section === 'series' ||
section === 'itv' ||
section === 'radio'
) {
this.stalkerStore.setSelectedContentType(
section as StalkerContentType
);
}
if (section === 'itv' && previousSection !== 'itv') {
if (
(section === 'itv' || section === 'radio') &&
previousSection !== section
) {
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
this.stalkerStore.setSearchPhrase('');
@@ -7,6 +7,7 @@ import {
effect,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
@@ -126,6 +127,8 @@ export class AudioPlayerComponent {
readonly icon = input<string>('');
readonly url = input.required<string>();
readonly channelName = input<string>('');
readonly dispatchAdjacentChannelAction = input(true);
readonly channelSwitchRequested = output<'next' | 'previous'>();
readonly playState = signal<'play' | 'paused'>('paused');
readonly volume = signal(1);
@@ -238,9 +241,12 @@ export class AudioPlayerComponent {
}
switchChannel(direction: 'next' | 'previous') {
this.store.dispatch(
ChannelActions.setAdjacentChannelAsActive({ direction })
);
if (this.dispatchAdjacentChannelAction()) {
this.store.dispatch(
ChannelActions.setAdjacentChannelAsActive({ direction })
);
} else {
this.channelSwitchRequested.emit(direction);
}
}
}
+4 -1
View File
@@ -73,7 +73,10 @@
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
// Player internals, including the radio audio stage, may use z-index
// 2 inside the content area. Keep the restore affordance above them
// for both paint order and hit testing.
z-index: 5;
background: rgba(128, 128, 128, 0.18);
color: var(--mat-sys-on-surface, rgba(255, 255, 255, 0.75));
box-shadow: none;
@@ -14,6 +14,7 @@ import { WorkspaceContextPanelComponent } from './workspace-context-panel.compon
const translations: Record<string, string> = {
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES': 'Manage categories',
'WORKSPACE.CONTEXT.RADIO_CATEGORIES': 'Radio Categories',
'WORKSPACE.CONTEXT.XTREAM_SYNCING_LIVE': 'Syncing live categories...',
'WORKSPACE.CONTEXT.XTREAM_SYNCING_MOVIES': 'Syncing movie categories...',
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING':
@@ -53,7 +54,9 @@ describe('WorkspaceContextPanelComponent', () => {
reloadCategories: jest.fn(),
};
const stalkerStore = {
getCategoryResource: signal([]),
getCategoryResource: signal<
Array<{ category_id: string; category_name: string }>
>([]),
selectedCategoryId: signal<string | null>(null),
isCategoryResourceLoading: signal(false),
isCategoryResourceFailed: signal(false),
@@ -82,6 +85,11 @@ describe('WorkspaceContextPanelComponent', () => {
xtreamStore.setSelectedItem.mockClear();
xtreamStore.setSelectedCategory.mockClear();
xtreamStore.reloadCategories.mockClear();
stalkerStore.getCategoryResource.set([]);
stalkerStore.selectedCategoryId.set(null);
stalkerStore.setSelectedCategory.mockClear();
stalkerStore.setPage.mockClear();
stalkerStore.clearSelectedItem.mockClear();
router.navigate.mockClear();
dialog.open.mockClear();
@@ -217,4 +225,32 @@ describe('WorkspaceContextPanelComponent', () => {
2,
]);
});
it('labels Stalker radio categories and keeps category selection in the radio layout', () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'radio');
stalkerStore.getCategoryResource.set([
{ category_id: '*', category_name: 'All radio' },
]);
fixture.detectChanges();
const title = fixture.nativeElement.querySelector(
'.context-header h2'
) as HTMLElement;
const categoryButton = fixture.nativeElement.querySelector(
'.category-item'
) as HTMLButtonElement;
expect(title.textContent).toContain('Radio Categories');
categoryButton.click();
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith('*');
expect(stalkerStore.setPage).toHaveBeenCalledWith(0);
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
});
@@ -69,7 +69,8 @@ export class WorkspaceContextPanelComponent {
this.context().provider === 'stalker' &&
(this.section() === 'vod' ||
this.section() === 'series' ||
this.section() === 'itv')
this.section() === 'itv' ||
this.section() === 'radio')
);
readonly xtreamCategories = this.xtreamStore.getCategoriesBySelectedType;
@@ -192,6 +193,9 @@ export class WorkspaceContextPanelComponent {
if (this.section() === 'series') {
return 'WORKSPACE.CONTEXT.SERIES_CATEGORIES';
}
if (this.section() === 'radio') {
return 'WORKSPACE.CONTEXT.RADIO_CATEGORIES';
}
return 'WORKSPACE.CONTEXT.LIVE_CATEGORIES';
}
@@ -319,7 +323,7 @@ export class WorkspaceContextPanelComponent {
this.stalkerStore.setPage(0);
this.stalkerStore.clearSelectedItem();
if (section === 'itv') {
if (section === 'itv' || section === 'radio') {
return;
}
@@ -19,7 +19,11 @@ import { WorkspaceContextPanelComponent } from '../../../workspace-context-panel
import { WorkspaceSettingsContextPanelComponent } from '../../../workspace-context-panel/workspace-settings-context-panel.component';
import { WorkspaceSourcesFiltersPanelComponent } from '../../../workspace-sources-filters-panel/workspace-sources-filters-panel.component';
const LIVE_SECTIONS: ReadonlySet<PortalRailSection> = new Set(['live', 'itv']);
const LIVE_SECTIONS: ReadonlySet<PortalRailSection> = new Set([
'live',
'itv',
'radio',
]);
@Component({
selector: 'app-workspace-shell-context-sidebar',
@@ -279,6 +279,18 @@ function getPortalNavigationCommands(
section: PortalRailSection
): WorkspaceCommandContribution[] {
const liveSection = context.provider === 'stalker' ? 'itv' : 'live';
const radioCommand =
context.provider === 'stalker'
? createNavigationCommand(ctx, {
id: 'go-to-radio',
context,
targetSection: 'radio',
currentSection: section,
icon: 'radio',
labelKey: 'WORKSPACE.SHELL.RAIL_RADIO',
priority: 115,
})
: null;
return [
createNavigationCommand(ctx, {
@@ -299,6 +311,7 @@ function getPortalNavigationCommands(
labelKey: 'WORKSPACE.SHELL.RAIL_LIVE',
priority: 110,
}),
radioCommand,
createNavigationCommand(ctx, {
id: 'go-to-series',
context,
@@ -24,6 +24,7 @@ export const RAIL_TOOLTIP_KEYS: Readonly<
vod: 'WORKSPACE.SHELL.RAIL_MOVIES',
live: 'WORKSPACE.SHELL.RAIL_LIVE',
itv: 'WORKSPACE.SHELL.RAIL_LIVE',
radio: 'WORKSPACE.SHELL.RAIL_RADIO',
series: 'WORKSPACE.SHELL.RAIL_SERIES',
'recently-added': 'WORKSPACE.SHELL.RAIL_RECENTLY_ADDED',
search: 'WORKSPACE.SHELL.RAIL_SEARCH',
@@ -81,7 +81,8 @@ export function resolveSearchPlaceholderKey(
(section === 'vod' ||
section === 'series' ||
section === 'live' ||
section === 'itv')
section === 'itv' ||
section === 'radio')
) {
return SEARCH_SECTION_PLACEHOLDER;
}
@@ -145,7 +146,8 @@ export function resolveSearchScopeLabel(ctx: SearchScopeContext): string {
section === 'vod' ||
section === 'series' ||
section === 'live' ||
section === 'itv'
section === 'itv' ||
section === 'radio'
) {
const categoryLabel = resolveActiveCategoryLabel(ctx);
const sectionLabel = translateRailSection(section, translate);
@@ -541,6 +541,19 @@ describe('WorkspaceShellFacade', () => {
);
});
it('treats stalker radio search as a remote section search', () => {
facade.currentUrl.set('/workspace/stalker/pl-1/radio?q=jazz');
(facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute();
TestBed.flushEffects();
expect(stalkerStore.setSearchPhrase).toHaveBeenCalledWith('jazz');
expect(facade.canUseSearch()).toBe(true);
expect(facade.searchScopeLabel()).toBe(
'WORKSPACE.SHELL.RAIL_RADIO / All Items'
);
expect(facade.searchStatusLabel()).toBe('');
});
it('applies q to Xtream category search on vod routes', () => {
const xtreamStore = TestBed.inject(
XtreamStore
@@ -110,6 +110,22 @@ describe('workspace-shell-route.utils', () => {
})
);
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-2/radio')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'stalker',
playlistId: 'pl-2',
},
section: 'radio',
contextPanel: 'category',
searchMode: 'remote-search',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-2/recent?q=test')
).toEqual(
@@ -171,6 +187,12 @@ describe('workspace-shell-route.utils', () => {
'recent'
)
).toBe(true);
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'stalker', playlistId: '1' },
'radio'
)
).toBe(true);
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'playlists', playlistId: '1' },
@@ -57,6 +57,7 @@ const PORTAL_SECTIONS: readonly PortalRailSection[] = [
'live',
'recent',
'recently-added',
'radio',
'search',
'series',
'vod',
@@ -84,6 +85,7 @@ const STALKER_CATEGORY_CONTEXT_SECTIONS = new Set<PortalRailSection>([
'vod',
'series',
'itv',
'radio',
]);
function normalizePath(url: string): {
@@ -159,6 +161,7 @@ function resolvePortalSearchMode(
section === 'vod' ||
section === 'series' ||
section === 'itv' ||
section === 'radio' ||
section === 'search'
) {
return 'remote-search';