From bc9d53529928f720c6ddeba2d837eb4924ecf40f Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:44:04 +0200 Subject: [PATCH 01/19] test(playback): define casting contracts --- .../services/dlna-renderer.service.spec.ts | 98 +++++++++++++++++++ .../casting/cast-control.component.spec.ts | 85 ++++++++++++++++ .../src/lib/casting/cast-media.utils.spec.ts | 67 +++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts create mode 100644 libs/ui/playback/src/lib/casting/cast-control.component.spec.ts create mode 100644 libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts 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..52f67a9b1 --- /dev/null +++ b/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts @@ -0,0 +1,98 @@ +import { + buildSsdpSearchRequest, + buildUpnpActionBody, + isTrustedSsdpLocation, + parseRendererDescription, + parseSsdpResponse, +} from './dlna-renderer.service'; + +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); + }); + + 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(''); + }); +}); 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..858a34be8 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control.component.spec.ts @@ -0,0 +1,85 @@ +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; + supportsAirPlay: jest.Mock; + 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(), + supportsAirPlay: jest.fn().mockReturnValue(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('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-media.utils.spec.ts b/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts new file mode 100644 index 000000000..f755216da --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts @@ -0,0 +1,67 @@ +import { + findCastMediaElement, + getCastMediaType, + hasPlaybackHeaders, + 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', + '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', + }) + ).toBe(false); + }); + + 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'], + ])('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); + }); +}); From 17c6e80d2781f1d16ac3cd2e93936c04c21ef51c Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:31:41 +0200 Subject: [PATCH 02/19] feat(playback): add secure remote casting controls --- README.md | 2 + .../src/app/api/main.preload.ts | 5 + .../src/app/events/casting.events.ts | 18 + .../external-player-playback-request.ts | 26 ++ .../external-player-session-registry.spec.ts | 35 ++ .../external-player-session-registry.ts | 6 +- .../app/events/mpv-session.service.spec.ts | 20 +- .../src/app/events/mpv-session.service.ts | 58 ++-- .../src/app/events/vlc-session.service.ts | 30 +- .../src/app/services/dlna-protocol.ts | 311 ++++++++++++++++++ .../services/dlna-renderer.service.spec.ts | 57 +++- .../src/app/services/dlna-renderer.service.ts | 223 +++++++++++++ apps/electron-backend/src/main.ts | 2 + apps/web/src/assets/i18n/ar.json | 16 + apps/web/src/assets/i18n/ary.json | 16 + apps/web/src/assets/i18n/by.json | 16 + apps/web/src/assets/i18n/de.json | 16 + apps/web/src/assets/i18n/el.json | 16 + apps/web/src/assets/i18n/en.json | 16 + apps/web/src/assets/i18n/es.json | 16 + apps/web/src/assets/i18n/fr.json | 16 + apps/web/src/assets/i18n/it.json | 16 + apps/web/src/assets/i18n/ja.json | 16 + apps/web/src/assets/i18n/ko.json | 16 + apps/web/src/assets/i18n/nl.json | 16 + apps/web/src/assets/i18n/pl.json | 16 + apps/web/src/assets/i18n/pt.json | 16 + apps/web/src/assets/i18n/ru.json | 16 + apps/web/src/assets/i18n/tr.json | 16 + apps/web/src/assets/i18n/zh.json | 16 + apps/web/src/assets/i18n/zhtw.json | 16 + apps/web/src/index.html | 2 +- docs/architecture/electron-security.md | 11 +- docs/architecture/remote-playback.md | 110 +++++++ .../video-player/video-player.component.html | 1 + .../unified-live-tab.component.html | 1 + .../unified-live-tab.component.spec.ts | 16 +- .../stalker-live-stream-layout.component.html | 1 + ...alker-live-stream-layout.component.spec.ts | 10 +- .../lib/runtime-capabilities.service.spec.ts | 4 + .../src/lib/runtime-capabilities.service.ts | 7 + libs/shared/interfaces/src/index.ts | 1 + .../interfaces/src/lib/casting.interface.ts | 5 + .../src/lib/electron-api.interface.ts | 6 + .../lib/external-player-session.interface.ts | 1 + .../src/lib/portal-playback.interface.ts | 1 + .../external-playback-dock.component.html | 9 +- libs/ui/playback/src/index.ts | 3 + .../art-player/art-player.component.spec.ts | 33 +- .../lib/art-player/art-player.component.ts | 2 +- .../audio-player/audio-player.component.html | 120 +++++++ .../audio-player.component.spec.ts | 23 ++ .../audio-player/audio-player.component.ts | 149 +-------- .../lib/casting/cast-control.component.html | 125 +++++++ .../lib/casting/cast-control.component.scss | 75 +++++ .../casting/cast-control.component.spec.ts | 6 + .../src/lib/casting/cast-control.component.ts | 133 ++++++++ .../src/lib/casting/cast-media.utils.spec.ts | 15 + .../src/lib/casting/cast-media.utils.ts | 89 +++++ .../src/lib/casting/cast.service.spec.ts | 81 +++++ .../playback/src/lib/casting/cast.service.ts | 215 ++++++++++++ .../src/lib/casting/google-cast.types.ts | 53 +++ .../web-player-view.component.html | 2 + .../web-player-view.component.spec.ts | 19 +- .../web-player-view.component.ts | 2 + .../workspace-shell.component.html | 8 +- .../workspace-shell.component.spec.ts | 27 +- .../workspace-shell.component.ts | 17 + 68 files changed, 2246 insertions(+), 218 deletions(-) create mode 100644 apps/electron-backend/src/app/events/casting.events.ts create mode 100644 apps/electron-backend/src/app/services/dlna-protocol.ts create mode 100644 apps/electron-backend/src/app/services/dlna-renderer.service.ts create mode 100644 docs/architecture/remote-playback.md create mode 100644 libs/shared/interfaces/src/lib/casting.interface.ts create mode 100644 libs/ui/playback/src/lib/audio-player/audio-player.component.html create mode 100644 libs/ui/playback/src/lib/casting/cast-control.component.html create mode 100644 libs/ui/playback/src/lib/casting/cast-control.component.scss create mode 100644 libs/ui/playback/src/lib/casting/cast-control.component.ts create mode 100644 libs/ui/playback/src/lib/casting/cast-media.utils.ts create mode 100644 libs/ui/playback/src/lib/casting/cast.service.spec.ts create mode 100644 libs/ui/playback/src/lib/casting/cast.service.ts create mode 100644 libs/ui/playback/src/lib/casting/google-cast.types.ts diff --git a/README.md b/README.md index 500f194b2..8caaef3f0 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/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.ts b/apps/electron-backend/src/app/events/casting.events.ts new file mode 100644 index 000000000..2aa311958 --- /dev/null +++ b/apps/electron-backend/src/app/events/casting.events.ts @@ -0,0 +1,18 @@ +import { ipcMain } from 'electron'; +import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { DlnaRendererService } from '../services/dlna-renderer.service'; + +const dlnaRendererService = new DlnaRendererService(); + +ipcMain.handle('CAST:DLNA_DISCOVER', () => dlnaRendererService.discover()); +ipcMain.handle( + 'CAST:DLNA_START', + (_event, deviceId: string, playback: ResolvedPortalPlayback) => + dlnaRendererService.startPlayback(deviceId, playback) +); + +export default class CastingEvents { + static bootstrapCastingEvents(): Electron.IpcMain { + 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..e1ba108aa --- /dev/null +++ b/apps/electron-backend/src/app/services/dlna-protocol.ts @@ -0,0 +1,311 @@ +import { request as httpRequest } from 'http'; +import { request as httpsRequest } from 'https'; +import { isIP } from 'net'; +import { SaxesParser } from 'saxes'; +import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { + isLocalHostname, + isPrivateOrReservedIpv4, + isPrivateOrReservedIpv6, +} from '../events/url-safety'; + +export const SSDP_ADDRESS = '239.255.255.250'; +export const SSDP_PORT = 1900; +export const AV_TRANSPORT_SERVICE = + 'urn:schemas-upnp-org:service:AVTransport:1'; + +const MAX_DLNA_RESPONSE_BYTES = 512 * 1024; + +export interface SsdpResponse { + location: string; + usn: string; +} + +export interface RendererDescription { + friendlyName: string; + modelName: string; + udn: string; + avTransportControlUrl: 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 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}` + ); +} + +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')) + ); + } + ); + req.setTimeout(4_000, () => + req.destroy(new Error('DLNA renderer request timed out.')) + ); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +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; + } +} + +export function hasPlaybackHeaders(playback: ResolvedPortalPlayback): boolean { + return Boolean( + playback.requiresRequestHeaders || + playback.userAgent || + playback.referer || + playback.origin || + Object.keys(playback.headers ?? {}).length > 0 + ); +} + +function isPrivateNetworkAddress(address: string): boolean { + if (isIP(address) !== 4) return false; + const [a, b] = address.split('.').map(Number); + return ( + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (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; +} + +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/app/services/dlna-renderer.service.spec.ts b/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts index 52f67a9b1..42db6c8d0 100644 --- a/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts +++ b/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts @@ -1,6 +1,8 @@ import { buildSsdpSearchRequest, buildUpnpActionBody, + DlnaRendererService, + isReceiverFetchableUrl, isTrustedSsdpLocation, parseRendererDescription, parseSsdpResponse, @@ -29,8 +31,7 @@ describe('DLNA renderer protocol helpers', () => { expect(response).toEqual( expect.objectContaining({ - location: - 'http://192.168.1.50:1400/xml/device_description.xml', + location: 'http://192.168.1.50:1400/xml/device_description.xml', usn: 'uuid:renderer-1::urn:schemas-upnp-org:device:MediaRenderer:1', }) ); @@ -50,10 +51,7 @@ describe('DLNA renderer protocol helpers', () => { ) ).toBe(false); expect( - isTrustedSsdpLocation( - 'file:///etc/passwd', - '192.168.1.50' - ) + isTrustedSsdpLocation('file:///etc/passwd', '192.168.1.50') ).toBe(false); }); @@ -95,4 +93,51 @@ describe('DLNA renderer protocol helpers', () => { ); 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); + }); }); 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..afe96b949 --- /dev/null +++ b/apps/electron-backend/src/app/services/dlna-renderer.service.ts @@ -0,0 +1,223 @@ +import { createSocket } from 'dgram'; +import { + DlnaRendererDevice, + ElectronBridgeErrorResult, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { + AV_TRANSPORT_SERVICE, + SSDP_ADDRESS, + SSDP_PORT, + SsdpResponse, + buildSsdpSearchRequest, + buildUpnpActionBody, + hasPlaybackHeaders, + isReceiverFetchableUrl, + isTrustedSsdpLocation, + parseRendererDescription, + parseSsdpResponse, + requestPinnedText, +} from './dlna-protocol'; + +export { + buildSsdpSearchRequest, + buildUpnpActionBody, + isTrustedSsdpLocation, + isReceiverFetchableUrl, + parseRendererDescription, + parseSsdpResponse, +} from './dlna-protocol'; + +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 { + 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, + }; +} 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 1f80c9af6..9edd8191b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -393,6 +393,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 1d37aafcc..9e187bd55 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -393,6 +393,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 5f6dbe5ad..949e715d5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -393,6 +393,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 98b7922a2..967858fda 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -393,6 +393,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 7b9e973f3..6d45f3429 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -393,6 +393,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 f0add855e..a902686e0 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -393,6 +393,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 0ce3be118..c63510f91 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -393,6 +393,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 50130074a..d286eaabe 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -393,6 +393,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 f24d32eb1..72bf65849 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -393,6 +393,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 3e45ce33d..90ea23cde 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -393,6 +393,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 82a38ca01..53849b750 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -393,6 +393,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 1c56af772..06802ce86 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -393,6 +393,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 cc288d25e..dfc5cd5fa 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -393,6 +393,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 ad485a7b7..91998f887 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -393,6 +393,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 d99dcab30..103a16361 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -393,6 +393,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 58ff1e31c..b44a2b7b9 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -393,6 +393,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 6dedcf2dd..a4cf5525d 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -393,6 +393,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 310488386..606f78f96 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -393,6 +393,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..e041384d7 --- /dev/null +++ b/docs/architecture/remote-playback.md @@ -0,0 +1,110 @@ +# 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. + +## 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. + +## 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 time out. + +## 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, XML, HTTP pinning, and SOAP: + `apps/electron-backend/src/app/services/dlna-protocol.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 9f28374bf..a4692bcc7 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 26b132d62..f84358146 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'; 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/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 434cbf868..25662b937 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('handles volume and mute keyboard shortcuts without focusing inputs', () => { createComponent(); component.setVolume(0.5); 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 a54bd623e..563d5cf3b 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 @@ -20,146 +20,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, @@ -171,6 +41,7 @@ export class AudioPlayerComponent { readonly icon = input(''); readonly url = input.required(); readonly channelName = input(''); + readonly playback = input(null); readonly dispatchAdjacentChannelAction = input(true); readonly channelSwitchRequested = output<'next' | 'previous'>(); @@ -180,6 +51,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.component.html b/libs/ui/playback/src/lib/casting/cast-control.component.html new file mode 100644 index 000000000..131f4574a --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control.component.html @@ -0,0 +1,125 @@ + + + +
+ {{ '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 index 858a34be8..14299a876 100644 --- a/libs/ui/playback/src/lib/casting/cast-control.component.spec.ts +++ b/libs/ui/playback/src/lib/casting/cast-control.component.spec.ts @@ -11,7 +11,10 @@ describe('CastControlComponent', () => { openAirPlayPicker: jest.Mock; openRemotePlaybackPicker: jest.Mock; startDlnaPlayback: jest.Mock; + startGoogleCast: jest.Mock; + canUseGoogleCast: jest.Mock; supportsAirPlay: jest.Mock; + supportsDlna: boolean; supportsRemotePlayback: jest.Mock; }; @@ -27,7 +30,10 @@ describe('CastControlComponent', () => { 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), }; 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..ddc36ee1e --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-control.component.ts @@ -0,0 +1,133 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + inject, + input, + 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, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { + findCastMediaElement, + hasPlaybackHeaders, + 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 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(); + } + } + + 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 index f755216da..9247b3baf 100644 --- a/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts +++ b/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts @@ -17,6 +17,9 @@ describe('cast media utilities', () => { 'blob:https://example.com/stream', 'file:///tmp/movie.mp4', 'data:video/mp4;base64,AAAA', + 'http://localhost:4200/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); @@ -30,6 +33,13 @@ describe('cast media utilities', () => { 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', @@ -44,6 +54,11 @@ describe('cast media utilities', () => { ['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); }); 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..78de4d474 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast-media.utils.ts @@ -0,0 +1,89 @@ +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 !== '[::1]' && + !hostname.startsWith('127.') + ); + } catch { + return false; + } +} + +export function hasPlaybackHeaders(playback: ResolvedPortalPlayback): boolean { + return Boolean( + playback.requiresRequestHeaders || + playback.userAgent || + playback.referer || + playback.origin || + Object.keys(playback.headers ?? {}).length > 0 + ); +} + +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..073f94ea4 --- /dev/null +++ b/libs/ui/playback/src/lib/casting/cast.service.ts @@ -0,0 +1,215 @@ +import { DOCUMENT } from '@angular/common'; +import { Injectable, inject } from '@angular/core'; +import { + DlnaRendererDevice, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + getCastMediaType, + hasPlaybackHeaders, + 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; + if (playback.thumbnail) { + metadata.images = [{ url: playback.thumbnail }]; + } + 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 cc4c21ce5..8eafa9e3d 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 @@ -60,6 +60,8 @@ } } + + @if (visiblePlaybackDiagnostic(); as issue) {
(); } +@Component({ + selector: 'app-cast-control', + template: '', +}) +class StubCastControlComponent { + readonly playback = input.required(); +} + describe('WebPlayerViewComponent', () => { let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; let fixture: ComponentFixture; @@ -114,6 +122,7 @@ describe('WebPlayerViewComponent', () => { set: { imports: [ StubArtPlayerComponent, + StubCastControlComponent, StubEmbeddedMpvPlayerComponent, StubHtmlVideoPlayerComponent, StubVjsPlayerComponent, @@ -145,6 +154,11 @@ 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('renders diagnostics and emits MPV fallback requests when managed external players are available', () => { @@ -347,7 +361,10 @@ describe('WebPlayerViewComponent', () => { it('suppresses browser diagnostics while embedded MPV is selected', () => { const requests: unknown[] = []; runtimeCapabilities.supportsManagedExternalPlayers = true; - fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv); + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.EmbeddedMpv + ); component.externalFallbackRequested.subscribe((request) => requests.push(request) ); 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..0e01a2c08 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 @@ -27,6 +27,7 @@ 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 { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; import { @@ -53,6 +54,7 @@ type PlaybackDiagnosticDetail = { }, imports: [ ArtPlayerComponent, + CastControlComponent, ClipboardModule, EmbeddedMpvPlayerComponent, HtmlVideoPlayerComponent, 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, + }; + } } From 441be8376bf648c529b0ae96204f1d74831111aa Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:12:33 +0200 Subject: [PATCH 03/19] fix(playback): address casting review findings --- apps/electron-backend/project.json | 8 +- .../src/app/events/casting.events.spec.ts | 30 +++++ .../src/app/events/casting.events.ts | 15 +-- .../src/app/services/dlna-protocol.ts | 117 ------------------ .../services/dlna-renderer.service.spec.ts | 36 ++++++ .../src/app/services/dlna-renderer.service.ts | 39 +++++- .../src/app/services/dlna-xml.ts | 105 ++++++++++++++++ docs/architecture/remote-playback.md | 8 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/portal-playback.utils.ts | 11 ++ .../src/lib/casting/cast-control.component.ts | 7 +- .../src/lib/casting/cast-media.utils.spec.ts | 29 ++++- .../src/lib/casting/cast-media.utils.ts | 26 ++-- .../playback/src/lib/casting/cast.service.ts | 8 +- tools/electron/serve-electron-dev.mjs | 64 ++++++++++ 15 files changed, 353 insertions(+), 151 deletions(-) create mode 100644 apps/electron-backend/src/app/events/casting.events.spec.ts create mode 100644 apps/electron-backend/src/app/services/dlna-xml.ts create mode 100644 libs/shared/interfaces/src/lib/portal-playback.utils.ts create mode 100644 tools/electron/serve-electron-dev.mjs diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index ec1cff48d..a968cda77 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -113,8 +113,13 @@ }, "serve": { "executor": "nx:run-commands", + "continuous": true, "options": { - "command": "nx run electron-backend:serve-electron" + "parallel": true, + "commands": [ + "pnpm nx serve web --no-tui", + "node tools/electron/serve-electron-dev.mjs" + ] } }, "serve-electron": { @@ -125,7 +130,6 @@ "executor": "nx-electron:execute", "options": { "buildTarget": "electron-backend:build", - "waitUntilTargets": ["web:serve"], "remoteDebuggingPort": 9222 } }, 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 index 2aa311958..bb0f981f0 100644 --- a/apps/electron-backend/src/app/events/casting.events.ts +++ b/apps/electron-backend/src/app/events/casting.events.ts @@ -4,15 +4,16 @@ import { DlnaRendererService } from '../services/dlna-renderer.service'; const dlnaRendererService = new DlnaRendererService(); -ipcMain.handle('CAST:DLNA_DISCOVER', () => dlnaRendererService.discover()); -ipcMain.handle( - 'CAST:DLNA_START', - (_event, deviceId: string, playback: ResolvedPortalPlayback) => - dlnaRendererService.startPlayback(deviceId, playback) -); - 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/services/dlna-protocol.ts b/apps/electron-backend/src/app/services/dlna-protocol.ts index e1ba108aa..8edd946dc 100644 --- a/apps/electron-backend/src/app/services/dlna-protocol.ts +++ b/apps/electron-backend/src/app/services/dlna-protocol.ts @@ -1,8 +1,6 @@ import { request as httpRequest } from 'http'; import { request as httpsRequest } from 'https'; import { isIP } from 'net'; -import { SaxesParser } from 'saxes'; -import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; import { isLocalHostname, isPrivateOrReservedIpv4, @@ -11,8 +9,6 @@ import { export const SSDP_ADDRESS = '239.255.255.250'; export const SSDP_PORT = 1900; -export const AV_TRANSPORT_SERVICE = - 'urn:schemas-upnp-org:service:AVTransport:1'; const MAX_DLNA_RESPONSE_BYTES = 512 * 1024; @@ -21,13 +17,6 @@ export interface SsdpResponse { usn: string; } -export interface RendererDescription { - friendlyName: string; - modelName: string; - udn: string; - avTransportControlUrl: string; -} - export function buildSsdpSearchRequest(): string { return [ 'M-SEARCH * HTTP/1.1', @@ -78,87 +67,6 @@ export function isTrustedSsdpLocation( } } -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}` - ); -} - export function requestPinnedText( targetUrl: string, address: string, @@ -250,23 +158,11 @@ export function isReceiverFetchableUrl(streamUrl: string): boolean { } } -export function hasPlaybackHeaders(playback: ResolvedPortalPlayback): boolean { - return Boolean( - playback.requiresRequestHeaders || - playback.userAgent || - playback.referer || - playback.origin || - Object.keys(playback.headers ?? {}).length > 0 - ); -} - function isPrivateNetworkAddress(address: string): boolean { if (isIP(address) !== 4) return false; const [a, b] = address.split('.').map(Number); return ( a === 10 || - a === 127 || - (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ); @@ -296,16 +192,3 @@ function normalizeHostname(hostname: string): string { ? normalized.slice(1, -1) : normalized; } - -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/app/services/dlna-renderer.service.spec.ts b/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts index 42db6c8d0..c9d1f5dbf 100644 --- a/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts +++ b/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts @@ -1,3 +1,4 @@ +import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; import { buildSsdpSearchRequest, buildUpnpActionBody, @@ -53,6 +54,15 @@ describe('DLNA renderer protocol helpers', () => { 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', () => { @@ -140,4 +150,30 @@ describe('DLNA renderer protocol helpers', () => { ])('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.', + }); + }); }); diff --git a/apps/electron-backend/src/app/services/dlna-renderer.service.ts b/apps/electron-backend/src/app/services/dlna-renderer.service.ts index afe96b949..a27e68f93 100644 --- a/apps/electron-backend/src/app/services/dlna-renderer.service.ts +++ b/apps/electron-backend/src/app/services/dlna-renderer.service.ts @@ -2,31 +2,32 @@ import { createSocket } from 'dgram'; import { DlnaRendererDevice, ElectronBridgeErrorResult, + hasPlaybackHeaders, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { - AV_TRANSPORT_SERVICE, SSDP_ADDRESS, SSDP_PORT, SsdpResponse, buildSsdpSearchRequest, - buildUpnpActionBody, - hasPlaybackHeaders, isReceiverFetchableUrl, isTrustedSsdpLocation, - parseRendererDescription, parseSsdpResponse, requestPinnedText, } from './dlna-protocol'; +import { + AV_TRANSPORT_SERVICE, + buildUpnpActionBody, + parseRendererDescription, +} from './dlna-xml'; export { buildSsdpSearchRequest, - buildUpnpActionBody, isTrustedSsdpLocation, isReceiverFetchableUrl, - parseRendererDescription, parseSsdpResponse, } from './dlna-protocol'; +export { buildUpnpActionBody, parseRendererDescription } from './dlna-xml'; const DEVICE_CACHE_TTL_MS = 5 * 60_000; @@ -63,6 +64,17 @@ export class DlnaRendererService { 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 { @@ -221,3 +233,18 @@ function toPublicDevice(renderer: CachedRenderer): DlnaRendererDevice { 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/docs/architecture/remote-playback.md b/docs/architecture/remote-playback.md index e041384d7..c9cfdd356 100644 --- a/docs/architecture/remote-playback.md +++ b/docs/architecture/remote-playback.md @@ -57,7 +57,9 @@ 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. +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 @@ -86,8 +88,10 @@ Redirects are not followed, responses are size-limited, and requests time out. `libs/shared/interfaces/src/lib/electron-api.interface.ts` - Electron IPC: `apps/electron-backend/src/app/events/casting.events.ts` -- SSDP, XML, HTTP pinning, and SOAP: +- 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` diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index f84358146..4dbeff54e 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -26,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/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/playback/src/lib/casting/cast-control.component.ts b/libs/ui/playback/src/lib/casting/cast-control.component.ts index ddc36ee1e..955dfccf0 100644 --- a/libs/ui/playback/src/lib/casting/cast-control.component.ts +++ b/libs/ui/playback/src/lib/casting/cast-control.component.ts @@ -15,13 +15,10 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { DlnaRendererDevice, + hasPlaybackHeaders, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; -import { - findCastMediaElement, - hasPlaybackHeaders, - isDirectCastUrl, -} from './cast-media.utils'; +import { findCastMediaElement, isDirectCastUrl } from './cast-media.utils'; import { CastService } from './cast.service'; @Component({ 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 index 9247b3baf..27c9c49c8 100644 --- a/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts +++ b/libs/ui/playback/src/lib/casting/cast-media.utils.spec.ts @@ -1,7 +1,8 @@ +import { hasPlaybackHeaders } from '@iptvnator/shared/interfaces'; import { findCastMediaElement, getCastMediaType, - hasPlaybackHeaders, + getSafeCastThumbnailUrl, isDirectCastUrl, } from './cast-media.utils'; @@ -18,6 +19,8 @@ describe('cast media utilities', () => { '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', @@ -48,6 +51,30 @@ describe('cast media utilities', () => { ).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'], diff --git a/libs/ui/playback/src/lib/casting/cast-media.utils.ts b/libs/ui/playback/src/lib/casting/cast-media.utils.ts index 78de4d474..7f71db11e 100644 --- a/libs/ui/playback/src/lib/casting/cast-media.utils.ts +++ b/libs/ui/playback/src/lib/casting/cast-media.utils.ts @@ -29,7 +29,9 @@ export function isDirectCastUrl(streamUrl: string): boolean { !url.username && !url.password && hostname !== 'localhost' && + !hostname.endsWith('.localhost') && hostname !== '[::1]' && + hostname !== '0.0.0.0' && !hostname.startsWith('127.') ); } catch { @@ -37,14 +39,22 @@ export function isDirectCastUrl(streamUrl: string): boolean { } } -export function hasPlaybackHeaders(playback: ResolvedPortalPlayback): boolean { - return Boolean( - playback.requiresRequestHeaders || - playback.userAgent || - playback.referer || - playback.origin || - Object.keys(playback.headers ?? {}).length > 0 - ); +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 { diff --git a/libs/ui/playback/src/lib/casting/cast.service.ts b/libs/ui/playback/src/lib/casting/cast.service.ts index 073f94ea4..5fa798e6a 100644 --- a/libs/ui/playback/src/lib/casting/cast.service.ts +++ b/libs/ui/playback/src/lib/casting/cast.service.ts @@ -2,12 +2,13 @@ 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, - hasPlaybackHeaders, + getSafeCastThumbnailUrl, isDirectCastUrl, supportsAirPlayPicker, supportsRemotePlaybackPicker, @@ -101,8 +102,9 @@ export class CastService { : runtime.chrome.cast.media.StreamType.BUFFERED; const metadata = new runtime.chrome.cast.media.GenericMediaMetadata(); metadata.title = playback.title; - if (playback.thumbnail) { - metadata.images = [{ url: playback.thumbnail }]; + const thumbnailUrl = getSafeCastThumbnailUrl(playback); + if (thumbnailUrl) { + metadata.images = [{ url: thumbnailUrl }]; } mediaInfo.metadata = metadata; diff --git a/tools/electron/serve-electron-dev.mjs b/tools/electron/serve-electron-dev.mjs new file mode 100644 index 000000000..7ef5e823e --- /dev/null +++ b/tools/electron/serve-electron-dev.mjs @@ -0,0 +1,64 @@ +import { spawn } from 'node:child_process'; +import { request } from 'node:http'; + +const WEB_URL = new URL('http://localhost:4200/'); +const STARTUP_TIMEOUT_MS = 120_000; +const POLL_INTERVAL_MS = 500; + +await waitForWebServer(); + +const packageManagerCli = process.env.npm_execpath; +if (!packageManagerCli) { + throw new Error('Unable to locate the workspace package manager CLI.'); +} + +const child = spawn( + process.execPath, + [packageManagerCli, 'nx', 'run', 'electron-backend:serve-electron'], + { + stdio: 'inherit', + windowsHide: true, + } +); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + child.kill(signal); + }); +} + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exitCode = code ?? 1; +}); + +function waitForWebServer() { + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + + return new Promise((resolveReady, reject) => { + const check = () => { + const req = request(WEB_URL, { method: 'HEAD' }, (response) => { + response.resume(); + resolveReady(); + }); + req.setTimeout(2_000, () => req.destroy()); + req.on('error', () => { + if (Date.now() >= deadline) { + reject( + new Error( + `Web development server did not start within ${STARTUP_TIMEOUT_MS} ms.` + ) + ); + return; + } + setTimeout(check, POLL_INTERVAL_MS); + }); + req.end(); + }; + + check(); + }); +} From e49383ccac2b5627dc92423150b54da01558fe4c Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:28:11 +0200 Subject: [PATCH 04/19] fix(dev): harden electron startup coordination --- apps/electron-backend/project.json | 13 +- .../src/app/services/dlna-protocol.ts | 20 +- .../services/dlna-renderer.service.spec.ts | 23 ++ docs/architecture/remote-playback.md | 3 +- tools/electron/serve-electron-dev.mjs | 75 ++---- tools/electron/serve-electron-dev.runtime.mjs | 216 ++++++++++++++++++ tools/electron/serve-electron-dev.test.mjs | 131 +++++++++++ 7 files changed, 424 insertions(+), 57 deletions(-) create mode 100644 tools/electron/serve-electron-dev.runtime.mjs create mode 100644 tools/electron/serve-electron-dev.test.mjs diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index a968cda77..8181117af 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -115,11 +115,7 @@ "executor": "nx:run-commands", "continuous": true, "options": { - "parallel": true, - "commands": [ - "pnpm nx serve web --no-tui", - "node tools/electron/serve-electron-dev.mjs" - ] + "command": "node tools/electron/serve-electron-dev.mjs" } }, "serve-electron": { @@ -181,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/services/dlna-protocol.ts b/apps/electron-backend/src/app/services/dlna-protocol.ts index 8edd946dc..3333ef4c5 100644 --- a/apps/electron-backend/src/app/services/dlna-protocol.ts +++ b/apps/electron-backend/src/app/services/dlna-protocol.ts @@ -11,6 +11,12 @@ 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; @@ -133,7 +139,8 @@ export function requestPinnedText( ); } ); - req.setTimeout(4_000, () => + enforceAbsoluteRequestTimeout(req); + req.setTimeout(DLNA_REQUEST_TIMEOUT_MS, () => req.destroy(new Error('DLNA renderer request timed out.')) ); req.on('error', reject); @@ -142,6 +149,17 @@ export function requestPinnedText( }); } +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); 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 index c9d1f5dbf..f44031b10 100644 --- a/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts +++ b/apps/electron-backend/src/app/services/dlna-renderer.service.spec.ts @@ -1,4 +1,5 @@ import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { EventEmitter } from 'events'; import { buildSsdpSearchRequest, buildUpnpActionBody, @@ -8,6 +9,7 @@ import { parseRendererDescription, parseSsdpResponse, } from './dlna-renderer.service'; +import { enforceAbsoluteRequestTimeout } from './dlna-protocol'; describe('DLNA renderer protocol helpers', () => { it('builds a standards-compliant MediaRenderer discovery request', () => { @@ -176,4 +178,25 @@ describe('DLNA renderer protocol helpers', () => { 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/docs/architecture/remote-playback.md b/docs/architecture/remote-playback.md index c9cfdd356..0d596819c 100644 --- a/docs/architecture/remote-playback.md +++ b/docs/architecture/remote-playback.md @@ -76,7 +76,8 @@ 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 time out. +Redirects are not followed, responses are size-limited, and requests have both +an inactivity timeout and an absolute deadline. ## Ownership diff --git a/tools/electron/serve-electron-dev.mjs b/tools/electron/serve-electron-dev.mjs index 7ef5e823e..4fd326d83 100644 --- a/tools/electron/serve-electron-dev.mjs +++ b/tools/electron/serve-electron-dev.mjs @@ -1,64 +1,39 @@ import { spawn } from 'node:child_process'; -import { request } from 'node:http'; +import { + assertPortAvailable, + coordinateChildProcesses, + waitForIptvnatorWebServer, +} from './serve-electron-dev.runtime.mjs'; const WEB_URL = new URL('http://localhost:4200/'); -const STARTUP_TIMEOUT_MS = 120_000; -const POLL_INTERVAL_MS = 500; - -await waitForWebServer(); - const packageManagerCli = process.env.npm_execpath; if (!packageManagerCli) { throw new Error('Unable to locate the workspace package manager CLI.'); } -const child = spawn( +await assertPortAvailable(Number(WEB_URL.port)); + +const childOptions = { + stdio: 'inherit', + windowsHide: true, +}; +const webChild = spawn( process.execPath, - [packageManagerCli, 'nx', 'run', 'electron-backend:serve-electron'], - { - stdio: 'inherit', - windowsHide: true, - } + [packageManagerCli, 'nx', 'serve', 'web', '--no-tui'], + childOptions ); -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => { - child.kill(signal); - }); +try { + await waitForIptvnatorWebServer(WEB_URL, webChild); +} catch (error) { + webChild.kill('SIGTERM'); + throw error; } -child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exitCode = code ?? 1; -}); - -function waitForWebServer() { - const deadline = Date.now() + STARTUP_TIMEOUT_MS; - - return new Promise((resolveReady, reject) => { - const check = () => { - const req = request(WEB_URL, { method: 'HEAD' }, (response) => { - response.resume(); - resolveReady(); - }); - req.setTimeout(2_000, () => req.destroy()); - req.on('error', () => { - if (Date.now() >= deadline) { - reject( - new Error( - `Web development server did not start within ${STARTUP_TIMEOUT_MS} ms.` - ) - ); - return; - } - setTimeout(check, POLL_INTERVAL_MS); - }); - req.end(); - }; +const electronChild = spawn( + process.execPath, + [packageManagerCli, 'nx', 'run', 'electron-backend:serve-electron'], + childOptions +); - check(); - }); -} +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; +} From c4e888617aa051527b70ddb00789c3ded417cbe1 Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:55:39 +0200 Subject: [PATCH 05/19] test(playback): reproduce persistent cast overlay --- .../web-player-view.component.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index b14306255..ebc2f7115 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -87,6 +87,8 @@ class StubEmbeddedMpvPlayerComponent { }) class StubCastControlComponent { readonly playback = input.required(); + readonly placement = input<'overlay' | 'inline'>('overlay'); + readonly menuOpenChange = output(); } describe('WebPlayerViewComponent', () => { @@ -161,6 +163,39 @@ describe('WebPlayerViewComponent', () => { ).not.toBeNull(); }); + it('auto-hides the cast overlay with the player controls and restores it on activity', () => { + jest.useFakeTimers(); + + try { + component.showCastControlTemporarily(); + 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' + ); + + fixture.nativeElement.dispatchEvent(new PointerEvent('pointermove')); + fixture.detectChanges(); + + expect(overlay.nativeElement.classList).toContain( + 'web-player-cast-control--visible' + ); + } finally { + jest.useRealTimers(); + } + }); + it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => { const requests: unknown[] = []; runtimeCapabilities.supportsManagedExternalPlayers = true; From b9007db1209f3c6687bbe521f393f01b55a0ad40 Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:04:18 +0200 Subject: [PATCH 06/19] fix(playback): auto-hide casting control --- docs/architecture/remote-playback.md | 5 ++ .../lib/casting/cast-control-visibility.ts | 77 +++++++++++++++++++ .../lib/casting/cast-control.component.html | 3 +- .../casting/cast-control.component.spec.ts | 13 ++++ .../src/lib/casting/cast-control.component.ts | 11 +++ .../web-player-view.component.html | 16 +++- .../web-player-view.component.scss | 25 ++++++ .../web-player-view.component.spec.ts | 38 ++++++++- .../web-player-view.component.ts | 14 +++- 9 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 libs/ui/playback/src/lib/casting/cast-control-visibility.ts diff --git a/docs/architecture/remote-playback.md b/docs/architecture/remote-playback.md index 0d596819c..4a9e0931c 100644 --- a/docs/architecture/remote-playback.md +++ b/docs/architecture/remote-playback.md @@ -26,6 +26,11 @@ window or the operating-system desktop. 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 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 index 131f4574a..3d6661795 100644 --- a/libs/ui/playback/src/lib/casting/cast-control.component.html +++ b/libs/ui/playback/src/lib/casting/cast-control.component.html @@ -6,7 +6,8 @@ [matMenuTriggerFor]="castMenu" [attr.aria-label]="'CASTING.OPEN' | translate" [matTooltip]="'CASTING.OPEN' | translate" - (menuOpened)="prepareMenu()" + (menuOpened)="handleMenuOpened()" + (menuClosed)="handleMenuClosed()" > {{ connectedDeviceName() ? 'cast_connected' : 'cast' }} 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 index 14299a876..5b697b015 100644 --- a/libs/ui/playback/src/lib/casting/cast-control.component.spec.ts +++ b/libs/ui/playback/src/lib/casting/cast-control.component.spec.ts @@ -75,6 +75,19 @@ describe('CastControlComponent', () => { ); }); + 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'); diff --git a/libs/ui/playback/src/lib/casting/cast-control.component.ts b/libs/ui/playback/src/lib/casting/cast-control.component.ts index 955dfccf0..5c9befdb2 100644 --- a/libs/ui/playback/src/lib/casting/cast-control.component.ts +++ b/libs/ui/playback/src/lib/casting/cast-control.component.ts @@ -5,6 +5,7 @@ import { computed, inject, input, + output, signal, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; @@ -42,6 +43,7 @@ import { CastService } from './cast.service'; export class CastControlComponent { readonly playback = input.required(); readonly placement = input<'overlay' | 'inline'>('overlay'); + readonly menuOpenChange = output(); readonly dlnaDevices = signal([]); readonly discovering = signal(false); @@ -73,6 +75,15 @@ export class CastControlComponent { } } + handleMenuOpened(): void { + this.menuOpenChange.emit(true); + void this.prepareMenu(); + } + + handleMenuClosed(): void { + this.menuOpenChange.emit(false); + } + openAirPlay(media = this.getMediaElement()): void { if (!media) { return; 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 8eafa9e3d..a95eb455c 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 @@ -60,7 +60,21 @@ } } - +
+ +
@if (visiblePlaybackDiagnostic(); as issue) {
{ jest.useFakeTimers(); try { - component.showCastControlTemporarily(); + component.castControlVisibility.showTemporarily(); fixture.detectChanges(); const overlay = fixture.debugElement.query( @@ -185,7 +185,7 @@ describe('WebPlayerViewComponent', () => { 'web-player-cast-control--visible' ); - fixture.nativeElement.dispatchEvent(new PointerEvent('pointermove')); + fixture.nativeElement.dispatchEvent(new Event('pointermove')); fixture.detectChanges(); expect(overlay.nativeElement.classList).toContain( @@ -196,6 +196,40 @@ describe('WebPlayerViewComponent', () => { } }); + 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', () => { const requests: unknown[] = []; runtimeCapabilities.supportsManagedExternalPlayers = true; 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 0e01a2c08..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, @@ -28,6 +29,7 @@ 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 { @@ -51,6 +53,9 @@ type PlaybackDiagnosticDetail = { styleUrls: ['./web-player-view.component.scss'], host: { class: 'web-player-view', + '(pointerenter)': 'castControlVisibility.showTemporarily()', + '(pointermove)': 'castControlVisibility.showTemporarily()', + '(keydown)': 'castControlVisibility.showTemporarily()', }, imports: [ ArtPlayerComponent, @@ -66,7 +71,7 @@ type PlaybackDiagnosticDetail = { ], encapsulation: ViewEncapsulation.None, }) -export class WebPlayerViewComponent { +export class WebPlayerViewComponent implements OnDestroy { storage = inject(StorageMap); private readonly runtime = inject(RuntimeCapabilitiesService); @@ -99,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 @@ -150,6 +156,12 @@ export class WebPlayerViewComponent { this.isLivePlayback(playback) ); }); + + this.castControlVisibility.showTemporarily(); + } + + ngOnDestroy(): void { + this.castControlVisibility.destroy(); } setVjsOptions(streamUrl: string, isLive = true) { From b22772fce63a82bb91e56254b1e2ec569bdcf565 Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:19:19 +0200 Subject: [PATCH 07/19] test(playback): cover hidden cast accessibility --- .../casting/cast-control-visibility.spec.ts | 42 +++++++++++++++++++ .../web-player-view.component.spec.ts | 6 +++ 2 files changed, 48 insertions(+) create mode 100644 libs/ui/playback/src/lib/casting/cast-control-visibility.spec.ts 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/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 079e48b0b..6c0cbf661 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -184,6 +184,10 @@ describe('WebPlayerViewComponent', () => { expect(overlay.nativeElement.classList).not.toContain( 'web-player-cast-control--visible' ); + expect(overlay.nativeElement.getAttribute('aria-hidden')).toBe( + 'true' + ); + expect(overlay.nativeElement.inert).toBe(true); fixture.nativeElement.dispatchEvent(new Event('pointermove')); fixture.detectChanges(); @@ -191,6 +195,8 @@ describe('WebPlayerViewComponent', () => { expect(overlay.nativeElement.classList).toContain( 'web-player-cast-control--visible' ); + expect(overlay.nativeElement.getAttribute('aria-hidden')).toBeNull(); + expect(overlay.nativeElement.inert).toBe(false); } finally { jest.useRealTimers(); } From 839dcbc3a161a571dba3c5d034370071a07fcc65 Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:23:42 +0200 Subject: [PATCH 08/19] fix(playback): hide inactive cast control from focus --- .../lib/web-player-view/web-player-view.component.html | 2 ++ .../lib/web-player-view/web-player-view.component.spec.ts | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) 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 a95eb455c..794dd8393 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 @@ -64,6 +64,8 @@ class="web-player-cast-control" data-test-id="cast-control-overlay" [class.web-player-cast-control--visible]="castControlVisibility.visible()" + [attr.aria-hidden]="castControlVisibility.visible() ? null : 'true'" + [attr.inert]="castControlVisibility.visible() ? null : ''" (pointerenter)="castControlVisibility.setInteractionActive(true)" (pointerleave)="castControlVisibility.setInteractionActive(false)" (focusin)="castControlVisibility.setInteractionActive(true)" diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 6c0cbf661..abd50cb5b 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -187,7 +187,7 @@ describe('WebPlayerViewComponent', () => { expect(overlay.nativeElement.getAttribute('aria-hidden')).toBe( 'true' ); - expect(overlay.nativeElement.inert).toBe(true); + expect(overlay.nativeElement.hasAttribute('inert')).toBe(true); fixture.nativeElement.dispatchEvent(new Event('pointermove')); fixture.detectChanges(); @@ -195,8 +195,10 @@ describe('WebPlayerViewComponent', () => { expect(overlay.nativeElement.classList).toContain( 'web-player-cast-control--visible' ); - expect(overlay.nativeElement.getAttribute('aria-hidden')).toBeNull(); - expect(overlay.nativeElement.inert).toBe(false); + expect( + overlay.nativeElement.getAttribute('aria-hidden') + ).toBeNull(); + expect(overlay.nativeElement.hasAttribute('inert')).toBe(false); } finally { jest.useRealTimers(); } From 4696ba91e7b3619d16560534d60d4d8efcb17a67 Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:36:39 +0200 Subject: [PATCH 09/19] test(workspace): reproduce compact rail toggle behavior --- ...rkspace-shell-rail-links.component.spec.ts | 53 +++++++++++++++++++ .../workspace-shell-rail.component.spec.ts | 44 +++++++++++---- .../workspace-shell.component.spec.ts | 14 +++++ 3 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.spec.ts diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.spec.ts new file mode 100644 index 000000000..20e0ce096 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.spec.ts @@ -0,0 +1,53 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { WorkspaceShellRailLinksComponent } from './workspace-shell-rail-links.component'; + +describe('WorkspaceShellRailLinksComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WorkspaceShellRailLinksComponent], + providers: [provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(WorkspaceShellRailLinksComponent); + fixture.componentRef.setInput('links', [ + { + icon: 'dashboard', + tooltip: 'Dashboard', + path: ['/workspace/dashboard'], + exact: true, + }, + { + icon: 'movie', + tooltip: 'Movies', + path: ['/workspace/movies'], + }, + ]); + }); + + it('keeps labels hidden in the compact rail', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelectorAll('.portal-rail-link-label') + ).toHaveLength(0); + }); + + it('renders every navigation label in the expanded rail', () => { + fixture.componentRef.setInput('expanded', true); + fixture.detectChanges(); + + const labels = Array.from( + fixture.nativeElement.querySelectorAll('.portal-rail-link-label') + ).map((element: Element) => element.textContent?.trim()); + + expect(labels).toEqual(['Dashboard', 'Movies']); + expect( + fixture.nativeElement + .querySelector('.rail-links') + ?.classList.contains('is-expanded') + ).toBe(true); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.spec.ts index 8c8a7dbe0..bdef2fe24 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.spec.ts @@ -1,5 +1,6 @@ import { Component, input } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { provideRouter } from '@angular/router'; @@ -18,6 +19,7 @@ class MockWorkspaceShellRailLinksComponent { readonly links = input([]); readonly selectedSection = input(null); readonly activeClass = input('active'); + readonly expanded = input(false); } describe('WorkspaceShellRailComponent', () => { @@ -60,11 +62,6 @@ describe('WorkspaceShellRailComponent', () => { }); it('renders provider context region and active settings shortcut state', () => { - fixture.componentRef.setInput('brandLink', '/workspace/sources'); - fixture.componentRef.setInput( - 'brandAriaLabelKey', - 'WORKSPACE.SHELL.OPEN_SOURCES' - ); fixture.componentRef.setInput('primaryContextLinks', [ { icon: 'movie', @@ -86,15 +83,40 @@ describe('WorkspaceShellRailComponent', () => { expect( fixture.nativeElement.querySelector('.rail-shortcut.is-active') ).not.toBeNull(); + }); + + it('uses the brand control to toggle the rail instead of navigating', () => { + fixture.detectChanges(); + + const brand = fixture.debugElement.query(By.css('.brand')); + + expect(brand.nativeElement.tagName).toBe('BUTTON'); + expect(brand.nativeElement.getAttribute('href')).toBeNull(); + expect(brand.nativeElement.getAttribute('aria-expanded')).toBe('false'); + + brand.triggerEventHandler('click'); + fixture.detectChanges(); + + expect(fixture.componentInstance.expanded()).toBe(true); + expect(brand.nativeElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('shows the application and navigation labels while expanded', () => { + fixture.componentRef.setInput('expanded', true); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.brand-label')?.textContent + ).toContain('IPTVnator'); expect( fixture.nativeElement - .querySelector('.brand') - ?.getAttribute('href') - ).toContain('/workspace/sources'); + .querySelector('.rail-shortcut-label') + ?.textContent.trim() + ).toBe('WORKSPACE.SHELL.RAIL_SETTINGS'); expect( fixture.nativeElement - .querySelector('.brand') - ?.getAttribute('aria-label') - ).toBe('WORKSPACE.SHELL.OPEN_SOURCES'); + .querySelector('.app-rail') + ?.classList.contains('is-expanded') + ).toBe(true); }); }); 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 bd1766660..7e7a76d96 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 @@ -29,6 +29,8 @@ class MockWorkspaceShellRailComponent { readonly selectedSection = input(null); readonly railProviderClass = input(''); readonly isSettingsRoute = input(false); + readonly expanded = input(false); + readonly expandedChange = output(); } @Component({ @@ -259,6 +261,18 @@ describe('WorkspaceShellComponent', () => { expect( fixture.nativeElement.querySelector('app-cast-control') ).not.toBeNull(); + + const rail = fixture.debugElement.query( + By.directive(MockWorkspaceShellRailComponent) + ); + rail.componentInstance.expandedChange.emit(true); + fixture.detectChanges(); + + expect( + fixture.nativeElement + .querySelector('.workspace-shell') + ?.classList.contains('rail-expanded') + ).toBe(true); }); it('renders the xtream import overlay child only when the facade flag is true', async () => { From a21b842df5111f0af4d6f539e0a90e6faab7d7ce Mon Sep 17 00:00:00 2001 From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com> Date: Sun, 14 Jun 2026 06:22:28 +0200 Subject: [PATCH 10/19] feat(workspace): add expandable navigation rail --- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/workspace-shell.md | 11 ++ .../workspace-shell-rail-links.component.html | 12 +- .../workspace-shell-rail-links.component.scss | 52 +++++++- ...rkspace-shell-rail-links.component.spec.ts | 5 + .../workspace-shell-rail-links.component.ts | 12 +- .../workspace-shell-rail.component.html | 83 ++++++++---- .../workspace-shell-rail.component.scss | 123 ++++++++++++++++-- .../workspace-shell-rail.component.spec.ts | 94 +++++++++++++ .../workspace-shell-rail.component.ts | 47 ++++++- .../workspace-shell-route-state.service.ts | 23 +--- .../services/workspace-shell.facade.spec.ts | 1 - .../services/workspace-shell.facade.ts | 3 - .../workspace-shell.component.html | 15 ++- .../workspace-shell.component.scss | 9 +- .../workspace-shell.component.spec.ts | 6 - .../workspace-shell.component.ts | 3 +- 34 files changed, 454 insertions(+), 81 deletions(-) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 9edd8191b..d3c9ea5e7 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "توسيع التنقل", + "COLLAPSE_NAVIGATION": "طي التنقل", "RAIL_DASHBOARD": "لوحة التحكم", "OPEN_DASHBOARD": "فتح لوحة التحكم", "RAIL_SOURCES": "المصادر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 9e187bd55..a0bbc1fbd 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "وسّع التنقل", + "COLLAPSE_NAVIGATION": "طوي التنقل", "RAIL_DASHBOARD": "لوحة التحكم", "OPEN_DASHBOARD": "حل لوحة التحكم", "RAIL_SOURCES": "المصادر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 949e715d5..33c4382b6 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Разгарнуць навігацыю", + "COLLAPSE_NAVIGATION": "Згарнуць навігацыю", "RAIL_DASHBOARD": "Панэль", "OPEN_DASHBOARD": "Адкрыць панэль", "RAIL_SOURCES": "Крыніцы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 967858fda..302a75034 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Navigation ausklappen", + "COLLAPSE_NAVIGATION": "Navigation einklappen", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Dashboard öffnen", "RAIL_SOURCES": "Quellen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 6d45f3429..94a62fc9d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Ανάπτυξη πλοήγησης", + "COLLAPSE_NAVIGATION": "Σύμπτυξη πλοήγησης", "RAIL_DASHBOARD": "Πίνακας ελέγχου", "OPEN_DASHBOARD": "Άνοιγμα πίνακα ελέγχου", "RAIL_SOURCES": "Πηγές", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a902686e0..e55aad1c6 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Expand navigation", + "COLLAPSE_NAVIGATION": "Collapse navigation", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Sources", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c63510f91..3c3ba4b48 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Ampliar navegación", + "COLLAPSE_NAVIGATION": "Contraer navegación", "RAIL_DASHBOARD": "Panel", "OPEN_DASHBOARD": "Abrir panel", "RAIL_SOURCES": "Fuentes", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d286eaabe..bfe97fa88 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Développer la navigation", + "COLLAPSE_NAVIGATION": "Réduire la navigation", "RAIL_DASHBOARD": "Tableau de bord", "OPEN_DASHBOARD": "Ouvrir le tableau de bord", "RAIL_SOURCES": "Sources", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 72bf65849..350e9c868 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Espandi navigazione", + "COLLAPSE_NAVIGATION": "Comprimi navigazione", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Apri dashboard", "RAIL_SOURCES": "Sorgenti", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 90ea23cde..ef6ef25b6 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "ナビゲーションを展開", + "COLLAPSE_NAVIGATION": "ナビゲーションを折りたたむ", "RAIL_DASHBOARD": "ダッシュボード", "OPEN_DASHBOARD": "ダッシュボードを開く", "RAIL_SOURCES": "ソース", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 53849b750..a40a9f8da 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "탐색 펼치기", + "COLLAPSE_NAVIGATION": "탐색 접기", "RAIL_DASHBOARD": "대시보드", "OPEN_DASHBOARD": "대시보드 열기", "RAIL_SOURCES": "소스", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 06802ce86..1846641aa 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Navigatie uitklappen", + "COLLAPSE_NAVIGATION": "Navigatie inklappen", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Bronnen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index dfc5cd5fa..8ee8666b0 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Rozwiń nawigację", + "COLLAPSE_NAVIGATION": "Zwiń nawigację", "RAIL_DASHBOARD": "Pulpit", "OPEN_DASHBOARD": "Otwórz pulpit", "RAIL_SOURCES": "Źródła", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 91998f887..6f676e9ad 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Expandir navegação", + "COLLAPSE_NAVIGATION": "Recolher navegação", "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Abrir dashboard", "RAIL_SOURCES": "Fontes", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 103a16361..3ed82a45a 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Развернуть навигацию", + "COLLAPSE_NAVIGATION": "Свернуть навигацию", "RAIL_DASHBOARD": "Панель", "OPEN_DASHBOARD": "Открыть панель", "RAIL_SOURCES": "Источники", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index b44a2b7b9..982590d61 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "Gezinmeyi genişlet", + "COLLAPSE_NAVIGATION": "Gezinmeyi daralt", "RAIL_DASHBOARD": "Pano", "OPEN_DASHBOARD": "Panoyu aç", "RAIL_SOURCES": "Kaynaklar", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index a4cf5525d..144454d8e 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "展开导航", + "COLLAPSE_NAVIGATION": "收起导航", "RAIL_DASHBOARD": "仪表盘", "OPEN_DASHBOARD": "打开仪表盘", "RAIL_SOURCES": "源", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 606f78f96..359a84992 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -910,6 +910,8 @@ }, "SHELL": { "BRAND_ALT": "IPTVnator", + "EXPAND_NAVIGATION": "展開導覽", + "COLLAPSE_NAVIGATION": "收合導覽", "RAIL_DASHBOARD": "儀表板", "OPEN_DASHBOARD": "開啟儀表板", "RAIL_SOURCES": "來源", diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 991582789..dd0287f49 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -153,6 +153,17 @@ Rail navigation is also shell-owned: 3. On dashboard, sources, settings, and global favorites, the shell falls back to the currently selected playlist so provider navigation remains available even outside a provider route. +4. The IPTVnator brand button toggles the primary rail between its default + 60px icon-only layout and an expanded, label-bearing layout. The expanded + width is content-driven by the longest visible label with equal horizontal + spacing around icons and text, capped to preserve the content area. Mobile + navigation remains compact and does not enter the expanded state. +5. Active-link markers must stay inset within the rail so selection styling is + not clipped by the rail's overflow boundary. +6. The navigation links own the rail's scroll area while the settings shortcut + remains visible in the fixed footer. +7. Expanded labels use automatic text direction and logical start alignment so + left-to-right and right-to-left translations align naturally. Command palette behavior is shell-owned but view-extensible: diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.html index 5642a1b9b..34eb36226 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail-links/workspace-shell-rail-links.component.html @@ -1,4 +1,4 @@ -