Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bc9d535
test(playback): define casting contracts
SalemOurabi Jun 13, 2026
17c6e80
feat(playback): add secure remote casting controls
SalemOurabi Jun 13, 2026
441be83
fix(playback): address casting review findings
SalemOurabi Jun 13, 2026
e49383c
fix(dev): harden electron startup coordination
SalemOurabi Jun 13, 2026
c4e8886
test(playback): reproduce persistent cast overlay
SalemOurabi Jun 13, 2026
b9007db
fix(playback): auto-hide casting control
SalemOurabi Jun 13, 2026
b22772f
test(playback): cover hidden cast accessibility
SalemOurabi Jun 13, 2026
839dcbc
fix(playback): hide inactive cast control from focus
SalemOurabi Jun 13, 2026
516755e
Merge remote-tracking branch 'upstream/master' into codex/casting-sup…
SalemOurabi Jun 13, 2026
4696ba9
test(workspace): reproduce compact rail toggle behavior
SalemOurabi Jun 13, 2026
a21b842
feat(workspace): add expandable navigation rail
SalemOurabi Jun 14, 2026
fe66103
Merge remote-tracking branch 'upstream/master' into codex/casting-sup…
SalemOurabi Jun 14, 2026
f0feae7
fix(i18n): clarify Arabic navigation labels
SalemOurabi Jun 14, 2026
a0c42f5
test(xtream): reproduce noisy IPC failures
SalemOurabi Jun 14, 2026
a515c0a
Return structured Xtream error responses via IPC
SalemOurabi Jun 14, 2026
afcb366
fix(xtream): preserve cancellations and repair electron e2e
SalemOurabi Jun 14, 2026
9f27fd3
Merge upstream master into casting support
SalemOurabi Jun 14, 2026
cf901ee
Merge remote-tracking branch 'upstream/master' into codex/casting-sup…
SalemOurabi Jun 14, 2026
25f1aa9
Merge upstream/master into codex/casting-support
SalemOurabi Jun 14, 2026
10adae4
refactor(xtream): extract request helpers into xtream-request.util
SalemOurabi Jun 14, 2026
dd6cc37
fix(playback): address casting review feedback
SalemOurabi Jun 15, 2026
0c0df9e
fix(csp): allow YouTube trailer embeds in frame-src
SalemOurabi Jun 16, 2026
944e87e
fix(playback): keep the cast control reachable across players and ful…
SalemOurabi Jun 16, 2026
cf4efb7
feat(playback): fullscreen the player container so casting works in f…
SalemOurabi Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/electron-backend/src/app/api/main.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
EmbeddedMpvRecordingStartOptions,
EmbeddedMpvSession,
EmbeddedMpvSupport,
DlnaRendererDevice,
ElectronBridgeApi,
ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload,
Expand Down Expand Up @@ -741,6 +742,10 @@ const electronApi: ElectronBridgeApi = {
contentType
),
getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'),
discoverDlnaRenderers: (): Promise<DlnaRendererDevice[]> =>
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),
Expand Down
18 changes: 18 additions & 0 deletions apps/electron-backend/src/app/events/casting.events.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
SalemOurabi marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface CreateExternalPlayerSessionOptions {
title: string;
thumbnail?: string | null;
streamUrl: string;
requiresRequestHeaders?: boolean;
contentInfo?: PlayerContentInfo;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -131,7 +133,9 @@ export class ExternalPlayerSessionRegistry {
try {
await runtime.close?.();
} finally {
return this.markClosed(id);
this.markClosed(id);
}

return this.getSession(id);
}
}
20 changes: 19 additions & 1 deletion apps/electron-backend/src/app/events/mpv-session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: '' },
]);
});
});
58 changes: 29 additions & 29 deletions apps/electron-backend/src/app/events/mpv-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});

Expand All @@ -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,
Expand All @@ -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,
]);
}

Expand Down
30 changes: 18 additions & 12 deletions apps/electron-backend/src/app/events/vlc-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand All @@ -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,
Expand Down
Loading
Loading