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/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/services/dlna-protocol.ts b/apps/electron-backend/src/app/services/dlna-protocol.ts new file mode 100644 index 000000000..3333ef4c5 --- /dev/null +++ b/apps/electron-backend/src/app/services/dlna-protocol.ts @@ -0,0 +1,212 @@ +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')) + ); + } + ); + enforceAbsoluteRequestTimeout(req); + 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/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index a635153a3..e18e78b24 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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index a617c041e..44b3fbe0f 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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7c39d2209..d1663ef35 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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f1a83f3f8..61df0d6cd 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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 775bf208d..57b4651cf 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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 8fadf88f0..08fbdd722 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -410,6 +410,22 @@ "UNMUTE": "Unmute", "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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a36753a51..ed197b59c 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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ab0150f91..f67617cfb 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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0099b270f..643be765d 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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e7b5bfcbb..d2664a8a2 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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f00557a87..22cdcc4c0 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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 62f211028..1498f51ef 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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e2c93f1a4..889117b98 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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e49fbfc48..e3cb8bd26 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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1d6495e75..ce2363777 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": "Контейнер потока, вероятно, не поддерживается встроенным браузерным плеером", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 126c30ca6..ccfe81f6e 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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 49c2e475d..48e1b5030 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", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4bfcbde42..b232df005 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", diff --git a/apps/web/src/index.html b/apps/web/src/index.html index c8e2b90e2..29d2f4394 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -8,7 +8,7 @@ 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/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/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..d209d132c 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'; @@ -702,6 +703,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..27c9c49c8 --- /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.className = 'web-player-view'; + const videoControl = document.createElement('app-cast-control'); + const video = document.createElement('video'); + videoHost.append(video, videoControl); + + const radioHost = document.createElement('div'); + radioHost.className = 'radio-hero'; + 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..7f71db11e --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-media.utils.ts @@ -0,0 +1,99 @@ +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 { + const host = + controlElement.closest('.web-player-view, .radio-hero') ?? + 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/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index c0d97fd3a..0b6958d9e 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -76,6 +76,24 @@ } } +
+ +
+ @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,86 @@ 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); + + fixture.nativeElement.dispatchEvent(new Event('pointermove')); + 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('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..22d46bf30 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,6 @@ import { Component, + OnDestroy, Signal, ViewEncapsulation, computed, @@ -27,6 +28,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 +53,13 @@ type PlaybackDiagnosticDetail = { styleUrls: ['./web-player-view.component.scss'], host: { class: 'web-player-view', + '(pointerenter)': 'castControlVisibility.showTemporarily()', + '(pointermove)': 'castControlVisibility.showTemporarily()', + '(keydown)': 'castControlVisibility.showTemporarily()', }, imports: [ ArtPlayerComponent, + CastControlComponent, ClipboardModule, EmbeddedMpvPlayerComponent, HtmlVideoPlayerComponent, @@ -64,7 +71,7 @@ type PlaybackDiagnosticDetail = { ], encapsulation: ViewEncapsulation.None, }) -export class WebPlayerViewComponent { +export class WebPlayerViewComponent implements OnDestroy { storage = inject(StorageMap); private readonly runtime = inject(RuntimeCapabilitiesService); @@ -97,6 +104,7 @@ export class WebPlayerViewComponent { }; readonly reloadToken = signal(0); readonly playbackDiagnostic = signal(null); + readonly castControlVisibility = new CastControlVisibility(); readonly visiblePlaybackDiagnostic = computed(() => this.selectedPlayer() === VideoPlayer.EmbeddedMpv ? null @@ -148,6 +156,12 @@ export class WebPlayerViewComponent { this.isLivePlayback(playback) ); }); + + this.castControlVisibility.showTemporarily(); + } + + ngOnDestroy(): void { + this.castControlVisibility.destroy(); } setVjsOptions(streamUrl: string, isLive = true) { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 17745132e..dae592f05 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -77,7 +77,13 @@ (artworkClicked)=" facade.openActiveExternalSessionTarget() " - /> + > + + } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts index 8e25ea08f..bd1766660 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts @@ -1,10 +1,4 @@ -import { - Component, - Directive, - input, - output, - signal, -} from '@angular/core'; +import { Component, Directive, input, output, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { RouterOutlet, provideRouter } from '@angular/router'; import { By } from '@angular/platform-browser'; @@ -88,12 +82,23 @@ class MockWorkspaceShellContextSidebarComponent { @Component({ selector: 'app-external-playback-dock', - template: '', + template: '', standalone: true, }) class MockExternalPlaybackDockComponent { readonly session = input(null); readonly closeClicked = output(); + readonly artworkClicked = output(); +} + +@Component({ + selector: 'app-cast-control', + template: '', + standalone: true, +}) +class MockCastControlComponent { + readonly playback = input(null); + readonly placement = input<'overlay' | 'inline'>('overlay'); } @Component({ @@ -210,6 +215,7 @@ describe('WorkspaceShellComponent', () => { set: { imports: [ RouterOutlet, + MockCastControlComponent, MockExternalPlaybackDockComponent, MockPlaylistDropOverlayComponent, MockPlaylistDropZoneDirective, @@ -250,6 +256,9 @@ describe('WorkspaceShellComponent', () => { expect( fixture.nativeElement.querySelector('app-external-playback-dock') ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-cast-control') + ).not.toBeNull(); }); it('renders the xtream import overlay child only when the facade flag is true', async () => { @@ -263,6 +272,7 @@ describe('WorkspaceShellComponent', () => { set: { imports: [ RouterOutlet, + MockCastControlComponent, MockExternalPlaybackDockComponent, MockPlaylistDropOverlayComponent, MockPlaylistDropZoneDirective, @@ -315,6 +325,7 @@ describe('WorkspaceShellComponent', () => { set: { imports: [ RouterOutlet, + MockCastControlComponent, MockExternalPlaybackDockComponent, MockPlaylistDropOverlayComponent, MockPlaylistDropZoneDirective, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts index 07abefca7..662b6ff89 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts @@ -1,6 +1,11 @@ import { Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { + ExternalPlayerSession, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; import { ExternalPlaybackDockComponent } from '@iptvnator/ui/components'; +import { CastControlComponent } from '@iptvnator/ui/playback'; import { PlaylistDropOverlayComponent, PlaylistDropZoneDirective, @@ -21,6 +26,7 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu @Component({ selector: 'app-workspace-shell', imports: [ + CastControlComponent, ExternalPlaybackDockComponent, PlaylistDropOverlayComponent, PlaylistDropZoneDirective, @@ -46,4 +52,15 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu export class WorkspaceShellComponent { readonly facade = inject(WorkspaceShellFacade); readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService); + + toCastPlayback(session: ExternalPlayerSession): ResolvedPortalPlayback { + return { + streamUrl: session.streamUrl, + title: session.title, + thumbnail: session.thumbnail, + isLive: !session.contentInfo, + requiresRequestHeaders: session.requiresRequestHeaders, + contentInfo: session.contentInfo, + }; + } } diff --git a/tools/electron/serve-electron-dev.mjs b/tools/electron/serve-electron-dev.mjs new file mode 100644 index 000000000..4fd326d83 --- /dev/null +++ b/tools/electron/serve-electron-dev.mjs @@ -0,0 +1,39 @@ +import { spawn } from 'node:child_process'; +import { + assertPortAvailable, + coordinateChildProcesses, + waitForIptvnatorWebServer, +} from './serve-electron-dev.runtime.mjs'; + +const WEB_URL = new URL('http://localhost:4200/'); +const packageManagerCli = process.env.npm_execpath; +if (!packageManagerCli) { + throw new Error('Unable to locate the workspace package manager CLI.'); +} + +await assertPortAvailable(Number(WEB_URL.port)); + +const childOptions = { + stdio: 'inherit', + windowsHide: true, +}; +const webChild = spawn( + process.execPath, + [packageManagerCli, 'nx', 'serve', 'web', '--no-tui'], + childOptions +); + +try { + await waitForIptvnatorWebServer(WEB_URL, webChild); +} catch (error) { + webChild.kill('SIGTERM'); + throw error; +} + +const electronChild = spawn( + process.execPath, + [packageManagerCli, 'nx', 'run', 'electron-backend:serve-electron'], + childOptions +); + +coordinateChildProcesses(webChild, electronChild); diff --git a/tools/electron/serve-electron-dev.runtime.mjs b/tools/electron/serve-electron-dev.runtime.mjs new file mode 100644 index 000000000..8b0d19eea --- /dev/null +++ b/tools/electron/serve-electron-dev.runtime.mjs @@ -0,0 +1,216 @@ +import { request } from 'node:http'; +import { createServer } from 'node:net'; + +const STARTUP_TIMEOUT_MS = 120_000; +const POLL_INTERVAL_MS = 500; +const MAX_RESPONSE_BYTES = 128 * 1024; +const SIGNAL_EXIT_CODES = { + SIGINT: 130, + SIGTERM: 143, +}; + +export async function assertPortAvailable(port, probe = listenOnce) { + const results = await Promise.all( + ['127.0.0.1', '::1'].map(async (host) => { + try { + await probe(host, port); + return true; + } catch (error) { + if ( + error.code === 'EADDRNOTAVAIL' || + error.code === 'EAFNOSUPPORT' + ) { + return false; + } + throw error; + } + }) + ); + if (!results.some(Boolean)) { + throw new Error('No localhost network family is available.'); + } +} + +export function isIptvnatorWebResponse(statusCode, contentType, body) { + return ( + statusCode >= 200 && + statusCode < 300 && + contentType.toLowerCase().includes('text/html') && + body.includes('IPTVnator') && + body.includes(' { + rejectForExit = (code, signal) => { + reject( + new Error( + `Web development server exited before readiness (${formatExit( + code, + signal + )}).` + ) + ); + }; + rejectForError = (error) => reject(error); + webChild.once('exit', rejectForExit); + webChild.once('error', rejectForError); + }); + + try { + await Promise.race([ + pollUntilReady( + url, + deadline, + timeoutMs, + pollIntervalMs, + requestPage + ), + failedChild, + ]); + } finally { + webChild.off('exit', rejectForExit); + webChild.off('error', rejectForError); + } +} + +export function coordinateChildProcesses( + webChild, + electronChild, + processRef = process +) { + let stopping = false; + const signalHandlers = new Map(); + + const stop = (exitCode, signal = 'SIGTERM') => { + if (stopping) return; + stopping = true; + for (const [name, handler] of signalHandlers) { + processRef.off(name, handler); + } + terminate(webChild, signal); + terminate(electronChild, signal); + processRef.exitCode = exitCode; + }; + + for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { + const handler = () => stop(SIGNAL_EXIT_CODES[signal], signal); + signalHandlers.set(signal, handler); + processRef.on(signal, handler); + } + + webChild.once('error', () => stop(1)); + electronChild.once('error', () => stop(1)); + webChild.once('exit', (code, signal) => + stop(toExitCode(code, signal), signal ?? 'SIGTERM') + ); + electronChild.once('exit', (code, signal) => + stop(toExitCode(code, signal), signal ?? 'SIGTERM') + ); + + return { stop }; +} + +async function pollUntilReady( + url, + deadline, + timeoutMs, + pollIntervalMs, + requestPage +) { + while (Date.now() < deadline) { + try { + const response = await requestPage(url); + if ( + isIptvnatorWebResponse( + response.statusCode, + response.contentType, + response.body + ) + ) { + return; + } + } catch { + // The owned development server is still starting. + } + await delay(pollIntervalMs); + } + throw new Error( + `IPTVnator web development server did not become ready within ${timeoutMs} ms.` + ); +} + +function requestWebPage(url) { + return new Promise((resolve, reject) => { + const req = request(url, { method: 'GET' }, (response) => { + const chunks = []; + let size = 0; + response.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_RESPONSE_BYTES) { + req.destroy( + new Error('Web readiness response exceeded size limit.') + ); + return; + } + chunks.push(chunk); + }); + response.on('end', () => { + resolve({ + statusCode: response.statusCode ?? 0, + contentType: String(response.headers['content-type'] ?? ''), + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + }); + req.setTimeout(2_000, () => { + req.destroy(new Error('Web readiness request timed out.')); + }); + req.on('error', reject); + req.end(); + }); +} + +function listenOnce(host, port) { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once('error', (error) => { + const wrapped = new Error( + `Cannot start IPTVnator: ${host}:${port} is unavailable (${error.code}).` + ); + wrapped.code = error.code; + reject(wrapped); + }); + server.listen({ host, port, exclusive: true }, () => { + server.close(resolve); + }); + }); +} + +function terminate(child, signal) { + if (child.exitCode === null && child.signalCode === null) { + child.kill(signal); + } +} + +function toExitCode(code, signal) { + if (typeof code === 'number') return code; + return SIGNAL_EXIT_CODES[signal] ?? 1; +} + +function formatExit(code, signal) { + return signal ? `signal ${signal}` : `code ${code ?? 1}`; +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/tools/electron/serve-electron-dev.test.mjs b/tools/electron/serve-electron-dev.test.mjs new file mode 100644 index 000000000..584f0c318 --- /dev/null +++ b/tools/electron/serve-electron-dev.test.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { createServer } from 'node:net'; +import test from 'node:test'; +import { + assertPortAvailable, + coordinateChildProcesses, + isIptvnatorWebResponse, + waitForIptvnatorWebServer, +} from './serve-electron-dev.runtime.mjs'; + +test('accepts only a successful IPTVnator HTML response', () => { + const body = 'IPTVnator'; + assert.equal(isIptvnatorWebResponse(200, 'text/html', body), true); + assert.equal(isIptvnatorWebResponse(503, 'text/html', body), false); + assert.equal(isIptvnatorWebResponse(200, 'application/json', body), false); + assert.equal( + isIptvnatorWebResponse( + 200, + 'text/html', + 'Other app' + ), + false + ); +}); + +test('rejects a port already owned by another process', async (context) => { + const server = createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + context.after(() => server.close()); + const address = server.address(); + + await assert.rejects( + assertPortAvailable(address.port), + /127\.0\.0\.1:.+ is unavailable \(EADDRINUSE\)/ + ); +}); + +test('allows IPv4 startup when IPv6 loopback is unavailable', async () => { + const probedHosts = []; + await assert.doesNotReject( + assertPortAvailable(4200, async (host) => { + probedHosts.push(host); + if (host === '::1') { + const error = new Error('IPv6 loopback is unavailable.'); + error.code = 'EADDRNOTAVAIL'; + throw error; + } + }) + ); + assert.deepEqual(probedHosts, ['127.0.0.1', '::1']); +}); + +test('rejects when the owned web process exits before readiness', async () => { + const webChild = new EventEmitter(); + const readiness = waitForIptvnatorWebServer( + new URL('http://localhost:4200/'), + webChild, + { + timeoutMs: 1_000, + pollIntervalMs: 1, + requestPage: () => new Promise(() => undefined), + } + ); + webChild.emit('exit', 1, null); + await assert.rejects(readiness, /exited before readiness \(code 1\)/); +}); + +test('waits past non-success responses until IPTVnator is ready', async () => { + const webChild = new EventEmitter(); + const body = 'IPTVnator'; + const responses = [ + { statusCode: 503, contentType: 'text/html', body }, + { statusCode: 200, contentType: 'text/html', body }, + ]; + + await waitForIptvnatorWebServer( + new URL('http://localhost:4200/'), + webChild, + { + timeoutMs: 1_000, + pollIntervalMs: 1, + requestPage: async () => responses.shift(), + } + ); + assert.equal(responses.length, 0); +}); + +test('terminates both children once on SIGINT without re-signalling', () => { + const processRef = new EventEmitter(); + processRef.exitCode = undefined; + const webChild = createChild(); + const electronChild = createChild(); + + coordinateChildProcesses(webChild, electronChild, processRef); + processRef.emit('SIGINT'); + electronChild.emit('exit', null, 'SIGINT'); + + assert.deepEqual(webChild.killedWith, ['SIGINT']); + assert.deepEqual(electronChild.killedWith, ['SIGINT']); + assert.equal(processRef.exitCode, 130); + assert.equal(processRef.listenerCount('SIGINT'), 0); + assert.equal(processRef.listenerCount('SIGTERM'), 0); +}); + +test('stops the sibling when either child exits', () => { + const processRef = new EventEmitter(); + processRef.exitCode = undefined; + const webChild = createChild(); + const electronChild = createChild(); + + coordinateChildProcesses(webChild, electronChild, processRef); + electronChild.exitCode = 2; + electronChild.emit('exit', 2, null); + + assert.deepEqual(webChild.killedWith, ['SIGTERM']); + assert.deepEqual(electronChild.killedWith, []); + assert.equal(processRef.exitCode, 2); +}); + +function createChild() { + const child = new EventEmitter(); + child.exitCode = null; + child.signalCode = null; + child.killedWith = []; + child.kill = (signal) => { + child.killedWith.push(signal); + return true; + }; + return child; +}