From ab905af285af9f8123cc0aa413bbd41518956c4c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 9 May 2026 01:28:39 +0200 Subject: [PATCH] feat(stalker): add radio support Entire-Checkpoint: f957cd9849e0 --- .../src/app/data-generator.ts | 80 +++++++++++- .../src/app/handlers/create-link.handler.ts | 2 +- .../src/app/handlers/favorites.handler.ts | 17 ++- .../app/handlers/get-categories.handler.ts | 4 +- .../src/app/handlers/get-genres.handler.ts | 2 + .../app/handlers/get-ordered-list.handler.ts | 19 ++- apps/stalker-mock-server/src/app/scenarios.ts | 15 +-- apps/web-e2e/src/stalker.e2e.ts | 114 ++++++++++++++++- apps/web/src/assets/i18n/ar.json | 9 +- apps/web/src/assets/i18n/ary.json | 9 +- apps/web/src/assets/i18n/by.json | 9 +- apps/web/src/assets/i18n/de.json | 9 +- apps/web/src/assets/i18n/el.json | 9 +- apps/web/src/assets/i18n/en.json | 9 +- apps/web/src/assets/i18n/es.json | 9 +- apps/web/src/assets/i18n/fr.json | 9 +- apps/web/src/assets/i18n/it.json | 9 +- apps/web/src/assets/i18n/ja.json | 9 +- apps/web/src/assets/i18n/ko.json | 9 +- apps/web/src/assets/i18n/nl.json | 9 +- apps/web/src/assets/i18n/pl.json | 9 +- apps/web/src/assets/i18n/pt.json | 9 +- apps/web/src/assets/i18n/ru.json | 9 +- apps/web/src/assets/i18n/tr.json | 9 +- apps/web/src/assets/i18n/zh.json | 9 +- apps/web/src/assets/i18n/zhtw.json | 9 +- docs/architecture/iptvnator-ui-guidelines.md | 4 +- docs/architecture/stalker-mock-server.md | 9 +- docs/architecture/stalker-portal.md | 26 +++- .../stalker-store-api-baseline.md | 16 ++- docs/architecture/workspace-shell.md | 2 +- .../lib/navigation/portal-rail-links.spec.ts | 2 + .../src/lib/navigation/portal-rail-links.ts | 7 ++ .../src/lib/stalker-content-types.ts | 6 +- .../with-stalker-content.feature.spec.ts | 86 ++++++++++++- .../features/with-stalker-content.feature.ts | 118 ++++++++++++++---- .../with-stalker-player.feature.spec.ts | 70 ++++++++++- .../features/with-stalker-player.feature.ts | 56 +++++++++ .../with-stalker-selection.feature.ts | 6 +- .../data-access/src/lib/stores/index.ts | 2 +- .../src/lib/stores/stalker-store.contracts.ts | 3 +- .../stalker-collection-detail.component.ts | 3 +- .../feature/src/lib/stalker-feature.routes.ts | 4 + .../stalker-live-stream-layout.component.html | 84 ++++++++----- ...alker-live-stream-layout.component.spec.ts | 78 +++++++++++- .../stalker-live-stream-layout.component.ts | 70 +++++++++-- ...er-workspace-route-session.service.spec.ts | 28 ++++- ...stalker-workspace-route-session.service.ts | 14 ++- .../audio-player/audio-player.component.ts | 14 ++- libs/ui/styles/_portal-layout.scss | 5 +- .../workspace-context-panel.component.spec.ts | 38 +++++- .../workspace-context-panel.component.ts | 8 +- ...rkspace-shell-context-sidebar.component.ts | 6 +- .../workspace-shell-command-builders.ts | 13 ++ .../helpers/workspace-shell-constants.ts | 1 + .../helpers/workspace-shell-search-labels.ts | 6 +- .../services/workspace-shell.facade.spec.ts | 13 ++ .../workspace-shell-route.utils.spec.ts | 22 ++++ .../navigation/workspace-shell-route.utils.ts | 3 + 59 files changed, 1059 insertions(+), 179 deletions(-) diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 321e91769..0f0cb9c68 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -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; // categoryId -> channels + radio: Map; // categoryId -> radio stations vod: Map; // categoryId -> items series: Map; // categoryId -> items seasons: Map; // 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 // --------------------------------------------------------------------------- diff --git a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts index 5d083e56b..163ecc225 100644 --- a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts @@ -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); diff --git a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts index 5d9c1e93d..10b58dd93 100644 --- a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts @@ -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, 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; diff --git a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts index 8f57a0e49..e1b49293a 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts @@ -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 { diff --git a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts index 1c82ad158..a31002aa0 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts @@ -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 { diff --git a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts index a7affa37f..569ead57d 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts @@ -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()) { diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index 3a0454bd8..6657990ae 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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, diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 7747bb1b7..799b4cbe8 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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 { await expect(input).toHaveValue(value); } +async function resetMockServer(request: APIRequestContext): Promise { + 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, }) => { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b8f31ed49..8ac2f2dfd 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم", "PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل", "LOADING_SEASONS": "جارٍ تحميل المواسم...", - "NO_EPISODES": "لا توجد حلقات متاحة" + "NO_EPISODES": "لا توجد حلقات متاحة", + "ALL_RADIO": "كل الراديو" }, "INFORMATION": "معلومات", "DOWNLOADS": { @@ -899,7 +900,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": "المصادر", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "عنصر واحد", "ITEM_COUNT_OTHER": "{{count}} عنصر", "SEARCH": "بحث", - "SEARCH_CATEGORIES": "البحث في الفئات" + "SEARCH_CATEGORIES": "البحث في الفئات", + "RADIO_CATEGORIES": "تصنيفات الراديو" }, "COMMAND_PALETTE": { "TITLE": "لوحة الأوامر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index aac6dd028..25d4bda68 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر", "PLAYBACK_ERROR": "فشل جلب رابط التشغيل", "LOADING_SEASONS": "جاري تشريج المواسم...", - "NO_EPISODES": "ما كاينش حلقات متوفرة" + "NO_EPISODES": "ما كاينش حلقات متوفرة", + "ALL_RADIO": "All radio" }, "INFORMATION": "معلومات", "DOWNLOADS": { @@ -899,7 +900,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": "المصادر", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "عنصر واحد", "ITEM_COUNT_OTHER": "{{count}} ديال العناصر", "SEARCH": "بحث", - "SEARCH_CATEGORIES": "قلب على فئات" + "SEARCH_CATEGORIES": "قلب على فئات", + "RADIO_CATEGORIES": "Radio Categories" }, "COMMAND_PALETTE": { "TITLE": "لوحة الأوامر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index b6d2f679d..d58d202a6 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы", "PLAYBACK_ERROR": "Не ўдалося атрымаць URL для прайгравання", "LOADING_SEASONS": "Загрузка сезонаў...", - "NO_EPISODES": "Няма даступных эпізодаў" + "NO_EPISODES": "Няма даступных эпізодаў", + "ALL_RADIO": "Усе радыё" }, "INFORMATION": "Інфармацыя", "DOWNLOADS": { @@ -899,7 +900,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": "Крыніцы", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "1 элемент", "ITEM_COUNT_OTHER": "{{count}} элементаў", "SEARCH": "Пошук", - "SEARCH_CATEGORIES": "Пошук катэгорый" + "SEARCH_CATEGORIES": "Пошук катэгорый", + "RADIO_CATEGORIES": "Катэгорыі радыё" }, "COMMAND_PALETTE": { "TITLE": "Палітра каманд", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 43f17bc7f..a7cae7965 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -690,7 +690,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": { @@ -899,7 +900,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", @@ -930,7 +932,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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 7e7464aa0..1a2875946 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή", "PLAYBACK_ERROR": "Αποτυχία λήψης διεύθυνσης URL αναπαραγωγής", "LOADING_SEASONS": "Φόρτωση σεζόν...", - "NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια" + "NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια", + "ALL_RADIO": "Όλα τα ραδιόφωνα" }, "INFORMATION": "Πληροφορίες", "DOWNLOADS": { @@ -899,7 +900,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": "Πηγές", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "1 στοιχείο", "ITEM_COUNT_OTHER": "{{count}} στοιχεία", "SEARCH": "Αναζήτηση", - "SEARCH_CATEGORIES": "Αναζήτηση κατηγοριών" + "SEARCH_CATEGORIES": "Αναζήτηση κατηγοριών", + "RADIO_CATEGORIES": "Κατηγορίες ραδιοφώνου" }, "COMMAND_PALETTE": { "TITLE": "Παλέτα εντολών", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c79653414..2f3b184bc 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -692,7 +692,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": { @@ -901,7 +902,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", @@ -932,7 +934,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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 01f5ddf0c..61ac74175 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -690,7 +690,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": { @@ -899,7 +900,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", @@ -930,7 +932,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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ab5591814..2a4c26081 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -690,7 +690,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": { @@ -899,7 +900,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", @@ -930,7 +932,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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 1218d5c3a..8dd146e2c 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -690,7 +690,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": { @@ -899,7 +900,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", @@ -930,7 +932,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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 4b4999477..3fec52eb0 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません", "PLAYBACK_ERROR": "再生URLの取得に失敗しました", "LOADING_SEASONS": "シーズンを読み込み中...", - "NO_EPISODES": "利用可能なエピソードはありません" + "NO_EPISODES": "利用可能なエピソードはありません", + "ALL_RADIO": "すべてのラジオ" }, "INFORMATION": "情報", "DOWNLOADS": { @@ -899,7 +900,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": "ソース", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "{{count}}件", "ITEM_COUNT_OTHER": "{{count}}件", "SEARCH": "検索", - "SEARCH_CATEGORIES": "カテゴリーを検索" + "SEARCH_CATEGORIES": "カテゴリーを検索", + "RADIO_CATEGORIES": "ラジオカテゴリ" }, "COMMAND_PALETTE": { "TITLE": "コマンドパレット", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index e682e16d4..297c8f58a 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다", "PLAYBACK_ERROR": "재생 URL을 가져오지 못했습니다", "LOADING_SEASONS": "시즌을 불러오는 중...", - "NO_EPISODES": "사용 가능한 에피소드가 없습니다" + "NO_EPISODES": "사용 가능한 에피소드가 없습니다", + "ALL_RADIO": "모든 라디오" }, "INFORMATION": "정보", "DOWNLOADS": { @@ -899,7 +900,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": "소스", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "{{count}}개 항목", "ITEM_COUNT_OTHER": "{{count}}개 항목", "SEARCH": "검색", - "SEARCH_CATEGORIES": "카테고리 검색" + "SEARCH_CATEGORIES": "카테고리 검색", + "RADIO_CATEGORIES": "라디오 카테고리" }, "COMMAND_PALETTE": { "TITLE": "명령어 팔레트", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 7e4f0b5a8..929630a65 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -690,7 +690,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": { @@ -899,7 +900,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", @@ -930,7 +932,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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 0692acef1..8d982edc4 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -690,7 +690,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": { @@ -899,7 +900,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", @@ -930,7 +932,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ń", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 0c8095ea7..ee59d92e0 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -690,7 +690,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": { @@ -899,7 +900,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", @@ -930,7 +932,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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f6e23bedd..6950e60b9 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере", "PLAYBACK_ERROR": "Не удалось получить URL для воспроизведения", "LOADING_SEASONS": "Загрузка сезонов…", - "NO_EPISODES": "Нет доступных эпизодов" + "NO_EPISODES": "Нет доступных эпизодов", + "ALL_RADIO": "Все радио" }, "INFORMATION": "Инфо", "DOWNLOADS": { @@ -899,7 +900,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": "Источники", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "1 элемент", "ITEM_COUNT_OTHER": "{{count}} элементов", "SEARCH": "Поиск", - "SEARCH_CATEGORIES": "Поиск категорий" + "SEARCH_CATEGORIES": "Поиск категорий", + "RADIO_CATEGORIES": "Категории радио" }, "COMMAND_PALETTE": { "TITLE": "Палитра команд", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 2bd0dc14d..4fb63ec73 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -690,7 +690,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": { @@ -899,7 +900,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", @@ -930,7 +932,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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 943b53cb3..397cc3566 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "此服务器上没有此内容", "PLAYBACK_ERROR": "获取播放 URL 失败", "LOADING_SEASONS": "正在加载季…", - "NO_EPISODES": "没有可用的剧集" + "NO_EPISODES": "没有可用的剧集", + "ALL_RADIO": "所有广播" }, "INFORMATION": "信息", "DOWNLOADS": { @@ -899,7 +900,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": "源", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "1 项", "ITEM_COUNT_OTHER": "{{count}} 项", "SEARCH": "搜索", - "SEARCH_CATEGORIES": "搜索分类" + "SEARCH_CATEGORIES": "搜索分类", + "RADIO_CATEGORIES": "广播分类" }, "COMMAND_PALETTE": { "TITLE": "命令面板", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 8ffcb68ba..fc30d3edc 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -690,7 +690,8 @@ "CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容", "PLAYBACK_ERROR": "無法取得播放網址", "LOADING_SEASONS": "正在載入季數...", - "NO_EPISODES": "沒有可用的集數" + "NO_EPISODES": "沒有可用的集數", + "ALL_RADIO": "所有廣播" }, "INFORMATION": "資訊", "DOWNLOADS": { @@ -899,7 +900,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": "來源", @@ -930,7 +932,8 @@ "ITEM_COUNT_ONE": "{{count}} 個項目", "ITEM_COUNT_OTHER": "{{count}} 個項目", "SEARCH": "搜尋", - "SEARCH_CATEGORIES": "搜尋類別" + "SEARCH_CATEGORIES": "搜尋類別", + "RADIO_CATEGORIES": "廣播分類" }, "COMMAND_PALETTE": { "TITLE": "命令選擇區", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 3759ab258..6464751e6 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 1205f7651..60458d092 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -47,6 +47,9 @@ faker.seed(macToNumber(mac)) │ └── generateChannels() → channels Map │ └── generateEpg() → epg Map │ +├── generateCategories('radio', N) → radioCategories[] +│ └── generateRadioStations() → radio Map +│ ├── generateCategories('vod', N) → vodCategories[] │ └── generateVodItems() → vod Map │ ├── 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; diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 0ca32581e..b45f909fc 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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: diff --git a/docs/architecture/stalker-store-api-baseline.md b/docs/architecture/stalker-store-api-baseline.md index b29f957e9..bfb4bb4aa 100644 --- a/docs/architecture/stalker-store-api-baseline.md +++ b/docs/architecture/stalker-store-api-baseline.md @@ -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` - `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` -- `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` - `getExpireDate(): Promise` +- `resolveItvPlayback(item: StalkerPortalItem): Promise` +- `resolveRadioPlayback(item: StalkerPortalItem): Promise` - `addToFavorites(item: any, onDone?: () => void): void` - `removeFromFavorites(favoriteId: string, onDone?: () => void): void` - `fetchMovieFileId(movieId: string): Promise` @@ -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: diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index c2b38e352..1f5783b0c 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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` diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts index 87e1d1f4d..b5bb6f5a1 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts @@ -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)'); }); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts index 16085a48e..cb335cefb 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts @@ -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)', diff --git a/libs/portal/stalker/data-access/src/lib/stalker-content-types.ts b/libs/portal/stalker/data-access/src/lib/stalker-content-types.ts index cf969457e..f7e35b085 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-content-types.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-content-types.ts @@ -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, - }, */ + }, }; diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 035e92b97..b9771a8e9 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -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, diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index a1ea2a029..c4d2d6f7b 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -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 }); + }, })) ); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 9ff6f1776..79587af21 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -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', + }) + ); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index b84401841..ac0e09f28 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -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 => { + 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 { return resolveItvPlaybackInternal(item); }, + async resolveRadioPlayback( + item: StalkerPlayableItem + ): Promise { + return resolveRadioPlaybackInternal(item); + }, async createLinkToPlayVod( cmd?: string, title?: string, diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index f2bb0787f..3657023e2 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -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(initialSelectionState), withMethods((store) => ({ - setSelectedContentType(type: 'vod' | 'itv' | 'series') { + setSelectedContentType( + type: 'vod' | 'itv' | 'series' | 'radio' + ) { patchState(store, { selectedContentType: type }); }, setSelectedCategory(id: string | number | null) { diff --git a/libs/portal/stalker/data-access/src/lib/stores/index.ts b/libs/portal/stalker/data-access/src/lib/stores/index.ts index 87e9faf4f..da41076d7 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/index.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/index.ts @@ -1,3 +1,3 @@ export * from './features'; +export * from './stalker-store.contracts'; export * from './utils'; - diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts index 5db70d433..a9e28c078 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts @@ -7,7 +7,7 @@ import { StalkerVodSource, } from '../models'; -export type StalkerContentType = 'vod' | 'series' | 'itv'; +export type StalkerContentType = 'vod' | 'series' | 'itv' | 'radio'; export interface ResourceState { value(): T; @@ -35,6 +35,7 @@ export interface StalkerCategorySliceContract { vodCategories(): StalkerCategoryItem[]; seriesCategories(): StalkerCategoryItem[]; itvCategories(): StalkerCategoryItem[]; + radioCategories(): StalkerCategoryItem[]; } export interface StalkerContentFeatureStoreContract diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index ed152ac48..55d2f5654 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -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; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts index 682f78298..b494d9c0a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts @@ -74,6 +74,10 @@ export function createStalkerRoutes(): Route[] { path: 'itv', loadComponent: loadStalkerLiveStreamLayoutComponent, }, + { + path: 'radio', + loadComponent: loadStalkerLiveStreamLayoutComponent, + }, { path: 'series', providers: [ diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index e9afb035a..750d63360 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -108,48 +108,64 @@ [message]="'PORTALS.SELECT_CATEGORY' | translate" /> } @else if (stalkerStore.selectedItem()) { - @if (streamUrl() && isEmbeddedPlayer) { + @if (streamUrl() && (isEmbeddedPlayer || isRadioMode())) {
- + @if (isRadioMode()) { + + } @else { + + }
} - @let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer; -
- @if (canCollapseEpgPanel) { - + @if (!isRadioMode()) { + @let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer; +
+ @if (canCollapseEpgPanel) { + + + + } @else { - - } @else { - - } -
+ } +
+ } } @else { (null); } +@Component({ + selector: 'app-audio-player', + standalone: true, + template: '', +}) +class StubAudioPlayerComponent { + readonly url = input.required(); + 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('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 { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index ba12d1d30..ff2b97ff3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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(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([]); @@ -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 { - 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; diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts index 7cbd8a748..abf8e150e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts @@ -33,7 +33,9 @@ function getStalkerSectionFromUrl(url: string): string | null { describe('StalkerWorkspaceRouteSession', () => { const routerEvents = new Subject(); const activePlaylist = signal(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(''); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts index 4a0a5b761..f65ec9104 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts @@ -14,7 +14,7 @@ import { PortalRailSection } from '@iptvnator/portal/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistsService } from 'services'; -type StalkerContentType = 'vod' | 'series' | 'itv'; +type StalkerContentType = 'vod' | 'series' | 'itv' | 'radio'; @Injectable() export class StalkerWorkspaceRouteSession { @@ -85,13 +85,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(''); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index 403f616bd..32f03e522 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -7,6 +7,7 @@ import { effect, inject, input, + output, signal, viewChild, } from '@angular/core'; @@ -126,6 +127,8 @@ export class AudioPlayerComponent { readonly icon = input(''); readonly url = input.required(); readonly channelName = input(''); + 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 }) - ); - } + this.channelSwitchRequested.emit(direction); + if (this.dispatchAdjacentChannelAction()) { + this.store.dispatch( + ChannelActions.setAdjacentChannelAsActive({ direction }) + ); + } + } } diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index f746c0def..aef73c0e4 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -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; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 1f0a2e561..0eb5c0292 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -14,6 +14,7 @@ import { WorkspaceContextPanelComponent } from './workspace-context-panel.compon const translations: Record = { '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(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(); + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 5ec9fd0ff..b8103669e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -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; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.ts index 862426f71..03732ef0c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-context-sidebar/workspace-shell-context-sidebar.component.ts @@ -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 = new Set(['live', 'itv']); +const LIVE_SECTIONS: ReadonlySet = new Set([ + 'live', + 'itv', + 'radio', +]); @Component({ selector: 'app-workspace-shell-context-sidebar', diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts index e1ef7693c..281baa36f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts @@ -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, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts index 0af75c37b..c0de77298 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts @@ -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', diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-search-labels.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-search-labels.ts index 73ff7bfbf..16541327d 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-search-labels.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-search-labels.ts @@ -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); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 864202a55..4d5b8a0e1 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -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 diff --git a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts index 9d20b4e5e..1fb327a09 100644 --- a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts +++ b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts @@ -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' }, diff --git a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts index e01a7e17a..00cf96c3a 100644 --- a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts +++ b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts @@ -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([ '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';