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