diff --git a/README.md b/README.md index 71840976d..c6d4d4ce0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ The application is a cross-platform, open-source project built with Electron and - Global favorites aggregated from all playlists - Recently viewed live channel removal from row actions and context menus - HTML video player with HLS.js support or Video.js-based player +- Remote playback controls for AirPlay, Google Cast, browser Remote Playback, + and Electron DLNA/UPnP renderers when the active stream is receiver-fetchable - Internationalization with support for 16 languages: - Arabic - Moroccan arabic diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index a2edd6418..8b53cfebf 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -565,7 +565,7 @@ export async function saveSettings(page: Page): Promise { export async function goToDashboard(page: Page): Promise { const dashboardLink = page - .locator('a.brand[href$="/workspace/dashboard"]') + .getByRole('link', { name: 'Dashboard', exact: true }) .first(); await expect(dashboardLink).toBeVisible(); diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index f0d834b90..f931dd935 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -247,10 +247,15 @@ test.describe('Electron Settings', () => { exact: true, }) ).toHaveCount(0); - await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute( - 'href', - /\/workspace\/sources$/ - ); + await expect( + secondLaunch.mainWindow.getByRole('link', { + name: 'Sources', + exact: true, + }) + ).toHaveClass(/is-active/); + await expect( + secondLaunch.mainWindow.locator('button.brand') + ).toBeVisible(); } finally { await closeElectronApp(secondLaunch); } diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index ec1cff48d..8181117af 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -113,8 +113,9 @@ }, "serve": { "executor": "nx:run-commands", + "continuous": true, "options": { - "command": "nx run electron-backend:serve-electron" + "command": "node tools/electron/serve-electron-dev.mjs" } }, "serve-electron": { @@ -125,7 +126,6 @@ "executor": "nx-electron:execute", "options": { "buildTarget": "electron-backend:build", - "waitUntilTargets": ["web:serve"], "remoteDebuggingPort": 9222 } }, @@ -177,7 +177,14 @@ "lint": { "command": "eslint apps/electron-backend/**/*.ts" }, + "test-serve-launcher": { + "executor": "nx:run-commands", + "options": { + "command": "node --test tools/electron/serve-electron-dev.test.mjs" + } + }, "test": { + "dependsOn": ["electron-backend:test-serve-launcher"], "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 38eda2a7b..253123e3d 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -4,6 +4,7 @@ import type { EmbeddedMpvRecordingStartOptions, EmbeddedMpvSession, EmbeddedMpvSupport, + DlnaRendererDevice, ElectronBridgeApi, ElectronBridgeDbOperationEvent, ElectronBridgeDownloadStartPayload, @@ -741,6 +742,10 @@ const electronApi: ElectronBridgeApi = { contentType ), getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'), + discoverDlnaRenderers: (): Promise => + ipcRenderer.invoke('CAST:DLNA_DISCOVER'), + startDlnaPlayback: (deviceId: string, playback: ResolvedPortalPlayback) => + ipcRenderer.invoke('CAST:DLNA_START', deviceId, playback), // Downloads downloadsStart: (data: ElectronBridgeDownloadStartPayload) => ipcRenderer.invoke('DOWNLOADS_START', data), diff --git a/apps/electron-backend/src/app/events/casting.events.spec.ts b/apps/electron-backend/src/app/events/casting.events.spec.ts new file mode 100644 index 000000000..dbea0dbd9 --- /dev/null +++ b/apps/electron-backend/src/app/events/casting.events.spec.ts @@ -0,0 +1,30 @@ +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('../services/dlna-renderer.service', () => ({ + DlnaRendererService: jest.fn().mockImplementation(() => ({ + discover: jest.fn(), + startPlayback: jest.fn(), + })), +})); + +import { ipcMain } from 'electron'; +import CastingEvents from './casting.events'; + +describe('CastingEvents', () => { + it('registers DLNA handlers during bootstrap', () => { + CastingEvents.bootstrapCastingEvents(); + + expect(ipcMain.handle).toHaveBeenCalledWith( + 'CAST:DLNA_DISCOVER', + expect.any(Function) + ); + expect(ipcMain.handle).toHaveBeenCalledWith( + 'CAST:DLNA_START', + expect.any(Function) + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/casting.events.ts b/apps/electron-backend/src/app/events/casting.events.ts new file mode 100644 index 000000000..bb0f981f0 --- /dev/null +++ b/apps/electron-backend/src/app/events/casting.events.ts @@ -0,0 +1,19 @@ +import { ipcMain } from 'electron'; +import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { DlnaRendererService } from '../services/dlna-renderer.service'; + +const dlnaRendererService = new DlnaRendererService(); + +export default class CastingEvents { + static bootstrapCastingEvents(): Electron.IpcMain { + ipcMain.handle('CAST:DLNA_DISCOVER', () => + dlnaRendererService.discover() + ); + ipcMain.handle( + 'CAST:DLNA_START', + (_event, deviceId: string, playback: ResolvedPortalPlayback) => + dlnaRendererService.startPlayback(deviceId, playback) + ); + return ipcMain; + } +} diff --git a/apps/electron-backend/src/app/events/external-player-playback-request.ts b/apps/electron-backend/src/app/events/external-player-playback-request.ts index d28f22223..48f25524f 100644 --- a/apps/electron-backend/src/app/events/external-player-playback-request.ts +++ b/apps/electron-backend/src/app/events/external-player-playback-request.ts @@ -8,6 +8,32 @@ export interface EffectiveExternalPlaybackRequest { headerFields: string[]; } +export interface MpvReusePropertyCommand { + property: 'user-agent' | 'referrer' | 'http-header-fields'; + value: string; +} + +export function buildMpvReusePropertyCommands(options: { + userAgent?: string; + referer?: string; + headerFields: string[]; +}): MpvReusePropertyCommand[] { + return [ + { + property: 'user-agent', + value: options.userAgent ?? '', + }, + { + property: 'referrer', + value: options.referer ?? '', + }, + { + property: 'http-header-fields', + value: options.headerFields.join(','), + }, + ]; +} + export function buildHttpHeaderFields( origin?: string, headers?: Record diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts index 18e573f11..00d29f5e6 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts @@ -27,6 +27,25 @@ describe('ExternalPlayerSessionRegistry', () => { expect(updates.at(-1)?.status).toBe('opened'); }); + it('marks protected playback without exposing request credentials', () => { + const session = registry.beginSession({ + player: 'mpv', + title: 'Header protected stream', + streamUrl: 'https://example.com/video.m3u8', + requiresRequestHeaders: true, + }); + + expect(session).toEqual( + expect.objectContaining({ + requiresRequestHeaders: true, + }) + ); + expect(session).not.toHaveProperty('headers'); + expect(session).not.toHaveProperty('userAgent'); + expect(session).not.toHaveProperty('referer'); + expect(session).not.toHaveProperty('origin'); + }); + it('keeps close capability and closes the session explicitly', async () => { const close = jest.fn(); const session = registry.beginSession({ @@ -47,6 +66,22 @@ describe('ExternalPlayerSessionRegistry', () => { expect(registry.getActiveSessionId()).toBeNull(); }); + it('marks a session closed without swallowing close failures', async () => { + const session = registry.beginSession({ + player: 'vlc', + title: 'Example', + streamUrl: 'https://example.com/video.m3u8', + }); + registry.attachCloser(session.id, () => { + throw new Error('close failed'); + }); + + await expect(registry.closeSession(session.id)).rejects.toThrow( + 'close failed' + ); + expect(registry.getSession(session.id)?.status).toBe('closed'); + }); + it('marks runtime failures as errors without clearing the active id', () => { const session = registry.beginSession({ player: 'mpv', diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.ts b/apps/electron-backend/src/app/events/external-player-session-registry.ts index f18e1fb39..ee5d670c4 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.ts @@ -9,6 +9,7 @@ interface CreateExternalPlayerSessionOptions { title: string; thumbnail?: string | null; streamUrl: string; + requiresRequestHeaders?: boolean; contentInfo?: PlayerContentInfo; } @@ -42,6 +43,7 @@ export class ExternalPlayerSessionRegistry { title: options.title, thumbnail: options.thumbnail ?? null, streamUrl: options.streamUrl, + requiresRequestHeaders: options.requiresRequestHeaders, contentInfo: options.contentInfo, startedAt, updatedAt: startedAt, @@ -131,7 +133,9 @@ export class ExternalPlayerSessionRegistry { try { await runtime.close?.(); } finally { - return this.markClosed(id); + this.markClosed(id); } + + return this.getSession(id); } } diff --git a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts index 4d4424e0e..a1bf61d86 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts @@ -37,7 +37,11 @@ import { VLC_REUSE_INSTANCE, store, } from '../services/store.service'; -import { openMpvPlayer, shutdownMpvSession } from './mpv-session.service'; +import { + openMpvPlayer, + shutdownMpvSession, +} from './mpv-session.service'; +import { buildMpvReusePropertyCommands } from './external-player-playback-request'; import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service'; function createMockChildProcess(): ChildProcess { @@ -148,3 +152,17 @@ describe('external player shutdown on app quit', () => { expect(proc.kill).toHaveBeenCalledTimes(1); }); }); + +describe('MPV reuse request headers', () => { + it('actively clears request properties before loading an unprotected stream', () => { + expect( + buildMpvReusePropertyCommands({ + headerFields: [], + }) + ).toEqual([ + { property: 'user-agent', value: '' }, + { property: 'referrer', value: '' }, + { property: 'http-header-fields', value: '' }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts index fb6052f45..10154fc0c 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -19,7 +19,10 @@ import { shouldReuseMpvInstance, shouldUseMpvSocketBridge, } from './external-player-launch-context'; -import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request'; +import { + buildMpvReusePropertyCommands, + resolveEffectiveExternalPlaybackRequest, +} from './external-player-playback-request'; import { buildPlayerStartError, externalPlayerSessions, @@ -221,11 +224,29 @@ export async function openMpvPlayer({ startTime, headers, }: OpenExternalPlayerRequest) { + const { + effectiveOrigin, + effectiveReferer, + effectiveUserAgent, + headerFields, + } = resolveEffectiveExternalPlaybackRequest({ + url, + userAgent, + referer, + origin, + headers, + }); const session = externalPlayerSessions.beginSession({ player: 'mpv', title, thumbnail, streamUrl: url, + requiresRequestHeaders: Boolean( + effectiveUserAgent || + effectiveReferer || + effectiveOrigin || + headerFields.length + ), contentInfo, }); @@ -243,19 +264,6 @@ export async function openMpvPlayer({ isFlatpak ); const useMpvSocketBridge = shouldUseMpvSocketBridge(isFlatpak); - const { - effectiveOrigin, - effectiveReferer, - effectiveUserAgent, - headerFields, - } = resolveEffectiveExternalPlaybackRequest({ - url, - userAgent, - referer, - origin, - headers, - }); - traceExternalPlayer('open mpv player', { path: mpvLaunchContext.playerPath, launchMode: mpvLaunchContext.mode, @@ -280,22 +288,14 @@ export async function openMpvPlayer({ ) { traceExternalPlayer('reuse existing mpv instance'); try { - if (effectiveUserAgent) { - await sendMpvCommand('set_property', [ - 'user-agent', - effectiveUserAgent, - ]); - } - if (effectiveReferer) { - await sendMpvCommand('set_property', [ - 'referrer', - effectiveReferer, - ]); - } - if (headerFields.length > 0) { + for (const command of buildMpvReusePropertyCommands({ + userAgent: effectiveUserAgent, + referer: effectiveReferer, + headerFields, + })) { await sendMpvCommand('set_property', [ - 'http-header-fields', - headerFields.join(','), + command.property, + command.value, ]); } diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index a8ad22e6a..5ce775b69 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -310,11 +310,29 @@ export async function openVlcPlayer({ startTime, headers, }: OpenVlcPlayerRequest) { + const { + mergedHeaders, + effectiveOrigin, + effectiveReferer, + effectiveUserAgent, + } = resolveEffectiveExternalPlaybackRequest({ + url, + userAgent, + referer, + origin, + headers, + }); const session = externalPlayerSessions.beginSession({ player: 'vlc', title, thumbnail, streamUrl: url, + requiresRequestHeaders: Boolean( + effectiveUserAgent || + effectiveReferer || + effectiveOrigin || + Object.keys(mergedHeaders).length + ), contentInfo, }); @@ -331,18 +349,6 @@ export async function openVlcPlayer({ requestedReuseInstance, isFlatpak ); - const { - mergedHeaders, - effectiveOrigin, - effectiveReferer, - effectiveUserAgent, - } = resolveEffectiveExternalPlaybackRequest({ - url, - userAgent, - referer, - origin, - headers, - }); traceExternalPlayer('open vlc player', { path: vlcLaunchContext.playerPath, launchMode: vlcLaunchContext.mode, diff --git a/apps/electron-backend/src/app/events/xtream-request.util.ts b/apps/electron-backend/src/app/events/xtream-request.util.ts new file mode 100644 index 000000000..f0bb0eb72 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-request.util.ts @@ -0,0 +1,136 @@ +/** + * Pure helpers for the Xtream Codes IPC handlers: request-URL construction, + * structured error responses, and debug-log error formatting. Kept separate + * from `xtream.events.ts` so that file stays focused on IPC registration. + */ + +import axios from 'axios'; +import { + ElectronBridgeXtreamErrorResponse, + normalizeXtreamServerUrl, +} from '@iptvnator/shared/interfaces'; + +/** + * Builds a structured, log-safe summary of an Xtream request failure. Never + * throws on an unparseable URL — falls back to the raw request string. + */ +export function formatXtreamError( + error: unknown, + requestUrl: string, + action?: string +) { + let parsedUrl: URL | null = null; + try { + parsedUrl = new URL(requestUrl); + } catch { + parsedUrl = null; + } + const base = { + action, + host: parsedUrl?.host ?? 'unknown', + pathname: parsedUrl?.pathname ?? requestUrl, + }; + + if (axios.isAxiosError(error)) { + const networkError = error as NodeJS.ErrnoException & { + hostname?: string; + }; + return { + ...base, + type: 'AxiosError', + code: error.code, + status: error.response?.status, + message: error.message, + syscall: networkError.syscall, + hostname: networkError.hostname, + }; + } + + if (error && typeof error === 'object') { + const errObj = error as Record; + return { + ...base, + type: 'ErrorObject', + status: errObj.status, + message: errObj.message, + }; + } + + return { + ...base, + type: 'UnknownError', + message: String(error), + }; +} + +/** + * Converts a thrown error into the structured IPC failure envelope the + * renderer expects, preserving cancellation (`AbortError`/499) and status. + */ +export function createXtreamErrorResponse( + error: unknown +): ElectronBridgeXtreamErrorResponse { + if (axios.isAxiosError(error)) { + if (error.code === 'ERR_CANCELED') { + return { + type: 'ERROR', + name: 'AbortError', + message: 'Xtream request cancelled', + status: 499, + }; + } + + return { + type: 'ERROR', + message: + error.response?.data?.message || + error.message || + 'Failed to fetch data from Xtream server', + status: error.response?.status || 500, + }; + } + + if (error && typeof error === 'object') { + const errorRecord = error as Record; + return { + type: 'ERROR', + ...(typeof errorRecord.name === 'string' + ? { name: errorRecord.name } + : {}), + message: + typeof errorRecord.message === 'string' + ? errorRecord.message + : 'An unknown error occurred', + status: + typeof errorRecord.status === 'number' + ? errorRecord.status + : 500, + }; + } + + return { + type: 'ERROR', + message: 'An unknown error occurred', + status: 500, + }; +} + +/** + * Normalizes a (possibly full) Xtream server URL and appends the canonical + * `player_api.php` endpoint with trimmed credential params. + */ +export function buildXtreamApiUrl( + url: string, + params: Record +): URL { + const baseUrl = normalizeXtreamServerUrl(url); + const apiUrl = new URL(`${baseUrl}/player_api.php`); + Object.entries(params).forEach(([key, value]) => { + apiUrl.searchParams.append( + key, + key === 'username' || key === 'password' ? value.trim() : value + ); + }); + + return apiUrl; +} diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 875406256..f1d0bf043 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -136,7 +136,8 @@ describe('XtreamEvents session cancellation', () => { pendingRequest.reject(cancelError); - await expect(requestPromise).rejects.toMatchObject({ + await expect(requestPromise).resolves.toMatchObject({ + type: 'ERROR', name: 'AbortError', status: 499, }); @@ -218,11 +219,52 @@ describe('XtreamEvents session cancellation', () => { firstRequest.reject(cancelError); secondRequest.reject(cancelError); - await expect(firstPromise).rejects.toMatchObject({ + await expect(firstPromise).resolves.toMatchObject({ + type: 'ERROR', name: 'AbortError', }); - await expect(secondPromise).rejects.toMatchObject({ + await expect(secondPromise).resolves.toMatchObject({ + type: 'ERROR', name: 'AbortError', }); }); + + it('returns provider failures without rejecting the Electron handler', async () => { + const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); + const providerError = Object.assign( + new Error('Request failed with status code 520'), + { + response: { + status: 520, + }, + } + ); + + expect(requestHandler).toBeDefined(); + + axiosMock.mockRejectedValue(providerError); + axiosMock.isAxiosError.mockImplementation( + (error: unknown) => error === providerError + ); + + const result = requestHandler?.( + {}, + { + url: 'http://localhost:3211', + params: { + action: 'get_short_epg', + password: 'secret', + username: 'user1', + }, + suppressErrorLog: true, + } + ) as Promise; + + await expect(result).resolves.toEqual({ + type: 'ERROR', + message: 'Request failed with status code 520', + status: 520, + }); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); }); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 22d58326f..92694d94e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -8,11 +8,15 @@ import { ipcMain } from 'electron'; import { PortalDebugEvent, XTREAM_CANCEL_SESSION, - normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; import { emitPortalDebugEvent } from './portal-debug.events'; import { assertRemoteUrlAllowed, UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +import { + buildXtreamApiUrl, + createXtreamErrorResponse, + formatXtreamError, +} from './xtream-request.util'; export default class XtreamEvents { static bootstrapXtreamEvents(): Electron.IpcMain { @@ -20,65 +24,6 @@ export default class XtreamEvents { } } -function formatXtreamError( - error: unknown, - requestUrl: string, - action?: string -) { - let parsedUrl: URL | null = null; - try { - parsedUrl = new URL(requestUrl); - } catch { - parsedUrl = null; - } - const base = { - action, - host: parsedUrl?.host ?? 'unknown', - pathname: parsedUrl?.pathname ?? requestUrl, - }; - - if (axios.isAxiosError(error)) { - return { - ...base, - type: 'AxiosError', - code: error.code, - status: error.response?.status, - message: error.message, - syscall: (error as NodeJS.ErrnoException).syscall, - hostname: (error as any).hostname, - }; - } - - if (error && typeof error === 'object') { - const errObj = error as Record; - return { - ...base, - type: 'ErrorObject', - status: errObj.status, - message: errObj.message, - }; - } - - return { - ...base, - type: 'UnknownError', - message: String(error), - }; -} - -function buildXtreamApiUrl(url: string, params: Record): URL { - const baseUrl = normalizeXtreamServerUrl(url); - const apiUrl = new URL(`${baseUrl}/player_api.php`); - Object.entries(params).forEach(([key, value]) => { - apiUrl.searchParams.append( - key, - key === 'username' || key === 'password' ? value.trim() : value - ); - }); - - return apiUrl; -} - /** * Handle Xtream Codes API requests */ @@ -217,38 +162,10 @@ ipcMain.handle( ); } - // Format error response - if (axios.isAxiosError(error)) { - if (error.code === 'ERR_CANCELED') { - throw { - type: 'ERROR', - name: 'AbortError', - message: 'Xtream request cancelled', - status: 499, - }; - } - const errorResponse = { - type: 'ERROR', - message: - error.response?.data?.message || - error.message || - 'Failed to fetch data from Xtream server', - status: error.response?.status || 500, - }; - throw errorResponse; - } else if ( - error && - typeof error === 'object' && - 'message' in error - ) { - throw error; - } else { - throw { - type: 'ERROR', - message: 'An unknown error occurred', - status: 500, - }; - } + // Returning a structured failure keeps background requests from + // becoming rejected Electron handlers while preserving status and + // message details for the renderer. + return createXtreamErrorResponse(error); } finally { if (activeRequestKey) { activeXtreamRequests.delete(activeRequestKey); diff --git a/apps/electron-backend/src/app/services/dlna-protocol.ts b/apps/electron-backend/src/app/services/dlna-protocol.ts new file mode 100644 index 000000000..600174f70 --- /dev/null +++ b/apps/electron-backend/src/app/services/dlna-protocol.ts @@ -0,0 +1,216 @@ +import { request as httpRequest } from 'http'; +import { request as httpsRequest } from 'https'; +import { isIP } from 'net'; +import { + isLocalHostname, + isPrivateOrReservedIpv4, + isPrivateOrReservedIpv6, +} from '../events/url-safety'; + +export const SSDP_ADDRESS = '239.255.255.250'; +export const SSDP_PORT = 1900; + +const MAX_DLNA_RESPONSE_BYTES = 512 * 1024; +const DLNA_REQUEST_TIMEOUT_MS = 4_000; + +interface TimeoutRequest { + destroy(error: Error): void; + once(event: 'close', listener: () => void): void; +} + +export interface SsdpResponse { + location: string; + usn: string; +} + +export function buildSsdpSearchRequest(): string { + return [ + 'M-SEARCH * HTTP/1.1', + `HOST: ${SSDP_ADDRESS}:${SSDP_PORT}`, + 'MAN: "ssdp:discover"', + 'MX: 2', + 'ST: urn:schemas-upnp-org:device:MediaRenderer:1', + '', + '', + ].join('\r\n'); +} + +export function parseSsdpResponse(rawResponse: string): SsdpResponse | null { + const lines = rawResponse.split(/\r?\n/); + if (!/^HTTP\/1\.[01]\s+200\b/i.test(lines[0]?.trim() ?? '')) { + return null; + } + + const headers = new Map(); + for (const line of lines.slice(1)) { + const separator = line.indexOf(':'); + if (separator <= 0) continue; + headers.set( + line.slice(0, separator).trim().toLowerCase(), + line.slice(separator + 1).trim() + ); + } + + const location = headers.get('location'); + const usn = headers.get('usn'); + return location && usn ? { location, usn } : null; +} + +export function isTrustedSsdpLocation( + location: string, + responderAddress: string +): boolean { + try { + const url = new URL(location); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + !url.username && + !url.password && + isPrivateNetworkAddress(responderAddress) + ); + } catch { + return false; + } +} + +export function requestPinnedText( + targetUrl: string, + address: string, + options: { + method?: 'GET' | 'POST'; + body?: string; + headers?: Record; + } = {} +): Promise { + return new Promise((resolve, reject) => { + if (!isTrustedSsdpLocation(targetUrl, address)) { + reject(new Error('DLNA renderer URL is not trusted.')); + return; + } + const target = new URL(targetUrl); + const request = + target.protocol === 'https:' ? httpsRequest : httpRequest; + const body = options.body ?? ''; + const req = request( + { + protocol: target.protocol, + hostname: address, + port: target.port || undefined, + path: `${target.pathname}${target.search}`, + method: options.method ?? 'GET', + servername: target.hostname, + headers: { + Host: target.host, + ...options.headers, + ...(body + ? { 'Content-Length': Buffer.byteLength(body) } + : {}), + }, + }, + (response) => { + if ( + !response.statusCode || + response.statusCode < 200 || + response.statusCode >= 300 + ) { + response.resume(); + reject( + new Error( + `DLNA renderer returned HTTP ${response.statusCode ?? 0}.` + ) + ); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + response.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_DLNA_RESPONSE_BYTES) { + req.destroy( + new Error('DLNA response exceeded the size limit.') + ); + return; + } + chunks.push(chunk); + }); + response.on('end', () => + resolve(Buffer.concat(chunks).toString('utf8')) + ); + } + ); + // Absolute deadline is deliberately longer than the per-socket + // inactivity timeout: setTimeout below catches an idle socket quickly, + // while this bounds total wall-clock time so a trickle-data sender + // (a byte every few ms) is still terminated. + enforceAbsoluteRequestTimeout(req, 10_000); + req.setTimeout(DLNA_REQUEST_TIMEOUT_MS, () => + req.destroy(new Error('DLNA renderer request timed out.')) + ); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +export function enforceAbsoluteRequestTimeout( + request: TimeoutRequest, + timeoutMs = DLNA_REQUEST_TIMEOUT_MS +): void { + const deadline = setTimeout(() => { + request.destroy(new Error('DLNA renderer request timed out.')); + }, timeoutMs); + deadline.unref(); + request.once('close', () => clearTimeout(deadline)); +} + +export function isReceiverFetchableUrl(streamUrl: string): boolean { + try { + const url = new URL(streamUrl); + const hostname = normalizeHostname(url.hostname); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + !url.username && + !url.password && + !isLocalHostname(hostname) && + isReceiverFetchableHost(hostname) + ); + } catch { + return false; + } +} + +function isPrivateNetworkAddress(address: string): boolean { + if (isIP(address) !== 4) return false; + const [a, b] = address.split('.').map(Number); + return ( + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); +} + +function isReceiverFetchableHost(hostname: string): boolean { + const version = isIP(hostname); + if (version === 0) return true; + if (version === 6) { + if (hostname.startsWith('fc') || hostname.startsWith('fd')) { + return true; + } + return !isPrivateOrReservedIpv6(hostname); + } + + const [first, second] = hostname.split('.').map(Number); + const isPrivateLan = + first === 10 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168); + return isPrivateLan || !isPrivateOrReservedIpv4(hostname); +} + +function normalizeHostname(hostname: string): string { + const normalized = hostname.toLowerCase(); + return normalized.startsWith('[') && normalized.endsWith(']') + ? normalized.slice(1, -1) + : normalized; +} diff --git a/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts b/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts new file mode 100644 index 000000000..f44031b10 --- /dev/null +++ b/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts @@ -0,0 +1,202 @@ +import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { EventEmitter } from 'events'; +import { + buildSsdpSearchRequest, + buildUpnpActionBody, + DlnaRendererService, + isReceiverFetchableUrl, + isTrustedSsdpLocation, + parseRendererDescription, + parseSsdpResponse, +} from './dlna-renderer.service'; +import { enforceAbsoluteRequestTimeout } from './dlna-protocol'; + +describe('DLNA renderer protocol helpers', () => { + it('builds a standards-compliant MediaRenderer discovery request', () => { + expect(buildSsdpSearchRequest()).toContain('M-SEARCH * HTTP/1.1\r\n'); + expect(buildSsdpSearchRequest()).toContain( + 'ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n' + ); + expect(buildSsdpSearchRequest()).toContain('MX: 2\r\n'); + }); + + it('parses case-insensitive SSDP response headers', () => { + const response = parseSsdpResponse( + [ + 'HTTP/1.1 200 OK', + 'CACHE-CONTROL: max-age=1800', + 'Location: http://192.168.1.50:1400/xml/device_description.xml', + 'USN: uuid:renderer-1::urn:schemas-upnp-org:device:MediaRenderer:1', + '', + '', + ].join('\r\n') + ); + + expect(response).toEqual( + expect.objectContaining({ + location: 'http://192.168.1.50:1400/xml/device_description.xml', + usn: 'uuid:renderer-1::urn:schemas-upnp-org:device:MediaRenderer:1', + }) + ); + }); + + it('pins description requests to the private SSDP responder', () => { + expect( + isTrustedSsdpLocation( + 'http://tv.local:1400/device.xml', + '192.168.1.50' + ) + ).toBe(true); + expect( + isTrustedSsdpLocation( + 'http://attacker.example/device.xml', + '203.0.113.10' + ) + ).toBe(false); + expect( + isTrustedSsdpLocation('file:///etc/passwd', '192.168.1.50') + ).toBe(false); + expect( + isTrustedSsdpLocation('http://localhost/device.xml', '127.0.0.1') + ).toBe(false); + expect( + isTrustedSsdpLocation( + 'http://renderer.local/device.xml', + '169.254.10.20' + ) + ).toBe(false); + }); + + it('extracts renderer identity and AVTransport control URL from XML', () => { + const description = parseRendererDescription( + ` + + + Living Room & TV + Example Renderer + uuid:renderer-1 + + + urn:schemas-upnp-org:service:AVTransport:1 + /MediaRenderer/AVTransport/Control + + + + ` + ); + + expect(description).toEqual({ + friendlyName: 'Living Room & TV', + modelName: 'Example Renderer', + udn: 'uuid:renderer-1', + avTransportControlUrl: '/MediaRenderer/AVTransport/Control', + }); + }); + + it('escapes untrusted media values in UPnP SOAP actions', () => { + const body = buildUpnpActionBody('SetAVTransportURI', { + InstanceID: '0', + CurrentURI: 'https://example.com/live?a=1&b=', + CurrentURIMetaData: '', + }); + + expect(body).toContain( + 'https://example.com/live?a=1&b=<bad>' + ); + expect(body).not.toContain(''); + }); + + it.each([ + 'http://localhost/live.m3u8', + 'http://127.0.0.1/live.m3u8', + 'http://169.254.169.254/latest/meta-data', + 'http://[::1]/live.m3u8', + 'http://[fe80::1]/live.m3u8', + 'http://224.0.0.1/live.m3u8', + ])( + 'rejects unsafe receiver targets at the IPC boundary: %s', + async (url) => { + expect(isReceiverFetchableUrl(url)).toBe(false); + + const service = new DlnaRendererService(); + const rendererCache = ( + service as unknown as { + renderers: Map; + } + ).renderers; + rendererCache.set('renderer-1', { + id: 'renderer-1', + name: 'TV', + address: '192.168.1.50', + controlUrl: 'http://192.168.1.50/control', + expiresAt: Date.now() + 60_000, + }); + + await expect( + service.startPlayback('renderer-1', { + streamUrl: url, + title: 'Unsafe', + }) + ).resolves.toEqual( + expect.objectContaining({ + success: false, + }) + ); + } + ); + + it.each([ + 'http://192.168.1.20/live.m3u8', + 'http://10.0.0.20/live.m3u8', + 'https://stream.example/live.m3u8', + ])('allows receiver-fetchable LAN and public targets: %s', (url) => { + expect(isReceiverFetchableUrl(url)).toBe(true); + }); + + it('rejects malformed renderer IPC playback payloads', async () => { + const service = new DlnaRendererService(); + const rendererCache = ( + service as unknown as { + renderers: Map; + } + ).renderers; + rendererCache.set('renderer-1', { + id: 'renderer-1', + name: 'TV', + address: '192.168.1.50', + controlUrl: 'http://192.168.1.50/control', + expiresAt: Date.now() + 60_000, + }); + + await expect( + service.startPlayback( + 'renderer-1', + null as unknown as ResolvedPortalPlayback + ) + ).resolves.toEqual({ + success: false, + error: 'Invalid DLNA playback request.', + }); + }); + + it('enforces and clears the absolute DLNA request deadline', () => { + jest.useFakeTimers(); + const request = new EventEmitter() as EventEmitter & { + destroy: jest.Mock; + }; + request.destroy = jest.fn(); + + enforceAbsoluteRequestTimeout(request, 100); + jest.advanceTimersByTime(100); + expect(request.destroy).toHaveBeenCalledWith( + new Error('DLNA renderer request timed out.') + ); + + request.destroy.mockClear(); + enforceAbsoluteRequestTimeout(request, 100); + request.emit('close'); + jest.advanceTimersByTime(100); + expect(request.destroy).not.toHaveBeenCalled(); + jest.useRealTimers(); + }); +}); diff --git a/apps/electron-backend/src/app/services/dlna-renderer.service.ts b/apps/electron-backend/src/app/services/dlna-renderer.service.ts new file mode 100644 index 000000000..a27e68f93 --- /dev/null +++ b/apps/electron-backend/src/app/services/dlna-renderer.service.ts @@ -0,0 +1,250 @@ +import { createSocket } from 'dgram'; +import { + DlnaRendererDevice, + ElectronBridgeErrorResult, + hasPlaybackHeaders, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { + SSDP_ADDRESS, + SSDP_PORT, + SsdpResponse, + buildSsdpSearchRequest, + isReceiverFetchableUrl, + isTrustedSsdpLocation, + parseSsdpResponse, + requestPinnedText, +} from './dlna-protocol'; +import { + AV_TRANSPORT_SERVICE, + buildUpnpActionBody, + parseRendererDescription, +} from './dlna-xml'; + +export { + buildSsdpSearchRequest, + isTrustedSsdpLocation, + isReceiverFetchableUrl, + parseSsdpResponse, +} from './dlna-protocol'; +export { buildUpnpActionBody, parseRendererDescription } from './dlna-xml'; + +const DEVICE_CACHE_TTL_MS = 5 * 60_000; + +interface RendererCandidate extends SsdpResponse { + address: string; +} + +interface CachedRenderer extends DlnaRendererDevice { + address: string; + controlUrl: string; + expiresAt: number; +} + +export class DlnaRendererService { + private readonly renderers = new Map(); + + async discover(timeoutMs = 2_200): Promise { + this.pruneExpiredRenderers(); + const candidates = await this.discoverCandidates(timeoutMs); + const descriptions = await Promise.allSettled( + candidates + .slice(0, 32) + .map((candidate) => this.resolveRenderer(candidate)) + ); + + return descriptions.flatMap((result) => + result.status === 'fulfilled' && result.value + ? [toPublicDevice(result.value)] + : [] + ); + } + + async startPlayback( + deviceId: string, + playback: ResolvedPortalPlayback + ): Promise { + if ( + typeof deviceId !== 'string' || + !deviceId || + !isPlaybackPayload(playback) + ) { + return { + success: false, + error: 'Invalid DLNA playback request.', + }; + } + + const renderer = this.renderers.get(deviceId); + if (!renderer || renderer.expiresAt < Date.now()) { + return { + success: false, + error: 'DLNA renderer is no longer available.', + }; + } + if (!isReceiverFetchableUrl(playback.streamUrl)) { + return { + success: false, + error: 'The stream URL cannot be fetched by a DLNA renderer.', + }; + } + if (hasPlaybackHeaders(playback)) { + return { + success: false, + error: 'DLNA renderers cannot inherit provider request headers.', + }; + } + + try { + await this.sendAction(renderer, 'SetAVTransportURI', { + InstanceID: '0', + CurrentURI: playback.streamUrl, + CurrentURIMetaData: '', + }); + await this.sendAction(renderer, 'Play', { + InstanceID: '0', + Speed: '1', + }); + return { success: true }; + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? error.message + : 'DLNA playback failed.', + }; + } + } + + private discoverCandidates( + timeoutMs: number + ): Promise { + return new Promise((resolve, reject) => { + const socket = createSocket({ type: 'udp4', reuseAddr: true }); + const candidates = new Map(); + let settled = false; + let bound = false; + + const finish = (error?: Error) => { + if (settled) return; + settled = true; + if (bound) socket.close(); + if (error) { + reject(error); + return; + } + resolve([...candidates.values()]); + }; + const timeout = setTimeout(() => finish(), timeoutMs); + + socket.on('message', (message, remote) => { + const response = parseSsdpResponse(message.toString('utf8')); + if ( + !response || + !isTrustedSsdpLocation(response.location, remote.address) + ) { + return; + } + candidates.set(response.usn, { + ...response, + address: remote.address, + }); + }); + socket.on('error', (error) => { + clearTimeout(timeout); + finish(error); + }); + socket.bind(0, () => { + bound = true; + if (settled) { + socket.close(); + return; + } + const request = Buffer.from(buildSsdpSearchRequest()); + socket.send(request, SSDP_PORT, SSDP_ADDRESS, (error) => { + if (error) { + clearTimeout(timeout); + finish(error); + } + }); + }); + }); + } + + private async resolveRenderer( + candidate: RendererCandidate + ): Promise { + const xml = await requestPinnedText( + candidate.location, + candidate.address + ); + const description = parseRendererDescription(xml); + if (!description) return null; + + const controlUrl = new URL( + description.avTransportControlUrl, + candidate.location + ).toString(); + if (!isTrustedSsdpLocation(controlUrl, candidate.address)) return null; + + const renderer: CachedRenderer = { + id: description.udn || candidate.usn, + name: description.friendlyName, + modelName: description.modelName || undefined, + address: candidate.address, + controlUrl, + expiresAt: Date.now() + DEVICE_CACHE_TTL_MS, + }; + this.renderers.set(renderer.id, renderer); + return renderer; + } + + private async sendAction( + renderer: CachedRenderer, + action: string, + values: Record + ): Promise { + const body = buildUpnpActionBody(action, values); + await requestPinnedText(renderer.controlUrl, renderer.address, { + method: 'POST', + body, + headers: { + 'Content-Type': 'text/xml; charset="utf-8"', + SOAPAction: `"${AV_TRANSPORT_SERVICE}#${action}"`, + }, + }); + } + + private pruneExpiredRenderers(): void { + const now = Date.now(); + for (const [id, renderer] of this.renderers) { + if (renderer.expiresAt < now) { + this.renderers.delete(id); + } + } + } +} + +function toPublicDevice(renderer: CachedRenderer): DlnaRendererDevice { + return { + id: renderer.id, + name: renderer.name, + modelName: renderer.modelName, + }; +} + +function isPlaybackPayload( + playback: ResolvedPortalPlayback +): playback is ResolvedPortalPlayback { + return Boolean( + playback && + typeof playback === 'object' && + typeof playback.streamUrl === 'string' && + playback.streamUrl && + (playback.headers === undefined || + (playback.headers !== null && + typeof playback.headers === 'object' && + !Array.isArray(playback.headers))) + ); +} diff --git a/apps/electron-backend/src/app/services/dlna-xml.ts b/apps/electron-backend/src/app/services/dlna-xml.ts new file mode 100644 index 000000000..634bedd44 --- /dev/null +++ b/apps/electron-backend/src/app/services/dlna-xml.ts @@ -0,0 +1,105 @@ +import { SaxesParser } from 'saxes'; + +export const AV_TRANSPORT_SERVICE = + 'urn:schemas-upnp-org:service:AVTransport:1'; + +export interface RendererDescription { + friendlyName: string; + modelName: string; + udn: string; + avTransportControlUrl: string; +} + +export function parseRendererDescription( + xml: string +): RendererDescription | null { + const values: Partial = {}; + let currentServiceType = ''; + let currentControlUrl = ''; + const elements: Array<{ name: string; text: string }> = []; + const parser = new SaxesParser(); + + parser.on('opentag', (tag) => { + elements.push({ name: localName(tag.name), text: '' }); + }); + parser.on('text', (text) => { + const current = elements.at(-1); + if (current) current.text += text; + }); + parser.on('closetag', () => { + const current = elements.pop(); + if (!current) return; + const value = current.text.trim(); + + switch (current.name) { + case 'friendlyName': + values.friendlyName ||= value; + break; + case 'modelName': + values.modelName ||= value; + break; + case 'UDN': + values.udn ||= value; + break; + case 'serviceType': + currentServiceType = value; + break; + case 'controlURL': + currentControlUrl = value; + break; + case 'service': + if (currentServiceType === AV_TRANSPORT_SERVICE) { + values.avTransportControlUrl = currentControlUrl; + } + currentServiceType = ''; + currentControlUrl = ''; + break; + } + }); + + try { + parser.write(xml).close(); + } catch { + return null; + } + + if (!values.friendlyName || !values.avTransportControlUrl) { + return null; + } + + return { + friendlyName: values.friendlyName, + modelName: values.modelName ?? '', + udn: values.udn ?? '', + avTransportControlUrl: values.avTransportControlUrl, + }; +} + +export function buildUpnpActionBody( + action: string, + values: Record +): string { + const fields = Object.entries(values) + .map(([name, value]) => `<${name}>${escapeXml(value)}`) + .join(''); + return ( + '' + + '' + + `` + + `${fields}` + ); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function localName(name: string): string { + return name.split(':').at(-1) ?? name; +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 9ecc6b0d2..55c690985 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -9,6 +9,7 @@ import { setMainWindow as setDownloadsMainWindow, } from './app/events/database/downloads.events'; import ElectronEvents from './app/events/electron.events'; +import CastingEvents from './app/events/casting.events'; import EmbeddedMpvEvents, { shutdownEmbeddedMpv, } from './app/events/embedded-mpv.events'; @@ -91,6 +92,7 @@ export default class Main { } ElectronEvents.bootstrapElectronEvents(); + CastingEvents.bootstrapCastingEvents(); WindowEvents.bootstrapWindowEvents(); EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); PlaylistEvents.bootstrapPlaylistEvents(); diff --git a/apps/web/project.json b/apps/web/project.json index f7b179709..0c735ec4a 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -85,6 +85,10 @@ }, "pwa": { "baseHref": "/", + "index": { + "input": "apps/web/src/index.pwa.html", + "output": "index.html" + }, "budgets": [ { "type": "initial", diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index ad0c5015b..5acacc91e 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -7,8 +7,11 @@ import { DialogService } from '@iptvnator/ui/components'; import { SettingsStore } from '@iptvnator/services'; import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + ERROR, PLAYLIST_PARSE_BY_URL, SECURITY_ERROR_PREFIX, + XTREAM_REQUEST, + XtreamCodeActions, } from '@iptvnator/shared/interfaces'; import { ElectronService } from './electron.service'; @@ -18,6 +21,7 @@ describe('ElectronService', () => { fetchPlaylistByUrl: jest.Mock; openInMpv: jest.Mock; openInVlc: jest.Mock; + xtreamRequest: jest.Mock; }; let snackBar: { open: jest.Mock }; let service: ElectronService; @@ -29,6 +33,7 @@ describe('ElectronService', () => { fetchPlaylistByUrl: jest.fn(), openInMpv: jest.fn().mockResolvedValue(session), openInVlc: jest.fn().mockResolvedValue(session), + xtreamRequest: jest.fn(), }; snackBar = { open: jest.fn(() => ({ @@ -170,4 +175,57 @@ describe('ElectronService', () => { } ); }); + + it('keeps structured background Xtream failures silent', async () => { + electronBridge.xtreamRequest.mockResolvedValue({ + type: ERROR, + message: 'Request failed with status code 520', + status: 520, + }); + + const result = await service.sendIpcEvent(XTREAM_REQUEST, { + url: 'https://provider.example', + params: { + action: XtreamCodeActions.GetShortEpg, + username: 'user', + password: 'secret', + }, + suppressErrorLog: true, + }); + + expect(result).toEqual({ + type: ERROR, + message: 'Request failed with status code 520', + status: 520, + }); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('preserves structured Xtream cancellation metadata', async () => { + electronBridge.xtreamRequest.mockResolvedValue({ + type: ERROR, + name: 'AbortError', + message: 'Xtream request cancelled', + status: 499, + }); + + const result = await service.sendIpcEvent(XTREAM_REQUEST, { + url: 'https://provider.example', + params: { + action: XtreamCodeActions.GetLiveStreams, + username: 'user', + password: 'secret', + }, + sessionId: 'session-1', + suppressErrorLog: true, + }); + + expect(result).toEqual({ + type: ERROR, + name: 'AbortError', + message: 'Xtream request cancelled', + status: 499, + }); + expect(snackBar.open).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index e65717678..bd162f353 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -40,6 +40,7 @@ interface PlayerLaunchPayload { interface ErrorStatus { readonly message?: string; + readonly name?: string; readonly status?: number; } @@ -572,6 +573,10 @@ export class ElectronService extends DataService { requestId: context.requestId, }); + if ('type' in response) { + throw response; + } + const result = { type: XTREAM_RESPONSE, payload: response.payload, @@ -610,6 +615,7 @@ export class ElectronService extends DataService { return { type: ERROR, + ...(errorInfo?.name ? { name: errorInfo.name } : {}), status: errorInfo?.status ?? 500, message: normalizedMessage, }; diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index a635153a3..cc1dc3181 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -410,6 +410,22 @@ "UNMUTE": "إلغاء كتم الصوت", "VOLUME": "مستوى الصوت" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "إظهار القائمة", + "COLLAPSE_NAVIGATION": "إخفاء القائمة", "RAIL_DASHBOARD": "لوحة التحكم", "OPEN_DASHBOARD": "فتح لوحة التحكم", "RAIL_SOURCES": "المصادر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index a617c041e..a569178be 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -410,6 +410,22 @@ "UNMUTE": "رجّع الصوت", "VOLUME": "الصوت" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "وسّع التنقل", + "COLLAPSE_NAVIGATION": "طوي التنقل", "RAIL_DASHBOARD": "لوحة التحكم", "OPEN_DASHBOARD": "حل لوحة التحكم", "RAIL_SOURCES": "المصادر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7c39d2209..d0566dd85 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -410,6 +410,22 @@ "UNMUTE": "Уключыць гук", "VOLUME": "Гучнасць" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Разгарнуць навігацыю", + "COLLAPSE_NAVIGATION": "Згарнуць навігацыю", "RAIL_DASHBOARD": "Панэль", "OPEN_DASHBOARD": "Адкрыць панэль", "RAIL_SOURCES": "Крыніцы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f1a83f3f8..8ebf5c5ad 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -410,6 +410,22 @@ "UNMUTE": "Stummschaltung aufheben", "VOLUME": "Lautstärke" }, + "CASTING": { + "OPEN": "Auf einem Gerät wiedergeben", + "TITLE": "Auf einem anderen Gerät wiedergeben", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "In Safari auf unterstützten Apple-Geräten verfügbar", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Benötigt die sichere Web-App und eine direkte Stream-URL", + "REMOTE_PLAYBACK": "Browser-Remote-Wiedergabe", + "DLNA_DEVICES": "DLNA- / UPnP-Geräte", + "REFRESH": "Erneut suchen", + "SEARCHING": "Geräte werden gesucht...", + "NO_DLNA_DEVICES": "Keine DLNA-Renderer in diesem Netzwerk gefunden.", + "DIRECT_URL_REQUIRED": "Remote-Geräte benötigen einen direkten HTTP(S)-Stream ohne Provider-Header.", + "DISCOVERY_FAILED": "Die Gerätesuche ist fehlgeschlagen.", + "PLAYBACK_FAILED": "Das Remote-Gerät konnte diesen Stream nicht starten." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Navigation ausklappen", + "COLLAPSE_NAVIGATION": "Navigation einklappen", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Dashboard öffnen", "RAIL_SOURCES": "Quellen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 775bf208d..d14168094 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -410,6 +410,22 @@ "UNMUTE": "Κατάργηση σίγασης", "VOLUME": "Ένταση" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Ανάπτυξη πλοήγησης", + "COLLAPSE_NAVIGATION": "Σύμπτυξη πλοήγησης", "RAIL_DASHBOARD": "Πίνακας ελέγχου", "OPEN_DASHBOARD": "Άνοιγμα πίνακα ελέγχου", "RAIL_SOURCES": "Πηγές", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 8fadf88f0..070c3a6d4 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -410,6 +410,24 @@ "UNMUTE": "Unmute", "VOLUME": "Volume" }, + "CASTING": { + "OPEN": "Cast to a device", + "ENTER_FULLSCREEN": "Fullscreen", + "EXIT_FULLSCREEN": "Exit fullscreen", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +931,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Expand navigation", + "COLLAPSE_NAVIGATION": "Collapse navigation", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Sources", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a36753a51..7148ee77b 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -410,6 +410,22 @@ "UNMUTE": "Activar sonido", "VOLUME": "Volumen" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Ampliar navegación", + "COLLAPSE_NAVIGATION": "Contraer navegación", "RAIL_DASHBOARD": "Panel", "OPEN_DASHBOARD": "Abrir panel", "RAIL_SOURCES": "Fuentes", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ab0150f91..d15bf9154 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -410,6 +410,22 @@ "UNMUTE": "Activer le son", "VOLUME": "Volume" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Développer la navigation", + "COLLAPSE_NAVIGATION": "Réduire la navigation", "RAIL_DASHBOARD": "Tableau de bord", "OPEN_DASHBOARD": "Ouvrir le tableau de bord", "RAIL_SOURCES": "Sources", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0099b270f..a242576ba 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -410,6 +410,22 @@ "UNMUTE": "Attiva audio", "VOLUME": "Volume" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Espandi navigazione", + "COLLAPSE_NAVIGATION": "Comprimi navigazione", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Apri dashboard", "RAIL_SOURCES": "Sorgenti", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e7b5bfcbb..fcf3d3e73 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -410,6 +410,22 @@ "UNMUTE": "ミュート解除", "VOLUME": "音量" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "ナビゲーションを展開", + "COLLAPSE_NAVIGATION": "ナビゲーションを折りたたむ", "RAIL_DASHBOARD": "ダッシュボード", "OPEN_DASHBOARD": "ダッシュボードを開く", "RAIL_SOURCES": "ソース", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f00557a87..d52c10b45 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -410,6 +410,22 @@ "UNMUTE": "음소거 해제", "VOLUME": "볼륨" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "탐색 펼치기", + "COLLAPSE_NAVIGATION": "탐색 접기", "RAIL_DASHBOARD": "대시보드", "OPEN_DASHBOARD": "대시보드 열기", "RAIL_SOURCES": "소스", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 62f211028..dcc7750ec 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -410,6 +410,22 @@ "UNMUTE": "Dempen opheffen", "VOLUME": "Volume" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Navigatie uitklappen", + "COLLAPSE_NAVIGATION": "Navigatie inklappen", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Bronnen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e2c93f1a4..9698f6d9b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -410,6 +410,22 @@ "UNMUTE": "Wyłącz wyciszenie", "VOLUME": "Głośność" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Rozwiń nawigację", + "COLLAPSE_NAVIGATION": "Zwiń nawigację", "RAIL_DASHBOARD": "Pulpit", "OPEN_DASHBOARD": "Otwórz pulpit", "RAIL_SOURCES": "Źródła", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e49fbfc48..f81b7e7d2 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -410,6 +410,22 @@ "UNMUTE": "Ativar som", "VOLUME": "Volume" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Expandir navegação", + "COLLAPSE_NAVIGATION": "Recolher navegação", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Abrir dashboard", "RAIL_SOURCES": "Fontes", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1d6495e75..90a06eb55 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -410,6 +410,22 @@ "UNMUTE": "Включить звук", "VOLUME": "Громкость" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "Контейнер потока, вероятно, не поддерживается встроенным браузерным плеером", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Развернуть навигацию", + "COLLAPSE_NAVIGATION": "Свернуть навигацию", "RAIL_DASHBOARD": "Панель", "OPEN_DASHBOARD": "Открыть панель", "RAIL_SOURCES": "Источники", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 126c30ca6..64a64bf38 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -410,6 +410,22 @@ "UNMUTE": "Sesi aç", "VOLUME": "Ses seviyesi" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Gezinmeyi genişlet", + "COLLAPSE_NAVIGATION": "Gezinmeyi daralt", "RAIL_DASHBOARD": "Pano", "OPEN_DASHBOARD": "Panoyu aç", "RAIL_SOURCES": "Kaynaklar", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 49c2e475d..edad3df71 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -410,6 +410,22 @@ "UNMUTE": "取消静音", "VOLUME": "音量" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "展开导航", + "COLLAPSE_NAVIGATION": "收起导航", "RAIL_DASHBOARD": "仪表盘", "OPEN_DASHBOARD": "打开仪表盘", "RAIL_SOURCES": "源", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4bfcbde42..2dbe3e865 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -410,6 +410,22 @@ "UNMUTE": "取消靜音", "VOLUME": "音量" }, + "CASTING": { + "OPEN": "Cast to a device", + "TITLE": "Play on another device", + "AIRPLAY": "AirPlay", + "SAFARI_ONLY": "Available in Safari on supported Apple devices", + "GOOGLE_CAST": "Google Cast / Chromecast", + "SECURE_PWA_ONLY": "Requires the secure web app and a direct stream URL", + "REMOTE_PLAYBACK": "Browser remote playback", + "DLNA_DEVICES": "DLNA / UPnP devices", + "REFRESH": "Search again", + "SEARCHING": "Searching for devices...", + "NO_DLNA_DEVICES": "No DLNA renderers found on this network.", + "DIRECT_URL_REQUIRED": "Remote devices need a direct HTTP(S) stream without provider headers.", + "DISCOVERY_FAILED": "Device discovery failed.", + "PLAYBACK_FAILED": "The remote device could not start this stream." + }, "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", @@ -913,6 +929,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "展開導覽", + "COLLAPSE_NAVIGATION": "收合導覽", "RAIL_DASHBOARD": "儀表板", "OPEN_DASHBOARD": "開啟儀表板", "RAIL_SOURCES": "來源", diff --git a/apps/web/src/index.html b/apps/web/src/index.html index c8e2b90e2..6139c06c0 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -8,7 +8,7 @@ diff --git a/apps/web/src/index.pwa.html b/apps/web/src/index.pwa.html new file mode 100644 index 000000000..3f7bdc9dc --- /dev/null +++ b/apps/web/src/index.pwa.html @@ -0,0 +1,82 @@ + + + + + IPTVnator + + + + + + + + + + + + + + + + +
+ IPTVnator + +
+ + + diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 6f4d581fe..974016aaf 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -58,11 +58,12 @@ default browser unless the app window is deliberately meant to host that URL. The Angular shell defines a baseline CSP in `apps/web/src/index.html`. -The policy keeps the application self-hosted for scripts, blocks object and -frame embedding, limits forms to the app origin, and allows IPTV playback -sources through `media-src` and `connect-src` for `http:`, `https:`, `blob:`, -and `data:`. The policy keeps `script-src` self-hosted and currently keeps -`unsafe-inline` for existing inline styles. +The policy keeps application scripts self-hosted except for the official Google +Cast Web Sender SDK at `https://www.gstatic.com`, blocks object and frame +embedding, limits forms to the app origin, and allows IPTV playback sources +through `media-src` and `connect-src` for `http:`, `https:`, `blob:`, and +`data:`. The policy currently keeps `unsafe-inline` for existing inline styles. +Do not broaden the Cast exception to a wildcard Google domain or another CDN. Angular production builds must not rely on inline event handlers for stylesheet activation. Keep `web:build:production` and `web:build:pwa` configured without diff --git a/docs/architecture/remote-playback.md b/docs/architecture/remote-playback.md new file mode 100644 index 000000000..4a9e0931c --- /dev/null +++ b/docs/architecture/remote-playback.md @@ -0,0 +1,120 @@ +# Remote Playback And Casting + +This document records the remote playback contract for IPTVnator's shared +players. + +## Scope + +The cast control is always present on: + +- the shared video viewport used by Video.js, HTML5, ArtPlayer, and embedded MPV +- the dedicated radio/audio player +- the external MPV/VLC playback dock + +The feature hands a media URL to a receiver. It does not mirror the IPTVnator +window or the operating-system desktop. + +## Runtime Matrix + +| Protocol | Runtime | Implementation | +| --------------- | ----------------------------------------------------------- | -------------------------------------------------------------- | +| AirPlay | Safari/WebKit PWA when the media element exposes the picker | Native `webkitShowPlaybackTargetPicker()` | +| Google Cast | Secure PWA | Official Google Cast Web Sender SDK and Default Media Receiver | +| Remote Playback | Browser exposing the Remote Playback API | Native media-element `remote.prompt()` | +| DLNA/UPnP | Electron | Main-process SSDP discovery and AVTransport SOAP actions | + +Unsupported choices remain visible but disabled so the control has a stable +location across players and runtimes. + +In the shared video viewport, the overlay follows the player controls: it +fades after 2.5 seconds of inactivity and returns on pointer or keyboard +activity. Hover, keyboard focus, and an open device menu keep it visible. The +radio player and external-player dock retain their always-visible controls. + +## Receiver-Fetchable Media + +Remote receivers fetch the stream themselves. Casting is disabled when the +playback payload depends on: + +- `blob:`, `data:`, or `file:` URLs +- `localhost`, IPv4 loopback, or IPv6 loopback URLs +- embedded URL credentials +- provider request headers, user-agent, referer, or origin overrides + +The external-player session snapshot exposes only +`requiresRequestHeaders: true`; it never copies provider header values or +authorization credentials back to the renderer. + +The Default Media Receiver is intended for direct, receiver-compatible media. +Streams needing cookies, DRM, a custom receiver, provider headers, or a +sender-only local URL require a different architecture and must not silently +fall back to an unsafe request path. + +## Google Cast + +`CastService` loads the official sender SDK from: + +`https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1` + +The web CSP permits only that origin in addition to self-hosted scripts. SDK +load failures and timeouts clear the cached initialization promise so a later +user action can retry. + +Google Cast uses the Default Media Receiver application ID and sends a typed +media load request with title, thumbnail, live/buffered stream type, and a MIME +type inferred from the URL extension. Artwork is included only when it is a +receiver-fetchable URL on the same origin as the media stream, preventing an +untrusted playlist logo from introducing a second receiver-side request target. + +## DLNA/UPnP + +Electron owns DLNA networking: + +1. Send an SSDP `M-SEARCH` for MediaRenderer devices over UDP multicast. +2. Accept descriptions only from private IPv4 responders. +3. Pin HTTP(S) description and control requests to the SSDP responder address. +4. Parse renderer XML with `saxes`. +5. Cache an opaque renderer ID for five minutes. +6. Send `SetAVTransportURI`, then `Play`, to the cached AVTransport endpoint. + +The renderer never supplies an arbitrary request URL through IPC. Playback IPC +accepts only a cached device ID and a typed `ResolvedPortalPlayback` payload. +Pinned requests retain the advertised hostname for HTTP `Host`, TLS SNI, and +certificate validation while connecting to the validated SSDP source address. +Redirects are not followed, responses are size-limited, and requests have both +an inactivity timeout and an absolute deadline. + +## Ownership + +- UI control and browser protocols: + `libs/ui/playback/src/lib/casting/` +- shared renderer contract: + `libs/shared/interfaces/src/lib/casting.interface.ts` +- preload contract: + `libs/shared/interfaces/src/lib/electron-api.interface.ts` +- Electron IPC: + `apps/electron-backend/src/app/events/casting.events.ts` +- SSDP, HTTP pinning, and receiver URL validation: + `apps/electron-backend/src/app/services/dlna-protocol.ts` +- renderer XML parsing and SOAP serialization: + `apps/electron-backend/src/app/services/dlna-xml.ts` +- renderer discovery and playback: + `apps/electron-backend/src/app/services/dlna-renderer.service.ts` + +## Validation + +Use the affected Nx targets: + +```bash +pnpm nx test ui-playback --runInBand +pnpm nx test electron-backend --runInBand --testPathPatterns=dlna-renderer.service.spec.ts +pnpm nx test electron-backend --runInBand --testPathPatterns=external-player-session-registry.spec.ts +pnpm nx test workspace-shell-feature --runInBand +pnpm nx test components --runInBand +pnpm run typecheck:ci +pnpm run i18n:check +``` + +Real-device validation still requires compatible hardware on the same network. +At minimum verify the menu state in both Electron and a secure PWA, then test +each available protocol with a direct public sample stream. diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 991582789..1101859c8 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -68,6 +68,13 @@ Provider route integration: to block remote imports before the loading overlay starts. 5. Workspace routes no longer rely on nested provider shell components for hidden local chrome. +6. Electron Xtream requests resolve with either a success payload or a + structured `ERROR` result. Expected provider failures, including silent EPG + preview failures, must not reject the Electron IPC handler because Electron + logs every rejected handler as an application error. The renderer converts + the structured result back into the shared Xtream error path so status, + user-facing notifications, cancellation handling, and debug events retain + their existing behavior. ## Shell Structure @@ -153,6 +160,17 @@ Rail navigation is also shell-owned: 3. On dashboard, sources, settings, and global favorites, the shell falls back to the currently selected playlist so provider navigation remains available even outside a provider route. +4. The IPTVnator brand button toggles the primary rail between its default + 60px icon-only layout and an expanded, label-bearing layout. The expanded + width is content-driven by the longest visible label with equal horizontal + spacing around icons and text, capped to preserve the content area. Mobile + navigation remains compact and does not enter the expanded state. +5. Active-link markers must stay inset within the rail so selection styling is + not clipped by the rail's overflow boundary. +6. The navigation links own the rail's scroll area while the settings shortcut + remains visible in the fixed footer. +7. Expanded labels use automatic text direction and logical start alignment so + left-to-right and right-to-left translations align naturally. Command palette behavior is shell-owned but view-extensible: diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index a5d66acc1..555df6538 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -66,6 +66,7 @@ @if (activeChannel.radio === 'true') { @if (isRadioMode()) { (); readonly icon = input(''); readonly channelName = input(''); + readonly playback = input(null); readonly dispatchAdjacentChannelAction = input(true); readonly channelSwitchRequested = output<'next' | 'previous'>(); } @@ -278,6 +282,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { streamUrl: 'https://stream.example/jazz.mp3', title: 'Jazz FM', thumbnail: 'jazz.png', + headers: { Authorization: 'Bearer radio' }, }); portalPlayer.isEmbeddedPlayer.mockReset(); portalPlayer.isEmbeddedPlayer.mockReturnValue(true); @@ -660,6 +665,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(audioPlayer.url()).toBe('https://stream.example/jazz.mp3'); expect(audioPlayer.icon()).toBe('jazz.png'); expect(audioPlayer.channelName()).toBe('Jazz FM'); + expect(audioPlayer.playback()?.headers).toEqual({ + Authorization: 'Bearer radio', + }); expect(audioPlayer.dispatchAdjacentChannelAction()).toBe(false); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts index e49a07319..c295c1ffc 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts @@ -336,4 +336,24 @@ describe('XtreamApiService', () => { ); expect(shortItems[0].stop_timestamp).toBe(fullItems[0].stop_timestamp); }); + + it('preserves cancellation metadata from structured IPC errors', async () => { + dataService.sendIpcEvent.mockResolvedValue({ + type: 'ERROR', + name: 'AbortError', + message: 'Xtream request cancelled', + status: 499, + }); + + await expect( + service.getLiveStreams(credentials, { + sessionId: 'session-1', + suppressErrorLog: true, + }) + ).rejects.toMatchObject({ + name: 'AbortError', + message: 'Xtream request cancelled', + status: 499, + }); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index ec5a7db01..5a9ce3ccd 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -539,7 +539,9 @@ export class XtreamApiService { suppressErrorLog: options?.suppressErrorLog, })) as { message?: string; + name?: string; payload?: unknown; + status?: number; type?: string; }; @@ -550,7 +552,18 @@ export class XtreamApiService { response?.type === 'ERROR' || (!response?.payload && response?.message) ) { - throw new Error(response?.message ?? 'Request failed'); + const requestError = new Error( + response?.message ?? 'Request failed' + ) as Error & { status?: number }; + + if (response?.name) { + requestError.name = response.name; + } + if (typeof response?.status === 'number') { + requestError.status = response.status; + } + + throw requestError; } return response?.payload as TResponse; diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index f0e5516e0..52d6475cb 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -35,6 +35,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsEmbeddedMpv).toBe(false); expect(service.supportsDesktopFileSave).toBe(false); expect(service.supportsRemoteControl).toBe(false); + expect(service.supportsDlnaCasting).toBe(false); expect(service.supportsXtreamSectionNavigation).toBe(true); expect(service.supportsEpgImport).toBe(false); expect(service.supportsEpgProgress).toBe(false); @@ -120,6 +121,8 @@ describe('RuntimeCapabilitiesService', () => { updateRemoteControlStatus: jest.fn(), onChannelChange: jest.fn(), onRemoteControlCommand: jest.fn(), + discoverDlnaRenderers: jest.fn(), + startDlnaPlayback: jest.fn(), xtreamRequest: jest.fn(), fetchEpg: jest.fn(), getChannelPrograms: jest.fn(), @@ -155,6 +158,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsEmbeddedMpv).toBe(true); expect(service.supportsDesktopFileSave).toBe(true); expect(service.supportsRemoteControl).toBe(true); + expect(service.supportsDlnaCasting).toBe(true); expect(service.supportsXtreamSectionNavigation).toBe(true); expect(service.supportsEpgImport).toBe(true); expect(service.supportsEpgProgress).toBe(true); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index f56123f2d..75cc9156d 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -256,6 +256,13 @@ export class RuntimeCapabilitiesService { ); } + get supportsDlnaCasting(): boolean { + return ( + this.hasElectronMethod('discoverDlnaRenderers') && + this.hasElectronMethod('startDlnaPlayback') + ); + } + get supportsXtreamSectionNavigation(): boolean { return ( this.isPwa || diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 79d3c0086..fb1e732a0 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/channel.interface'; export * from './lib/channel.model'; +export * from './lib/casting.interface'; export * from './lib/dev-logger.util'; export * from './lib/embedded-mpv-session.interface'; export * from './lib/electron-api.interface'; @@ -25,6 +26,7 @@ export * from './lib/playlist.interface'; export * from './lib/portal-activity-item.interface'; export * from './lib/portal-debug.interface'; export * from './lib/portal-playback.interface'; +export * from './lib/portal-playback.utils'; export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; export * from './lib/stalker-portal-actions.enum'; diff --git a/libs/shared/interfaces/src/lib/casting.interface.ts b/libs/shared/interfaces/src/lib/casting.interface.ts new file mode 100644 index 000000000..d0092dd3a --- /dev/null +++ b/libs/shared/interfaces/src/lib/casting.interface.ts @@ -0,0 +1,5 @@ +export interface DlnaRendererDevice { + id: string; + name: string; + modelName?: string; +} diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 538f84f1f..b663b2d90 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -4,6 +4,7 @@ import { EmbeddedMpvSession, EmbeddedMpvSupport, } from './embedded-mpv-session.interface'; +import { DlnaRendererDevice } from './casting.interface'; import { EpgChannelMetadata } from './epg-channel-metadata.model'; import { EpgProgram } from './epg-program.model'; import { ExternalPlayerSession } from './external-player-session.interface'; @@ -171,11 +172,22 @@ export interface ElectronBridgeXtreamRequestPayload { suppressErrorLog?: boolean; } -export interface ElectronBridgeXtreamResponse { +export interface ElectronBridgeXtreamSuccessResponse { payload: unknown; action: string; } +export interface ElectronBridgeXtreamErrorResponse { + type: 'ERROR'; + message: string; + status: number; + name?: string; +} + +export type ElectronBridgeXtreamResponse = + | ElectronBridgeXtreamSuccessResponse + | ElectronBridgeXtreamErrorResponse; + export interface ElectronBridgeXtreamCancelResult extends ElectronBridgeResult { cancelled: number; } @@ -702,6 +714,11 @@ export interface ElectronBridgeApi { callback: (data: ElectronBridgePlayerError) => void ) => void; getLocalIpAddresses: () => Promise; + discoverDlnaRenderers?: () => Promise; + startDlnaPlayback?: ( + deviceId: string, + playback: ResolvedPortalPlayback + ) => Promise; onEpgProgress?: ( callback: (data: ElectronBridgeEpgProgress) => void ) => void; diff --git a/libs/shared/interfaces/src/lib/external-player-session.interface.ts b/libs/shared/interfaces/src/lib/external-player-session.interface.ts index 868c2bd7c..32ac118ee 100644 --- a/libs/shared/interfaces/src/lib/external-player-session.interface.ts +++ b/libs/shared/interfaces/src/lib/external-player-session.interface.ts @@ -16,6 +16,7 @@ export interface ExternalPlayerSession { title: string; thumbnail?: string | null; streamUrl: string; + requiresRequestHeaders?: boolean; contentInfo?: PlayerContentInfo; startedAt: string; updatedAt: string; diff --git a/libs/shared/interfaces/src/lib/portal-playback.interface.ts b/libs/shared/interfaces/src/lib/portal-playback.interface.ts index 1ab48fa88..ac2dda468 100644 --- a/libs/shared/interfaces/src/lib/portal-playback.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-playback.interface.ts @@ -18,4 +18,5 @@ export interface ResolvedPortalPlayback { userAgent?: string; referer?: string; origin?: string; + requiresRequestHeaders?: boolean; } diff --git a/libs/shared/interfaces/src/lib/portal-playback.utils.ts b/libs/shared/interfaces/src/lib/portal-playback.utils.ts new file mode 100644 index 000000000..029cd89ca --- /dev/null +++ b/libs/shared/interfaces/src/lib/portal-playback.utils.ts @@ -0,0 +1,11 @@ +import { ResolvedPortalPlayback } from './portal-playback.interface'; + +export function hasPlaybackHeaders(playback: ResolvedPortalPlayback): boolean { + return Boolean( + playback.requiresRequestHeaders || + playback.userAgent || + playback.referer || + playback.origin || + Object.keys(playback.headers ?? {}).length > 0 + ); +} diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html index 9998539e6..489cddaec 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html @@ -10,9 +10,7 @@ artworkInteractive() " [disabled]="!artworkInteractive()" - [attr.aria-label]=" - artworkInteractive() ? 'Open in playlist' : null - " + [attr.aria-label]="artworkInteractive() ? 'Open in playlist' : null" (click)="onArtworkClick()" > @if (showArtwork()) { @@ -32,7 +30,9 @@
{{ session().title }}
- {{ playerLabel() }} + {{ + playerLabel() + }}
+ + +
diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts index b82104e09..9bee6136f 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts @@ -56,6 +56,11 @@ describe('AudioPlayerComponent', () => { expect(loadSpy).toHaveBeenCalled(); expect(playSpy).toHaveBeenCalled(); expect(component.playState()).toBe('play'); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="cast-control-button"]' + ) + ).not.toBeNull(); }); it('clamps volume updates and persists them to localStorage', () => { @@ -72,6 +77,24 @@ describe('AudioPlayerComponent', () => { expect(localStorage.getItem('volume')).toBe('0'); }); + it('preserves protected playback metadata for the cast guard', () => { + createComponent(); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/radio.mp3', + title: 'Protected Radio', + headers: { Authorization: 'Bearer test' }, + }); + fixture.detectChanges(); + + expect(component.castPlayback()).toEqual( + expect.objectContaining({ + headers: { Authorization: 'Bearer test' }, + streamUrl: 'https://example.com/radio.mp3', + isLive: true, + }) + ); + }); + it('applies external volume input changes to the current audio element', () => { const audio = createComponent(); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index c6e350106..936a5ca54 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -21,146 +21,16 @@ import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; import { ChannelActions } from '@iptvnator/m3u-state'; +import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { CastControlComponent } from '../casting/cast-control.component'; @Component({ selector: 'app-audio-player', - template: ` -
- @if (displayIcon() && !logoError()) { -
- } -
- -
-
- @if (displayIcon() && !logoError()) { - - } @else { -
- radio -
- } -
- -

- {{ channelName() || 'Radio' }} -

- - @if (playState() === 'play') { - LIVE - } @else { - PAUSED - } - - -
- - - - - -
- -
- - - - -
-
- - -
- `, + templateUrl: './audio-player.component.html', styleUrls: ['./audio-player.component.scss'], imports: [ FormsModule, + CastControlComponent, MatButtonModule, MatIconModule, MatSliderModule, @@ -172,6 +42,7 @@ export class AudioPlayerComponent { readonly icon = input(''); readonly url = input.required(); readonly channelName = input(''); + readonly playback = input(null); readonly externalVolume = input(null, { alias: 'volume' }); readonly dispatchAdjacentChannelAction = input(true); readonly channelSwitchRequested = output<'next' | 'previous'>(); @@ -183,6 +54,16 @@ export class AudioPlayerComponent { readonly logoError = signal(false); readonly displayIcon = computed(() => this.icon() || null); + readonly castPlayback = computed(() => { + const playback = this.playback(); + return { + ...playback, + streamUrl: this.url(), + title: this.channelName() || playback?.title || 'Radio', + thumbnail: this.displayIcon() ?? playback?.thumbnail, + isLive: true, + }; + }); readonly volumeIcon = computed(() => { const v = this.volume(); if (v === 0 || this.isMuted()) return 'volume_off'; diff --git a/libs/ui/playback/src/lib/casting/cast-control-visibility.spec.ts b/libs/ui/playback/src/lib/casting/cast-control-visibility.spec.ts new file mode 100644 index 000000000..8ab1f34c5 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control-visibility.spec.ts @@ -0,0 +1,42 @@ +import { CastControlVisibility } from './cast-control-visibility'; + +describe('CastControlVisibility', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('keeps the overlay visible when focus moves within its container', () => { + jest.useFakeTimers(); + const visibility = new CastControlVisibility(); + const container = document.createElement('div'); + const child = document.createElement('button'); + container.appendChild(child); + + visibility.setInteractionActive(true); + visibility.handleFocusOut({ + currentTarget: container, + relatedTarget: child, + } as unknown as FocusEvent); + jest.advanceTimersByTime(3000); + + expect(visibility.visible()).toBe(true); + visibility.destroy(); + }); + + it('starts hiding when focus leaves the overlay container', () => { + jest.useFakeTimers(); + const visibility = new CastControlVisibility(); + const container = document.createElement('div'); + const outside = document.createElement('button'); + + visibility.setInteractionActive(true); + visibility.handleFocusOut({ + currentTarget: container, + relatedTarget: outside, + } as unknown as FocusEvent); + jest.advanceTimersByTime(3000); + + expect(visibility.visible()).toBe(false); + visibility.destroy(); + }); +}); diff --git a/libs/ui/playback/src/lib/casting/cast-control-visibility.ts b/libs/ui/playback/src/lib/casting/cast-control-visibility.ts new file mode 100644 index 000000000..e7d693514 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control-visibility.ts @@ -0,0 +1,77 @@ +import { signal } from '@angular/core'; + +const CAST_CONTROL_HIDE_DELAY_MS = 2500; + +export class CastControlVisibility { + readonly visible = signal(true); + + private hideTimer: ReturnType | null = null; + private interactionActive = false; + private menuOpen = false; + + showTemporarily(): void { + this.visible.set(true); + this.scheduleHide(); + } + + setInteractionActive(active: boolean): void { + this.interactionActive = active; + this.visible.set(true); + + if (active) { + this.clearHideTimer(); + return; + } + + this.scheduleHide(); + } + + handleFocusOut(event: FocusEvent): void { + const container = event.currentTarget as HTMLElement | null; + const nextTarget = event.relatedTarget; + + if (nextTarget instanceof Node && container?.contains(nextTarget)) { + return; + } + + this.setInteractionActive(false); + } + + setMenuOpen(open: boolean): void { + this.menuOpen = open; + this.visible.set(true); + + if (open) { + this.clearHideTimer(); + return; + } + + this.scheduleHide(); + } + + destroy(): void { + this.clearHideTimer(); + } + + private scheduleHide(): void { + this.clearHideTimer(); + + if (this.interactionActive || this.menuOpen) { + return; + } + + this.hideTimer = setTimeout(() => { + this.visible.set(false); + this.hideTimer = null; + }, CAST_CONTROL_HIDE_DELAY_MS); + } + + private clearHideTimer(): void { + if (this.hideTimer === null) { + return; + } + + clearTimeout(this.hideTimer); + this.hideTimer = null; + } +} diff --git a/libs/ui/playback/src/lib/casting/cast-control.component.html b/libs/ui/playback/src/lib/casting/cast-control.component.html new file mode 100644 index 000000000..3d6661795 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control.component.html @@ -0,0 +1,126 @@ + + + +
+ {{ 'CASTING.TITLE' | translate }} + {{ playback().title }} +
+ + + + + + @if (remotePlaybackAvailable()) { + + } + + @if (dlnaAvailable()) { +
+ {{ 'CASTING.DLNA_DEVICES' | translate }} + +
+ + @if (discovering()) { +
+ + {{ 'CASTING.SEARCHING' | translate }} +
+ } @else { + @for (device of dlnaDevices(); track device.id) { + + } @empty { +
+ {{ 'CASTING.NO_DLNA_DEVICES' | translate }} +
+ } + } + } + + @if (!directCastingAvailable()) { +
+ lock + {{ 'CASTING.DIRECT_URL_REQUIRED' | translate }} +
+ } + + @if (statusKey()) { +
+ error_outline + {{ statusKey() | translate }} +
+ } +
diff --git a/libs/ui/playback/src/lib/casting/cast-control.component.scss b/libs/ui/playback/src/lib/casting/cast-control.component.scss new file mode 100644 index 000000000..36035326d --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control.component.scss @@ -0,0 +1,75 @@ +:host { + position: absolute; + top: 12px; + right: 12px; + z-index: 7; + display: block; +} + +:host.cast-control--inline { + position: static; +} + +.cast-control__trigger { + color: #fff; + background: rgb(0 0 0 / 58%); + border: 1px solid rgb(255 255 255 / 18%); + backdrop-filter: blur(12px); +} + +:host.cast-control--connected .cast-control__trigger { + color: var(--app-selection-color, #7cb7ff); +} + +.cast-control__heading, +.cast-control__section, +.cast-control__state, +.cast-control__warning { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; +} + +.cast-control__heading { + min-width: 270px; + flex-direction: column; + align-items: flex-start; + border-bottom: 1px solid var(--mat-sys-outline-variant); +} + +.cast-control__heading span, +small { + display: block; + max-width: 250px; + overflow: hidden; + color: var(--mat-sys-on-surface-variant); + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cast-control__section { + justify-content: space-between; + color: var(--mat-sys-on-surface-variant); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.cast-control__state, +.cast-control__warning { + color: var(--mat-sys-on-surface-variant); + font-size: 0.8rem; +} + +.cast-control__warning { + border-top: 1px solid var(--mat-sys-outline-variant); +} + +.cast-control__warning mat-icon { + flex: 0 0 auto; + font-size: 18px; + width: 18px; + height: 18px; +} diff --git a/libs/ui/playback/src/lib/casting/cast-control.component.spec.ts b/libs/ui/playback/src/lib/casting/cast-control.component.spec.ts new file mode 100644 index 000000000..5b697b015 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control.component.spec.ts @@ -0,0 +1,104 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import { CastControlComponent } from './cast-control.component'; +import { CastService } from './cast.service'; + +describe('CastControlComponent', () => { + let fixture: ComponentFixture; + let castService: { + discoverDlnaDevices: jest.Mock; + openAirPlayPicker: jest.Mock; + openRemotePlaybackPicker: jest.Mock; + startDlnaPlayback: jest.Mock; + startGoogleCast: jest.Mock; + canUseGoogleCast: jest.Mock; + supportsAirPlay: jest.Mock; + supportsDlna: boolean; + supportsRemotePlayback: jest.Mock; + }; + + beforeEach(async () => { + castService = { + discoverDlnaDevices: jest.fn().mockResolvedValue([ + { + id: 'renderer-1', + name: 'Living Room TV', + modelName: 'Example TV', + }, + ]), + openAirPlayPicker: jest.fn(), + openRemotePlaybackPicker: jest.fn(), + startDlnaPlayback: jest.fn(), + startGoogleCast: jest.fn(), + canUseGoogleCast: jest.fn().mockReturnValue(true), + supportsAirPlay: jest.fn().mockReturnValue(true), + supportsDlna: true, + supportsRemotePlayback: jest.fn().mockReturnValue(true), + }; + + await TestBed.configureTestingModule({ + imports: [CastControlComponent, TranslateModule.forRoot()], + providers: [{ provide: CastService, useValue: castService }], + }).compileComponents(); + + fixture = TestBed.createComponent(CastControlComponent); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live/channel.m3u8', + title: 'Example Live', + }); + fixture.detectChanges(); + }); + + afterEach(() => fixture.destroy()); + + it('keeps the cast icon visible before devices are discovered', () => { + const button = fixture.debugElement.query( + By.css('[data-test-id="cast-control-button"]') + ); + + expect(button).not.toBeNull(); + expect(button.nativeElement.getAttribute('aria-label')).toContain( + 'CASTING.OPEN' + ); + }); + + it('uses the active media element for AirPlay and remote playback', async () => { + const media = document.createElement('video'); + + await fixture.componentInstance.openAirPlay(media); + await fixture.componentInstance.openRemotePlayback(media); + + expect(castService.openAirPlayPicker).toHaveBeenCalledWith(media); + expect(castService.openRemotePlaybackPicker).toHaveBeenCalledWith( + media + ); + }); + + it('reports menu visibility so the overlay does not hide while in use', async () => { + const menuStates: boolean[] = []; + fixture.componentInstance.menuOpenChange.subscribe((open) => + menuStates.push(open) + ); + + fixture.componentInstance.handleMenuOpened(); + fixture.componentInstance.handleMenuClosed(); + await Promise.resolve(); + + expect(menuStates).toEqual([true, false]); + }); + + it('discovers DLNA devices and starts direct playback on selection', async () => { + await fixture.componentInstance.prepareMenu(); + await fixture.componentInstance.startDlnaPlayback('renderer-1'); + + expect(castService.discoverDlnaDevices).toHaveBeenCalled(); + expect(castService.startDlnaPlayback).toHaveBeenCalledWith( + 'renderer-1', + expect.objectContaining({ + streamUrl: 'https://example.com/live/channel.m3u8', + title: 'Example Live', + }) + ); + }); +}); diff --git a/libs/ui/playback/src/lib/casting/cast-control.component.ts b/libs/ui/playback/src/lib/casting/cast-control.component.ts new file mode 100644 index 000000000..5c9befdb2 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control.component.ts @@ -0,0 +1,141 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + inject, + input, + output, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + DlnaRendererDevice, + hasPlaybackHeaders, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { findCastMediaElement, isDirectCastUrl } from './cast-media.utils'; +import { CastService } from './cast.service'; + +@Component({ + selector: 'app-cast-control', + templateUrl: './cast-control.component.html', + styleUrl: './cast-control.component.scss', + imports: [ + MatButtonModule, + MatIconModule, + MatMenuModule, + MatProgressSpinnerModule, + MatTooltipModule, + TranslatePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class.cast-control--connected]': 'connectedDeviceName()', + '[class.cast-control--inline]': "placement() === 'inline'", + }, +}) +export class CastControlComponent { + readonly playback = input.required(); + readonly placement = input<'overlay' | 'inline'>('overlay'); + readonly menuOpenChange = output(); + + readonly dlnaDevices = signal([]); + readonly discovering = signal(false); + readonly airPlayAvailable = signal(false); + readonly remotePlaybackAvailable = signal(false); + readonly connectedDeviceName = signal(''); + readonly statusKey = signal(''); + readonly directCastingAvailable = computed( + () => + isDirectCastUrl(this.playback().streamUrl) && + !hasPlaybackHeaders(this.playback()) + ); + readonly googleCastAvailable = computed(() => + this.castService.canUseGoogleCast(this.playback()) + ); + readonly dlnaAvailable = computed(() => this.castService.supportsDlna); + + private readonly elementRef = inject>(ElementRef); + private readonly castService = inject(CastService); + + async prepareMenu(): Promise { + const media = this.getMediaElement(); + this.airPlayAvailable.set(this.castService.supportsAirPlay(media)); + this.remotePlaybackAvailable.set( + this.castService.supportsRemotePlayback(media) + ); + if (this.dlnaAvailable()) { + await this.refreshDlnaDevices(); + } + } + + handleMenuOpened(): void { + this.menuOpenChange.emit(true); + void this.prepareMenu(); + } + + handleMenuClosed(): void { + this.menuOpenChange.emit(false); + } + + openAirPlay(media = this.getMediaElement()): void { + if (!media) { + return; + } + this.castService.openAirPlayPicker(media); + } + + async openRemotePlayback(media = this.getMediaElement()): Promise { + if (!media) { + return; + } + await this.runCastAction(() => + this.castService.openRemotePlaybackPicker(media) + ); + } + + async startGoogleCast(): Promise { + await this.runCastAction(() => + this.castService.startGoogleCast(this.playback()) + ); + } + + async refreshDlnaDevices(): Promise { + this.discovering.set(true); + this.statusKey.set(''); + try { + this.dlnaDevices.set(await this.castService.discoverDlnaDevices()); + } catch { + this.statusKey.set('CASTING.DISCOVERY_FAILED'); + } finally { + this.discovering.set(false); + } + } + + async startDlnaPlayback(deviceId: string): Promise { + const device = this.dlnaDevices().find(({ id }) => id === deviceId); + await this.runCastAction(async () => { + await this.castService.startDlnaPlayback(deviceId, this.playback()); + this.connectedDeviceName.set(device?.name ?? ''); + }); + } + + private async runCastAction(action: () => Promise): Promise { + this.statusKey.set(''); + try { + await action(); + } catch { + this.statusKey.set('CASTING.PLAYBACK_FAILED'); + } + } + + private getMediaElement(): HTMLMediaElement | null { + return findCastMediaElement(this.elementRef.nativeElement); + } +} diff --git a/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts b/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts new file mode 100644 index 000000000..5cdfa157f --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts @@ -0,0 +1,109 @@ +import { hasPlaybackHeaders } from '@iptvnator/shared/interfaces'; +import { + findCastMediaElement, + getCastMediaType, + getSafeCastThumbnailUrl, + isDirectCastUrl, +} from './cast-media.utils'; + +describe('cast media utilities', () => { + it.each([ + 'https://example.com/live/channel.m3u8', + 'http://192.168.1.20/media/movie.mp4', + ])('accepts receiver-fetchable HTTP media URLs', (url) => { + expect(isDirectCastUrl(url)).toBe(true); + }); + + it.each([ + 'blob:https://example.com/stream', + 'file:///tmp/movie.mp4', + 'data:video/mp4;base64,AAAA', + 'http://localhost:4200/live.m3u8', + 'http://dev.localhost:4200/live.m3u8', + 'http://0.0.0.0:8080/live.m3u8', + 'http://127.0.0.1:8080/live.m3u8', + 'http://[::1]:8080/live.m3u8', + 'not a URL', + ])('rejects URLs a remote receiver cannot fetch', (url) => { + expect(isDirectCastUrl(url)).toBe(false); + }); + + it('detects provider headers that remote receivers cannot inherit', () => { + expect( + hasPlaybackHeaders({ + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + headers: { Referer: 'https://provider.example' }, + }) + ).toBe(true); + expect( + hasPlaybackHeaders({ + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + requiresRequestHeaders: true, + }) + ).toBe(true); + expect( + hasPlaybackHeaders({ + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + }) + ).toBe(false); + }); + + it('only forwards same-origin receiver-fetchable artwork', () => { + expect( + getSafeCastThumbnailUrl({ + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + thumbnail: 'https://example.com/logo.png', + }) + ).toBe('https://example.com/logo.png'); + expect( + getSafeCastThumbnailUrl({ + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + thumbnail: 'http://127.0.0.1/admin.png', + }) + ).toBeUndefined(); + expect( + getSafeCastThumbnailUrl({ + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + thumbnail: 'https://cdn.example.net/logo.png', + }) + ).toBeUndefined(); + }); + + it.each([ + ['https://example.com/live.m3u8', 'application/x-mpegURL'], + ['https://example.com/channel.ts', 'video/mp2t'], + ['https://example.com/movie.mp4', 'video/mp4'], + ['https://example.com/radio.mp3', 'audio/mpeg'], + ['https://example.com/audio.aac', 'audio/aac'], + ['https://example.com/live/user/pass/42', 'video/mp2t'], + [ + 'https://example.com/play?extension=m3u8&token=signed', + 'application/x-mpegURL', + ], + ])('infers a receiver media type for %s', (url, mediaType) => { + expect(getCastMediaType(url)).toBe(mediaType); + }); + + it('finds media inside either video or radio player hosts', () => { + const videoHost = document.createElement('div'); + videoHost.setAttribute('data-cast-scope', ''); + const videoControl = document.createElement('app-cast-control'); + const video = document.createElement('video'); + videoHost.append(video, videoControl); + + const radioHost = document.createElement('div'); + radioHost.setAttribute('data-cast-scope', ''); + const radioControl = document.createElement('app-cast-control'); + const audio = document.createElement('audio'); + radioHost.append(radioControl, audio); + + expect(findCastMediaElement(videoControl)).toBe(video); + expect(findCastMediaElement(radioControl)).toBe(audio); + }); +}); diff --git a/libs/ui/playback/src/lib/casting/cast-media.utils.ts b/libs/ui/playback/src/lib/casting/cast-media.utils.ts new file mode 100644 index 000000000..d237699d1 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-media.utils.ts @@ -0,0 +1,102 @@ +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { getPlaybackMediaExtensionFromUrl } from '../playback-diagnostics/playback-media-source.util'; + +type RemotePlaybackMediaElement = HTMLMediaElement & { + remote?: { + prompt: () => Promise; + }; + webkitShowPlaybackTargetPicker?: () => void; +}; + +export function findCastMediaElement( + controlElement: Element +): RemotePlaybackMediaElement | null { + // Scoped via an explicit [data-cast-scope] attribute on the player host + // rather than CSS class names, so a layout/class refactor cannot silently + // break Cast media discovery. + const host = + controlElement.closest('[data-cast-scope]') ?? + controlElement.parentElement; + + return ( + host?.querySelector('video, audio') ?? null + ); +} + +export function isDirectCastUrl(streamUrl: string): boolean { + try { + const url = new URL(streamUrl); + const hostname = url.hostname.toLowerCase(); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + !url.username && + !url.password && + hostname !== 'localhost' && + !hostname.endsWith('.localhost') && + hostname !== '[::1]' && + hostname !== '0.0.0.0' && + !hostname.startsWith('127.') + ); + } catch { + return false; + } +} + +export function getSafeCastThumbnailUrl( + playback: ResolvedPortalPlayback +): string | undefined { + if (!playback.thumbnail || !isDirectCastUrl(playback.thumbnail)) { + return undefined; + } + + try { + const streamUrl = new URL(playback.streamUrl); + const thumbnailUrl = new URL(playback.thumbnail); + return streamUrl.origin === thumbnailUrl.origin + ? thumbnailUrl.toString() + : undefined; + } catch { + return undefined; + } +} + +export function getCastMediaType(streamUrl: string): string { + const extension = getPlaybackMediaExtensionFromUrl(streamUrl); + switch (extension) { + case 'm3u': + case 'm3u8': + return 'application/x-mpegURL'; + case 'ts': + return 'video/mp2t'; + case 'mp3': + return 'audio/mpeg'; + case 'aac': + return 'audio/aac'; + case 'm4a': + return 'audio/mp4'; + case 'webm': + return 'video/webm'; + case 'mkv': + return 'video/x-matroska'; + case 'mp4': + return 'video/mp4'; + default: + return extension ? 'video/mp4' : 'video/mp2t'; + } +} + +export function supportsAirPlayPicker(media: HTMLMediaElement | null): boolean { + return ( + typeof (media as RemotePlaybackMediaElement | null) + ?.webkitShowPlaybackTargetPicker === 'function' + ); +} + +export function supportsRemotePlaybackPicker( + media: HTMLMediaElement | null +): boolean { + return ( + typeof (media as RemotePlaybackMediaElement | null)?.remote?.prompt === + 'function' + ); +} diff --git a/libs/ui/playback/src/lib/casting/cast.service.spec.ts b/libs/ui/playback/src/lib/casting/cast.service.spec.ts new file mode 100644 index 000000000..515543aef --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast.service.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { CastService } from './cast.service'; +import type { GoogleCastWindow } from './google-cast.types'; + +describe('CastService', () => { + let service: CastService; + let castWindow: GoogleCastWindow; + + beforeEach(() => { + Object.defineProperty(globalThis, 'isSecureContext', { + configurable: true, + value: true, + }); + castWindow = window as GoogleCastWindow; + delete castWindow.cast; + delete castWindow.chrome; + delete castWindow.__onGCastApiAvailable; + + TestBed.configureTestingModule({ + providers: [ + CastService, + { + provide: RuntimeCapabilitiesService, + useValue: { + isPwa: true, + supportsDlnaCasting: false, + }, + }, + ], + }); + service = TestBed.inject(CastService); + }); + + afterEach(() => { + document.getElementById('iptvnator-google-cast-sdk')?.remove(); + delete castWindow.cast; + delete castWindow.chrome; + delete castWindow.__onGCastApiAvailable; + TestBed.resetTestingModule(); + }); + + it('restores the global callback and permits retry after SDK failure', async () => { + const previousCallback = jest.fn(); + castWindow.__onGCastApiAvailable = previousCallback; + const playback = { + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + isLive: true, + }; + + const firstAttempt = service.startGoogleCast(playback); + const firstScript = document.getElementById( + 'iptvnator-google-cast-sdk' + ); + expect(firstScript).not.toBeNull(); + + const firstRejection = expect(firstAttempt).rejects.toThrow( + 'Google Cast is not available.' + ); + castWindow.__onGCastApiAvailable?.(false); + await firstRejection; + + expect(previousCallback).toHaveBeenCalledWith(false); + expect(castWindow.__onGCastApiAvailable).toBe(previousCallback); + expect(firstScript?.isConnected).toBe(false); + + const secondAttempt = service.startGoogleCast(playback); + const secondScript = document.getElementById( + 'iptvnator-google-cast-sdk' + ); + expect(secondScript).not.toBeNull(); + expect(secondScript).not.toBe(firstScript); + + const secondRejection = expect(secondAttempt).rejects.toThrow( + 'Google Cast is not available.' + ); + castWindow.__onGCastApiAvailable?.(false); + await secondRejection; + }); +}); diff --git a/libs/ui/playback/src/lib/casting/cast.service.ts b/libs/ui/playback/src/lib/casting/cast.service.ts new file mode 100644 index 000000000..5fa798e6a --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast.service.ts @@ -0,0 +1,217 @@ +import { DOCUMENT } from '@angular/common'; +import { Injectable, inject } from '@angular/core'; +import { + DlnaRendererDevice, + hasPlaybackHeaders, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + getCastMediaType, + getSafeCastThumbnailUrl, + isDirectCastUrl, + supportsAirPlayPicker, + supportsRemotePlaybackPicker, +} from './cast-media.utils'; +import type { GoogleCastRuntime, GoogleCastWindow } from './google-cast.types'; + +const GOOGLE_CAST_SDK_URL = + 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'; +const GOOGLE_CAST_SCRIPT_ID = 'iptvnator-google-cast-sdk'; + +@Injectable({ providedIn: 'root' }) +export class CastService { + private readonly document = inject(DOCUMENT); + private readonly runtime = inject(RuntimeCapabilitiesService); + private googleCastRuntimePromise?: Promise; + + get supportsDlna(): boolean { + return this.runtime.supportsDlnaCasting; + } + + supportsAirPlay(media: HTMLMediaElement | null): boolean { + return supportsAirPlayPicker(media); + } + + supportsRemotePlayback(media: HTMLMediaElement | null): boolean { + return supportsRemotePlaybackPicker(media); + } + + canUseGoogleCast(playback: ResolvedPortalPlayback): boolean { + return ( + this.runtime.isPwa && + globalThis.isSecureContext && + isDirectCastUrl(playback.streamUrl) && + !hasPlaybackHeaders(playback) + ); + } + + openAirPlayPicker(media: HTMLMediaElement): void { + if (!supportsAirPlayPicker(media)) { + throw new Error('AirPlay is not available in this runtime.'); + } + + media.setAttribute('x-webkit-airplay', 'allow'); + ( + media as HTMLMediaElement & { + webkitShowPlaybackTargetPicker: () => void; + } + ).webkitShowPlaybackTargetPicker(); + } + + async openRemotePlaybackPicker(media: HTMLMediaElement): Promise { + const remote = ( + media as HTMLMediaElement & { + remote?: { prompt: () => Promise }; + } + ).remote; + if (!remote?.prompt) { + throw new Error('Remote Playback is not available.'); + } + + await remote.prompt(); + } + + async startGoogleCast(playback: ResolvedPortalPlayback): Promise { + if (!this.canUseGoogleCast(playback)) { + throw new Error( + 'Google Cast requires a secure PWA and a direct media URL without custom headers.' + ); + } + + const runtime = await this.loadGoogleCastRuntime(); + const context = runtime.cast.framework.CastContext.getInstance(); + context.setOptions({ + receiverApplicationId: + runtime.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, + autoJoinPolicy: runtime.cast.framework.AutoJoinPolicy.ORIGIN_SCOPED, + }); + await context.requestSession(); + + const session = context.getCurrentSession(); + if (!session) { + throw new Error('Google Cast session was not created.'); + } + + const mediaInfo = new runtime.chrome.cast.media.MediaInfo( + playback.streamUrl, + getCastMediaType(playback.streamUrl) + ); + mediaInfo.streamType = playback.isLive + ? runtime.chrome.cast.media.StreamType.LIVE + : runtime.chrome.cast.media.StreamType.BUFFERED; + const metadata = new runtime.chrome.cast.media.GenericMediaMetadata(); + metadata.title = playback.title; + const thumbnailUrl = getSafeCastThumbnailUrl(playback); + if (thumbnailUrl) { + metadata.images = [{ url: thumbnailUrl }]; + } + mediaInfo.metadata = metadata; + + await session.loadMedia( + new runtime.chrome.cast.media.LoadRequest(mediaInfo) + ); + } + + async discoverDlnaDevices(): Promise { + return (await window.electron?.discoverDlnaRenderers?.()) ?? []; + } + + async startDlnaPlayback( + deviceId: string, + playback: ResolvedPortalPlayback + ): Promise { + const result = await window.electron?.startDlnaPlayback?.( + deviceId, + playback + ); + if (!result?.success) { + throw new Error(result?.error ?? 'DLNA playback failed.'); + } + } + + private loadGoogleCastRuntime(): Promise { + if (this.googleCastRuntimePromise) { + return this.googleCastRuntimePromise; + } + + const runtimePromise = new Promise( + (resolve, reject) => { + const castWindow = window as GoogleCastWindow; + const existingRuntime = this.getGoogleCastRuntime(castWindow); + if (existingRuntime) { + resolve(existingRuntime); + return; + } + + const previousCallback = castWindow.__onGCastApiAvailable; + let script: HTMLScriptElement | null = null; + let settled = false; + let timeoutId = 0; + + const restoreCallback = () => { + if ( + castWindow.__onGCastApiAvailable === handleAvailability + ) { + castWindow.__onGCastApiAvailable = previousCallback; + } + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + window.clearTimeout(timeoutId); + restoreCallback(); + script?.remove(); + reject(error); + }; + const handleAvailability = (available: boolean) => { + previousCallback?.(available); + const runtime = this.getGoogleCastRuntime(castWindow); + if (!available || !runtime) { + fail(new Error('Google Cast is not available.')); + return; + } + if (settled) return; + settled = true; + window.clearTimeout(timeoutId); + restoreCallback(); + resolve(runtime); + }; + castWindow.__onGCastApiAvailable = handleAvailability; + timeoutId = window.setTimeout( + () => + fail( + new Error( + 'Google Cast SDK initialization timed out.' + ) + ), + 10_000 + ); + + if (!this.document.getElementById(GOOGLE_CAST_SCRIPT_ID)) { + script = this.document.createElement('script'); + script.id = GOOGLE_CAST_SCRIPT_ID; + script.src = GOOGLE_CAST_SDK_URL; + script.async = true; + script.onerror = () => + fail(new Error('Google Cast SDK could not be loaded.')); + this.document.head.appendChild(script); + } + } + ); + this.googleCastRuntimePromise = runtimePromise.catch((error) => { + this.googleCastRuntimePromise = undefined; + throw error; + }); + + return this.googleCastRuntimePromise; + } + + private getGoogleCastRuntime( + castWindow: GoogleCastWindow + ): GoogleCastRuntime | null { + return castWindow.cast?.framework && castWindow.chrome?.cast?.media + ? (castWindow as GoogleCastRuntime) + : null; + } +} diff --git a/libs/ui/playback/src/lib/casting/google-cast.types.ts b/libs/ui/playback/src/lib/casting/google-cast.types.ts new file mode 100644 index 000000000..23b3e77cc --- /dev/null +++ b/libs/ui/playback/src/lib/casting/google-cast.types.ts @@ -0,0 +1,53 @@ +export interface GoogleCastSession { + loadMedia(request: unknown): Promise; +} + +export interface GoogleCastContext { + getCurrentSession(): GoogleCastSession | null; + requestSession(): Promise; + setOptions(options: { + receiverApplicationId: string; + autoJoinPolicy: string; + }): void; +} + +export interface GoogleCastRuntime { + cast: { + framework: { + AutoJoinPolicy: { + ORIGIN_SCOPED: string; + }; + CastContext: { + getInstance(): GoogleCastContext; + }; + }; + }; + chrome: { + cast: { + media: { + DEFAULT_MEDIA_RECEIVER_APP_ID: string; + GenericMediaMetadata: new () => { + title?: string; + images?: Array<{ url: string }>; + }; + LoadRequest: new (mediaInfo: unknown) => unknown; + MediaInfo: new ( + contentId: string, + contentType: string + ) => { + metadata?: unknown; + streamType?: string; + }; + StreamType: { + BUFFERED: string; + LIVE: string; + }; + }; + }; + }; +} + +export type GoogleCastWindow = Window & + Partial & { + __onGCastApiAvailable?: (available: boolean) => void; + }; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html index ca9596274..2512dbff2 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html @@ -4,6 +4,7 @@ id="video-player" autoplay="true" controls="true" + controlsList="nofullscreen" > + + + + @if (visiblePlaybackDiagnostic(); as issue) {
(); } +@Component({ + selector: 'app-cast-control', + template: '', +}) +class StubCastControlComponent { + readonly playback = input.required(); + readonly placement = input<'overlay' | 'inline'>('overlay'); + readonly menuOpenChange = output(); +} + describe('WebPlayerViewComponent', () => { let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; let fixture: ComponentFixture; @@ -126,6 +136,7 @@ describe('WebPlayerViewComponent', () => { set: { imports: [ StubArtPlayerComponent, + StubCastControlComponent, StubEmbeddedMpvPlayerComponent, StubHtmlVideoPlayerComponent, StubVjsPlayerComponent, @@ -157,6 +168,172 @@ describe('WebPlayerViewComponent', () => { fixture.detectChanges(); expect(fixture.nativeElement.classList).toContain('web-player-view'); + expect( + fixture.debugElement.query( + By.css('[data-test-id="stub-cast-control"]') + ) + ).not.toBeNull(); + }); + + it('auto-hides the cast overlay with the player controls and restores it on activity', () => { + jest.useFakeTimers(); + + try { + component.castControlVisibility.showTemporarily(); + fixture.detectChanges(); + + const overlay = fixture.debugElement.query( + By.css('[data-test-id="cast-control-overlay"]') + ); + + expect(overlay.nativeElement.classList).toContain( + 'web-player-cast-control--visible' + ); + + jest.advanceTimersByTime(3000); + fixture.detectChanges(); + + expect(overlay.nativeElement.classList).not.toContain( + 'web-player-cast-control--visible' + ); + expect(overlay.nativeElement.getAttribute('aria-hidden')).toBe( + 'true' + ); + expect(overlay.nativeElement.hasAttribute('inert')).toBe(true); + + // Activity is tracked at the document level (capture phase) so a + // player that captures pointer events or owns the fullscreen + // element can't suppress the re-show. jsdom reports a 0x0 host + // rect, so a pointer at (0,0) counts as within the player surface. + document.dispatchEvent( + new MouseEvent('pointermove', { clientX: 0, clientY: 0 }) + ); + fixture.detectChanges(); + + expect(overlay.nativeElement.classList).toContain( + 'web-player-cast-control--visible' + ); + expect( + overlay.nativeElement.getAttribute('aria-hidden') + ).toBeNull(); + expect(overlay.nativeElement.hasAttribute('inert')).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps the cast overlay visible while the device menu is open', () => { + jest.useFakeTimers(); + + try { + component.castControlVisibility.showTemporarily(); + fixture.detectChanges(); + + const overlay = fixture.debugElement.query( + By.css('[data-test-id="cast-control-overlay"]') + ); + const castControl = fixture.debugElement.query( + By.directive(StubCastControlComponent) + ).componentInstance as StubCastControlComponent; + + castControl.menuOpenChange.emit(true); + jest.advanceTimersByTime(3000); + fixture.detectChanges(); + + expect(overlay.nativeElement.classList).toContain( + 'web-player-cast-control--visible' + ); + + castControl.menuOpenChange.emit(false); + jest.advanceTimersByTime(3000); + fixture.detectChanges(); + + expect(overlay.nativeElement.classList).not.toContain( + 'web-player-cast-control--visible' + ); + } finally { + jest.useRealTimers(); + } + }); + + it('routes fullscreen through the player container and reflects fullscreen state', () => { + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + const requestFullscreen = jest.fn().mockResolvedValue(undefined); + const exitFullscreen = jest.fn().mockResolvedValue(undefined); + host.requestFullscreen = requestFullscreen; + + const originalFsDescriptor = Object.getOwnPropertyDescriptor( + document, + 'fullscreenElement' + ); + const originalExit = ( + document as unknown as { exitFullscreen?: () => Promise } + ).exitFullscreen; + let fullscreenElement: Element | null = null; + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }); + ( + document as unknown as { exitFullscreen: () => Promise } + ).exitFullscreen = exitFullscreen; + + try { + const button = fixture.debugElement.query( + By.css('[data-test-id="player-fullscreen-toggle"]') + ); + expect(button).not.toBeNull(); + expect(button.nativeElement.textContent).not.toContain( + 'fullscreen_exit' + ); + + // Entering: fullscreen is requested on the container host, never on + // an inner player element, so the cast overlay shares the context. + button.nativeElement.click(); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + expect(exitFullscreen).not.toHaveBeenCalled(); + + // The browser reports the host as the fullscreen element. + fullscreenElement = host; + document.dispatchEvent(new Event('fullscreenchange')); + fixture.detectChanges(); + expect(component.isFullscreen()).toBe(true); + expect(button.nativeElement.textContent).toContain( + 'fullscreen_exit' + ); + + // Toggling again while fullscreen exits via the document API. + component.toggleFullscreen(); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + + fullscreenElement = null; + document.dispatchEvent(new Event('fullscreenchange')); + fixture.detectChanges(); + expect(component.isFullscreen()).toBe(false); + } finally { + if (originalFsDescriptor) { + Object.defineProperty( + document, + 'fullscreenElement', + originalFsDescriptor + ); + } else { + delete ( + document as unknown as { fullscreenElement?: unknown } + ).fullscreenElement; + } + if (originalExit) { + ( + document as unknown as { exitFullscreen?: unknown } + ).exitFullscreen = originalExit; + } else { + delete ( + document as unknown as { exitFullscreen?: unknown } + ).exitFullscreen; + } + } }); it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 2b9d7c5f4..030cdaa21 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -1,5 +1,9 @@ import { Component, + ElementRef, + NgZone, + OnDestroy, + OnInit, Signal, ViewEncapsulation, computed, @@ -27,6 +31,8 @@ import { import type { ExternalPlayerName } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { ArtPlayerComponent } from '../art-player/art-player.component'; +import { CastControlComponent } from '../casting/cast-control.component'; +import { CastControlVisibility } from '../casting/cast-control-visibility'; import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; import { @@ -50,9 +56,11 @@ type PlaybackDiagnosticDetail = { styleUrls: ['./web-player-view.component.scss'], host: { class: 'web-player-view', + 'data-cast-scope': '', }, imports: [ ArtPlayerComponent, + CastControlComponent, ClipboardModule, EmbeddedMpvPlayerComponent, HtmlVideoPlayerComponent, @@ -64,9 +72,12 @@ type PlaybackDiagnosticDetail = { ], encapsulation: ViewEncapsulation.None, }) -export class WebPlayerViewComponent { +export class WebPlayerViewComponent implements OnInit, OnDestroy { storage = inject(StorageMap); private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly hostRef = inject>(ElementRef); + private readonly zone = inject(NgZone); + private castActivityThrottled = false; streamUrl = input.required(); title = input(''); @@ -94,9 +105,12 @@ export class WebPlayerViewComponent { isLive: boolean; reloadToken: number; sources: { src: string; type: string }[]; + controlBar: { fullscreenToggle: boolean }; }; readonly reloadToken = signal(0); + readonly isFullscreen = signal(false); readonly playbackDiagnostic = signal(null); + readonly castControlVisibility = new CastControlVisibility(); readonly visiblePlaybackDiagnostic = computed(() => this.selectedPlayer() === VideoPlayer.EmbeddedMpv ? null @@ -148,6 +162,109 @@ export class WebPlayerViewComponent { this.isLivePlayback(playback) ); }); + + this.castControlVisibility.showTemporarily(); + } + + ngOnInit(): void { + // Drive cast-control visibility from document-level activity instead of + // host pointer events. A player can capture pointer events (ArtPlayer) + // or own the fullscreen element (Video.js), in which case host-level + // listeners never fire and the control hides for good. Listeners are + // attached in the capture phase (so a player cannot swallow them) and + // outside Angular (so idle movement does not trigger change detection); + // only the gated re-show re-enters the zone. + this.zone.runOutsideAngular(() => { + document.addEventListener( + 'pointermove', + this.handlePlayerActivity, + true + ); + document.addEventListener( + 'keydown', + this.handlePlayerActivity, + true + ); + document.addEventListener( + 'fullscreenchange', + this.handleFullscreenChange + ); + }); + } + + ngOnDestroy(): void { + document.removeEventListener( + 'pointermove', + this.handlePlayerActivity, + true + ); + document.removeEventListener('keydown', this.handlePlayerActivity, true); + document.removeEventListener( + 'fullscreenchange', + this.handleFullscreenChange + ); + this.castControlVisibility.destroy(); + } + + private readonly handlePlayerActivity = (event: Event): void => { + if (this.castActivityThrottled || !this.isWithinPlayerSurface(event)) { + return; + } + this.castActivityThrottled = true; + requestAnimationFrame(() => { + this.castActivityThrottled = false; + }); + this.zone.run(() => this.castControlVisibility.showTemporarily()); + }; + + private readonly handleFullscreenChange = (): void => { + this.zone.run(() => { + this.isFullscreen.set( + document.fullscreenElement === this.hostRef.nativeElement + ); + this.castControlVisibility.showTemporarily(); + }); + }; + + /** + * Toggles fullscreen on the player container (`.web-player-view`) rather than + * on an individual player element. The container holds both the active player + * and the cast-control overlay, so casting stays reachable in fullscreen for + * every player (Video.js, HTML5, ArtPlayer). Each player's own fullscreen + * control is disabled so this is the single fullscreen entry point. + */ + toggleFullscreen(): void { + if (document.fullscreenElement) { + void document.exitFullscreen().catch(() => undefined); + return; + } + void this.hostRef.nativeElement + .requestFullscreen() + .catch(() => undefined); + } + + private isWithinPlayerSurface(event: Event): boolean { + const host = this.hostRef.nativeElement; + const fullscreenElement = document.fullscreenElement; + if ( + fullscreenElement && + (fullscreenElement === host || + host.contains(fullscreenElement) || + fullscreenElement.contains(host)) + ) { + // In fullscreen the player owns the screen — any activity counts. + return true; + } + if (event instanceof MouseEvent) { + const rect = host.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); + } + return false; } setVjsOptions(streamUrl: string, isLive = true) { @@ -163,6 +280,10 @@ export class WebPlayerViewComponent { isLive, reloadToken: untracked(() => this.reloadToken()), sources: [{ src: streamUrl, type: mimeType }], + // Native Video.js fullscreen toggles only the