From e192cba77699f27e175d10f325d142df52f5d165 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 5 May 2026 00:55:04 +0200 Subject: [PATCH] feat(player): add VLC reuse-instance setting (#893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VLC was unconditionally spawned per click — VLC's own single-instance preference fails because the per-launch RC args defeat its D-Bus forwarder. Mirror the existing MPV reuse pattern so users can opt in to driving one tracked VLC via its RC interface (clear + add) instead of opening a new window every stream. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: f957cd9849e0 --- .../src/app/events/player.events.spec.ts | 73 +++++ .../src/app/events/player.events.ts | 263 +++++++++++++++++- .../src/app/events/settings.events.ts | 5 + .../src/app/services/store.service.ts | 2 + apps/web/src/app/app.component.spec.ts | 1 + .../settings-playback-section.component.html | 12 + .../app/settings/settings.component.spec.ts | 1 + .../src/app/settings/settings.component.ts | 1 + 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/embedded-inline-playback.md | 1 + .../src/lib/settings-store.service.ts | 2 + .../shared/interfaces/src/lib/ipc-commands.ts | 1 + .../interfaces/src/lib/settings.interface.ts | 1 + 30 files changed, 387 insertions(+), 12 deletions(-) diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index 9040817ff..a13cb57e1 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -15,6 +15,7 @@ jest.mock('../services/store.service', () => ({ MPV_PLAYER_PATH: 'MPV_PLAYER_PATH', MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', VLC_PLAYER_PATH: 'VLC_PLAYER_PATH', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', store: { get: jest.fn(), set: jest.fn(), @@ -30,14 +31,17 @@ import { MPV_PLAYER_PATH, store, VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, } from '../services/store.service'; import { buildExternalPlayerSpawnSpec, + buildVlcEnqueueCommands, isRunningInFlatpak, parseVlcRcPlaybackState, parseVlcRcNumericResponse, resolveExternalPlayerLaunchContext, shouldReuseMpvInstance, + shouldReuseVlcInstance, shouldUseMpvSocketBridge, } from './player.events'; @@ -150,6 +154,12 @@ describe('player.events Flatpak launch helpers', () => { expect(shouldUseMpvSocketBridge(false)).toBe(true); }); + it('disables VLC reuse only in Flatpak', () => { + expect(shouldReuseVlcInstance(true, true)).toBe(false); + expect(shouldReuseVlcInstance(true, false)).toBe(true); + expect(shouldReuseVlcInstance(false, false)).toBe(false); + }); + it('parses numeric VLC RC responses that include the prompt prefix', () => { expect( parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari @@ -215,4 +225,67 @@ describe('player.events external player path settings', () => { '/Applications/mpv.app/Contents/MacOS/mpv' ); }); + + it('persists the VLC reuse-instance preference', () => { + getIpcMainHandler('SET_VLC_REUSE_INSTANCE')({}, true); + + expect(store.set).toHaveBeenCalledWith(VLC_REUSE_INSTANCE, true); + }); +}); + +describe('buildVlcEnqueueCommands', () => { + it('clears the playlist and adds the URL with no extra options', () => { + expect( + buildVlcEnqueueCommands({ url: 'http://stream.example/a.m3u8' }) + ).toEqual(['clear', 'add http://stream.example/a.m3u8']); + }); + + it('attaches per-input HTTP options inline with the add command', () => { + const commands = buildVlcEnqueueCommands({ + url: 'http://stream.example/a.m3u8', + title: 'Channel One', + userAgent: 'Custom/1.0', + referer: 'https://referer.example', + headers: { 'X-Token': 'abc' }, + }); + + expect(commands[0]).toBe('clear'); + expect(commands[1]).toBe( + 'add http://stream.example/a.m3u8 :http-user-agent=Custom/1.0 :http-referrer=https://referer.example :http-header=X-Token: abc :meta-title=Channel One' + ); + }); + + it('falls back to origin when referer is absent', () => { + const commands = buildVlcEnqueueCommands({ + url: 'http://stream.example/a.m3u8', + origin: 'https://origin.example', + }); + + expect(commands[1]).toContain( + ':http-referrer=https://origin.example' + ); + }); + + it('appends a seek command when startTime is provided', () => { + const commands = buildVlcEnqueueCommands({ + url: 'http://stream.example/a.m3u8', + startTime: 42.7, + }); + + expect(commands).toEqual([ + 'clear', + 'add http://stream.example/a.m3u8', + 'seek 42', + ]); + }); + + it('skips empty header values', () => { + const commands = buildVlcEnqueueCommands({ + url: 'http://stream.example/a.m3u8', + headers: { 'X-Empty': ' ', 'X-Real': 'value' }, + }); + + expect(commands[1]).toContain(':http-header=X-Real: value'); + expect(commands[1]).not.toContain('X-Empty'); + }); }); diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 759ae70db..689e8b03f 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -11,6 +11,7 @@ import { MPV_REUSE_INSTANCE, store, VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, } from '../services/store.service'; import { ChildProcess, spawn } from 'child_process'; @@ -31,6 +32,10 @@ let mpvProcess: ChildProcess | null = null; let mpvSocketPath: string | null = null; let positionPollingInterval: NodeJS.Timeout | null = null; +// Keep track of the running VLC process for reuse +let vlcProcess: ChildProcess | null = null; +let vlcRcPort: number | null = null; + interface ExternalPlaybackSnapshot { positionSeconds: number; durationSeconds: number | null; @@ -169,6 +174,96 @@ export function shouldUseMpvSocketBridge( return !isFlatpak; } +export function shouldReuseVlcInstance( + requestedReuseInstance: boolean, + isFlatpak: boolean = isRunningInFlatpak() +): boolean { + return !isFlatpak && requestedReuseInstance; +} + +export function buildVlcEnqueueCommands(options: { + url: string; + title?: string; + userAgent?: string; + referer?: string; + origin?: string; + headers?: Record; + startTime?: number; +}): string[] { + const inputOptions: string[] = []; + + if (options.userAgent) { + inputOptions.push(`:http-user-agent=${options.userAgent}`); + } + if (options.referer) { + inputOptions.push(`:http-referrer=${options.referer}`); + } else if (options.origin) { + inputOptions.push(`:http-referrer=${options.origin}`); + } + Object.entries(options.headers ?? {}).forEach(([name, value]) => { + if (!name || value === undefined || value === null) return; + const trimmedValue = String(value).trim(); + if (!trimmedValue) return; + inputOptions.push(`:http-header=${name}: ${trimmedValue}`); + }); + if (options.title) { + inputOptions.push(`:meta-title=${options.title}`); + } + + const inputLine = + inputOptions.length > 0 + ? `${options.url} ${inputOptions.join(' ')}` + : options.url; + + const commands = ['clear', `add ${inputLine}`]; + + if (options.startTime && Number.isFinite(options.startTime)) { + commands.push(`seek ${Math.floor(options.startTime)}`); + } + + return commands; +} + +function sendVlcRcCommand(port: number, command: string): Promise { + return new Promise((resolve, reject) => { + const client = createConnection({ port, host: '127.0.0.1' }); + let settled = false; + + const finish = (err?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeoutHandle); + if (!client.destroyed) client.destroy(); + err ? reject(err) : resolve(); + }; + + const timeoutHandle = setTimeout( + () => finish(new Error('VLC RC command timed out')), + 2000 + ); + + client.on('connect', () => { + client.write(`${command}\n`); + }); + // The VLC RC interface emits its '> ' prompt after each command finishes. + client.on('data', (chunk) => { + if (chunk.toString().includes('>')) { + finish(); + } + }); + client.on('error', (err) => finish(err)); + }); +} + +async function sendVlcRcCommands( + port: number, + commands: string[] +): Promise { + for (const command of commands) { + await sendVlcRcCommand(port, command); + } +} + function buildPlayerStartError( player: 'MPV' | 'VLC', error: Error, @@ -978,6 +1073,14 @@ ipcMain.handle( getVlcPath({ isFlatpak }), { isFlatpak } ); + const requestedReuseInstance = store.get( + VLC_REUSE_INSTANCE, + false + ); + const reuseInstance = shouldReuseVlcInstance( + requestedReuseInstance, + isFlatpak + ); const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {}; const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders) ? fallbackHeaders @@ -1000,17 +1103,118 @@ ipcMain.handle( mergedHeaders['User-Agent'] ?? mergedHeaders['user-agent'] ?? undefined; - console.log('Opening VLC player with path:', vlcLaunchContext.playerPath); - console.log('VLC launch mode:', vlcLaunchContext.mode); - console.log('URL:', url); - // console.log('User-Agent:', userAgent); - // console.log('Referer:', referer); - // console.log('Origin:', origin); - // console.log('Start Time:', startTime); + console.log('[VLC] Opening player', { + path: vlcLaunchContext.playerPath, + launchMode: vlcLaunchContext.mode, + requestedReuseInstance, + reuseInstance, + stream: maskUrlForLogs(url), + hasUserAgent: Boolean(effectiveUserAgent), + hasReferer: Boolean(effectiveReferer), + hasOrigin: Boolean(effectiveOrigin), + hasContentInfo: Boolean(contentInfo), + startTime: startTime ?? null, + }); - // Get free port for RC interface + // Try to drive an already-running VLC via its RC port instead of + // spawning a new instance. + if ( + reuseInstance && + vlcProcess && + !vlcProcess.killed && + vlcRcPort + ) { + console.log( + 'Reusing existing VLC instance on RC port', + vlcRcPort + ); + try { + const enqueueCommands = buildVlcEnqueueCommands({ + url, + title, + userAgent: effectiveUserAgent, + referer: effectiveReferer, + origin: effectiveOrigin, + headers: mergedHeaders, + startTime, + }); + await sendVlcRcCommands(vlcRcPort, enqueueCommands); + console.log( + 'Successfully loaded new URL in existing VLC instance' + ); + + const reusedRcPort = vlcRcPort; + let lastReusedSnapshot: ExternalPlaybackSnapshot | null = + null; + externalPlayerSessions.attachCloser( + session.id, + async () => { + try { + await sendVlcRcCommand(reusedRcPort, 'stop'); + } catch { + if (vlcProcess && !vlcProcess.killed) { + vlcProcess.kill(); + } + } + } + ); + + if (contentInfo) { + startVlcPositionPolling( + reusedRcPort, + contentInfo, + session.id, + (snapshot) => { + lastReusedSnapshot = snapshot; + }, + () => { + if ( + lastReusedSnapshot && + contentInfo && + externalPlayerSessions.getSession( + session.id + )?.status !== 'closed' + ) { + sendPlaybackPositionUpdate( + session.id, + contentInfo, + lastReusedSnapshot + ); + } + externalPlayerSessions.markClosed(session.id); + } + ); + } else { + stopVlcPositionPolling(); + } + + return ( + externalPlayerSessions.markOpened(session.id) ?? session + ); + } catch (err) { + console.error( + 'Failed to reuse existing VLC, spawning fresh:', + err + ); + // Tracked process is not responsive; clear state and fall + // through to spawn a brand new VLC. + if (vlcProcess && !vlcProcess.killed) { + try { + vlcProcess.kill(); + } catch { + /* ignore */ + } + } + vlcProcess = null; + vlcRcPort = null; + stopVlcPositionPolling(); + } + } + + // Get free port for RC interface (always allocate when reuse is + // enabled so future calls can drive this instance). let rcPort = 0; - if (contentInfo) { + if (contentInfo || reuseInstance) { try { rcPort = await getFreePort(); console.log('Using VLC RC port:', rcPort); @@ -1073,12 +1277,24 @@ ipcMain.handle( vlcLaunchContext, playerArgs ); + const trackProcess = reuseInstance && !isRetry; const proc = spawn(spawnSpec.command, spawnSpec.args, { shell: false, - detached: true, - stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown + detached: !trackProcess, + stdio: trackProcess + ? ['ignore', 'pipe', 'pipe'] + : 'ignore', }); + if (trackProcess && rcPort > 0) { + vlcProcess = proc; + vlcRcPort = rcPort; + console.log( + 'Tracking VLC process for reuse on RC port', + rcPort + ); + } + const markVlcSessionClosed = () => { if ( externalPlayerSessions.getSession(session.id)?.status === @@ -1162,6 +1378,10 @@ ipcMain.handle( proc.on('error', (err) => { console.error('Failed to start VLC player:', err); + if (vlcProcess === proc) { + vlcProcess = null; + vlcRcPort = null; + } if (!isRetry && rcPort > 0) { console.log('Retrying VLC without RC interface...'); // Retry without RC args @@ -1189,6 +1409,10 @@ ipcMain.handle( proc.on('exit', (code) => { console.log(`VLC exited with code ${code}`); + if (vlcProcess === proc) { + vlcProcess = null; + vlcRcPort = null; + } stopVlcPositionPolling(); if ( @@ -1238,7 +1462,9 @@ ipcMain.handle( externalPlayerSessions.markClosed(session.id); }); - proc.unref(); + if (!trackProcess) { + proc.unref(); + } }; spawnVlc(args); @@ -1266,6 +1492,19 @@ ipcMain.handle( } ); +ipcMain.handle('SET_VLC_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { + console.log('... setting vlc reuse instance', reuseInstance); + store.set(VLC_REUSE_INSTANCE, reuseInstance); + + // If disabling reuse, kill the existing tracked process + if (!reuseInstance && vlcProcess && !vlcProcess.killed) { + console.log('Disabling reuse, cleaning up existing VLC process'); + vlcProcess.kill(); + vlcProcess = null; + vlcRcPort = null; + } +}); + ipcMain.handle( CLOSE_EXTERNAL_PLAYER_SESSION, async (_event, sessionId: string) => { diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index 4d50233dc..322d5720f 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron'; import { MPV_REUSE_INSTANCE, store, + VLC_REUSE_INSTANCE, } from '../services/store.service'; import { httpServer } from '../server/http-server'; @@ -19,6 +20,10 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance); } + if (arg.vlcReuseInstance !== undefined) { + store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance); + } + // Handle remote control settings if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) { const enabled = arg.remoteControl ?? store.get('remoteControl', false); diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index 1b026af08..d83b3e607 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -5,12 +5,14 @@ export const WINDOW_BOUNDS = 'WINDOW_BOUNDS'; export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH'; export const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH'; export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE'; +export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE'; export type StoreType = { [WINDOW_BOUNDS]: Electron.Rectangle; [MPV_PLAYER_PATH]: string; [VLC_PLAYER_PATH]: string; [MPV_REUSE_INSTANCE]: boolean; + [VLC_REUSE_INSTANCE]: boolean; }; // Export singleton store instance diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 7792752d1..37a8dc3d2 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -45,6 +45,7 @@ const DEFAULT_SETTINGS: Settings = { mpvPlayerPath: '', mpvReuseInstance: false, vlcPlayerPath: '', + vlcReuseInstance: false, remoteControl: false, remoteControlPort: 8765, downloadFolder: '', diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index b036e6615..a29c1d4d2 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -146,5 +146,17 @@ + +
+
+

{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}

+

+ {{ 'SETTINGS.VLC_REUSE_INSTANCE_DESCRIPTION' | translate }} +

+
+
+ +
+
} diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 35c243c46..7301de1e4 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -77,6 +77,7 @@ const DEFAULT_SETTINGS = { mpvPlayerPath: '', mpvReuseInstance: false, vlcPlayerPath: '', + vlcReuseInstance: false, remoteControl: false, remoteControlPort: 8765, epgUrl: [], diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 4b9ece471..435032340 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -188,6 +188,7 @@ export class SettingsComponent implements OnInit, OnDestroy { mpvPlayerPath: '', mpvReuseInstance: false, vlcPlayerPath: '', + vlcReuseInstance: false, remoteControl: false, remoteControlPort: [ 8765, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 0e0f7c6aa..b8f31ed49 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "مسار VLC", "VLC_PLAYER_PATH_DESCRIPTION": "حدد مسار مشغل VLC في نظامك.", "VLC_PLAYER_PATH": "مسار مشغل VLC", + "VLC_REUSE_INSTANCE_LABEL": "إعادة استخدام نسخة VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "عند التفعيل، سيتم إعادة استخدام نفس نافذة VLC لتشغيل البثوث المختلفة. عند التعطيل، يفتح كل بث في نافذة VLC جديدة.", "REMOTE_CONTROL": "التحكم عن بعد", "REMOTE_CONTROL_DESCRIPTION": "تمكين التحكم عن بعد للتطبيق.", "REMOTE_CONTROL_PORT": "منفذ التحكم عن بعد", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 30080cdd0..aac6dd028 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "مسار VLC", "VLC_PLAYER_PATH_DESCRIPTION": "حدد مسار مشغل VLC في السيستيم ديالك. غادي يستعمل المسار اللي عطيتيه لملف VLC بدل المسار الافتراضي ديال السيستيم ديالنا.", "VLC_PLAYER_PATH": "مسار مشغل VLC", + "VLC_REUSE_INSTANCE_LABEL": "إعادة استخدام نسخة VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "إلى كانت مفعلة، نفس نافذة VLC غادي تستعمل لتشغيل بثوث مختلفة. إلى كانت معطلة، كل بث كيتحل في نافذة VLC جديدة.", "REMOTE_CONTROL": "التحكم عن بعد", "REMOTE_CONTROL_DESCRIPTION": "فعل التحكم عن بعد للتطبيق.", "REMOTE_CONTROL_PORT": "منفذ التحكم عن بعد", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e21912a25..b6d2f679d 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "Шлях да VLC прайгравальніка", "VLC_PLAYER_PATH_DESCRIPTION": "Усталюйце шлях да прайгравальніка VLC у вашай сістэме. \nЁн будзе выкарыстоўваць прадастаўлены шлях да бінарнага файла VLC замест шляху па змаўчанні.", "VLC_PLAYER_PATH": "Шлях да VLC прайгравальніка", + "VLC_REUSE_INSTANCE_LABEL": "Паўторна выкарыстоўваць асобнік VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Калі ўключана, тое ж акно VLC будзе паўторна выкарыстоўвацца для прайгравання розных патокаў. Калі адключана, кожны паток адкрываецца ў новым акне VLC.", "REMOTE_CONTROL": "Аддаленае кіраванне", "REMOTE_CONTROL_DESCRIPTION": "Уключыць аддаленае кіраванне праграмай.", "REMOTE_CONTROL_PORT": "Порт аддаленага кіравання", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index fdf894652..43f17bc7f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "VLC-Pfad", "VLC_PLAYER_PATH_DESCRIPTION": "Legen Sie den Pfad zum VLC-Player in Ihrem System fest. \nEs wird der bereitgestellte Pfad zu einer VLC-Binärdatei anstelle des Standardpfads für unser System verwendet.", "VLC_PLAYER_PATH": "VLC-Playerpfad", + "VLC_REUSE_INSTANCE_LABEL": "VLC-Instanz wiederverwenden", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Wenn aktiviert, wird dasselbe VLC-Fenster zum Abspielen verschiedener Streams wiederverwendet. Wenn deaktiviert, wird jeder Stream in einem neuen VLC-Fenster geöffnet.", "REMOTE_CONTROL": "Fernsteuerung", "REMOTE_CONTROL_DESCRIPTION": "Fernsteuerung der Anwendung aktivieren.", "REMOTE_CONTROL_PORT": "Fernsteuerungsport", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 17515bc63..7e7464aa0 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "διαδρομή VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Ορίστε τη διαδρομή προς το VLC player στο σύστημά σας. Θα χρησιμοποιήσει την παρεχόμενη διαδρομή προς ένα δυαδικό αρχείο VLC αντί για την προεπιλεγμένη διαδρομή για το σύστημά μας.", "VLC_PLAYER_PATH": "διαδρομή VLC player", + "VLC_REUSE_INSTANCE_LABEL": "Επαναχρησιμοποίηση της παρουσίας VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Όταν είναι ενεργοποιημένη, το ίδιο παράθυρο VLC θα επαναχρησιμοποιηθεί για την αναπαραγωγή διαφορετικών ροών. Όταν είναι απενεργοποιημένη, κάθε ροή ανοίγει σε ένα νέο παράθυρο VLC.", "REMOTE_CONTROL": "Τηλεχειριστήριο", "REMOTE_CONTROL_DESCRIPTION": "Ενεργοποιήστε τον τηλεχειρισμό της εφαρμογής.", "REMOTE_CONTROL_PORT": "Θύρα τηλεχειριστηρίου", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index bf4d9f6fe..3b0e3bc14 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "VLC path", "VLC_PLAYER_PATH_DESCRIPTION": "Set path to VLC player in your system. It will use the provided path to a VLC binary instead of the default path for our system.", "VLC_PLAYER_PATH": "VLC player path", + "VLC_REUSE_INSTANCE_LABEL": "Reuse VLC instance", + "VLC_REUSE_INSTANCE_DESCRIPTION": "When enabled, the same VLC window will be reused for playing different streams. When disabled, each stream opens in a new VLC window.", "REMOTE_CONTROL": "Remote control", "REMOTE_CONTROL_DESCRIPTION": "Enable remote control of the application.", "REMOTE_CONTROL_PORT": "Remote control port", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3d72143ec..01f5ddf0c 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "ruta VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Establezca la ruta al reproductor VLC en su sistema. \nUtilizará la ruta proporcionada a un binario VLC en lugar de la ruta predeterminada para nuestro sistema.", "VLC_PLAYER_PATH": "Ruta del reproductor VLC", + "VLC_REUSE_INSTANCE_LABEL": "Reutilizar instancia de VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Cuando esta opción está activada, se reutiliza la misma ventana de VLC para reproducir distintos streams. Cuando está desactivada, cada stream se abre en una nueva ventana de VLC.", "REMOTE_CONTROL": "Control remoto", "REMOTE_CONTROL_DESCRIPTION": "Habilita el control remoto de la aplicación.", "REMOTE_CONTROL_PORT": "Puerto de control remoto", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 617ac3589..ab5591814 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "Chemin VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Définissez le chemin d'accès au lecteur VLC dans votre système. Il utilisera le chemin fourni vers un binaire VLC au lieu du chemin par défaut.", "VLC_PLAYER_PATH": "Chemin du lecteur VLC", + "VLC_REUSE_INSTANCE_LABEL": "Réutiliser l'instance VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Lorsqu'elle est activée, la même fenêtre VLC sera réutilisée pour lire différents flux. Lorsqu'elle est désactivée, chaque flux s'ouvre dans une nouvelle fenêtre VLC.", "REMOTE_CONTROL": "Télécommande", "REMOTE_CONTROL_DESCRIPTION": "Activer la télécommande de l'application.", "REMOTE_CONTROL_PORT": "Port de télécommande", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index edba2a83f..1218d5c3a 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "Percorso VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Imposta il percorso del lettore VLC nel sistema. Verrà usato il percorso fornito per il file exe VLC invece del percorso predefinito per il sistema.", "VLC_PLAYER_PATH": "Percorso lettore VLC", + "VLC_REUSE_INSTANCE_LABEL": "Riutilizza istanza VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Quando attivo, la stessa finestra VLC viene riutilizzata per riprodurre stream diversi. Quando disattivo, ogni stream viene aperto in una nuova finestra VLC.", "REMOTE_CONTROL": "Telecomando", "REMOTE_CONTROL_DESCRIPTION": "Abilita controllo remoto nell'applicazione.", "REMOTE_CONTROL_PORT": "Porta controllo remoto", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 70bc74085..4b4999477 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "VLCのパス", "VLC_PLAYER_PATH_DESCRIPTION": "システム内のVLCプレーヤーのパスを設定します。システムのデフォルトパスの代わりに、指定されたVLCバイナリのパスを使用します。", "VLC_PLAYER_PATH": "VLCプレーヤーのパス", + "VLC_REUSE_INSTANCE_LABEL": "VLCインスタンスを再利用", + "VLC_REUSE_INSTANCE_DESCRIPTION": "有効にすると、異なるストリームの再生に同じVLCウィンドウが再利用されます。無効にすると、各ストリームが新しいVLCウィンドウで開きます。", "REMOTE_CONTROL": "リモートコントロール", "REMOTE_CONTROL_DESCRIPTION": "アプリケーションのリモートコントロールを有効にします。", "REMOTE_CONTROL_PORT": "リモートコントロールポート", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 3807806b9..e682e16d4 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "VLC 경로", "VLC_PLAYER_PATH_DESCRIPTION": "시스템에서 VLC 플레이어의 경로를 설정하십시오. \n시스템의 기본 경로 대신 VLC 바이너리에 제공된 경로를 사용합니다.", "VLC_PLAYER_PATH": "VLC 플레이어 경로", + "VLC_REUSE_INSTANCE_LABEL": "VLC 인스턴스 재사용", + "VLC_REUSE_INSTANCE_DESCRIPTION": "활성화하면 동일한 VLC 창에서 다른 스트림을 재생합니다. 비활성화하면 각 스트림이 새 VLC 창에서 열립니다.", "REMOTE_CONTROL": "원격 제어", "REMOTE_CONTROL_DESCRIPTION": "애플리케이션의 원격 제어를 활성화합니다.", "REMOTE_CONTROL_PORT": "원격 제어 포트", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c754b5b0d..7e4f0b5a8 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "VLC-bestandspad", "VLC_PLAYER_PATH_DESCRIPTION": "Stel het bestandspad in naar de VLC-speler op je systeem. Dit bestandspad wordt gebruikt in plaats van het standaardpad van het systeem.", "VLC_PLAYER_PATH": "VLC-speler bestandspad", + "VLC_REUSE_INSTANCE_LABEL": "VLC-instantie hergebruiken", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Wanneer ingeschakeld, wordt hetzelfde VLC-venster hergebruikt voor het afspelen van verschillende streams. Wanneer uitgeschakeld, wordt elke stream geopend in een nieuw VLC-venster.", "REMOTE_CONTROL": "Afstandsbediening", "REMOTE_CONTROL_DESCRIPTION": "Schakel de afstandsbediening voor de applicatie in.", "REMOTE_CONTROL_PORT": "Afstandsbedieningspoort", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 9c6612cb0..0692acef1 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "Ścieżka VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Ustaw ścieżkę do odtwarzacza VLC w systemie. Będzie używać podanej ścieżki do pliku binarnego VLC zamiast domyślnej ścieżki dla naszego systemu.", "VLC_PLAYER_PATH": "Ścieżka odtwarzacza VLC", + "VLC_REUSE_INSTANCE_LABEL": "Użyj ponownie instancji VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Po włączeniu to samo okno VLC będzie używane do odtwarzania różnych strumieni. Po wyłączeniu każdy strumień otwiera się w nowym oknie VLC.", "REMOTE_CONTROL": "Zdalne sterowanie", "REMOTE_CONTROL_DESCRIPTION": "Włącz zdalne sterowanie aplikacją.", "REMOTE_CONTROL_PORT": "Port zdalnego sterowania", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 845f8df4f..0c8095ea7 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "Caminho do VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Defina o caminho para o reprodutor VLC em seu sistema. Ele usará o caminho fornecido para um binário VLC em vez do caminho padrão para o nosso sistema.", "VLC_PLAYER_PATH": "Caminho do reprodutor VLC", + "VLC_REUSE_INSTANCE_LABEL": "Reutilizar instância do VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Quando ativado, a mesma janela do VLC será reutilizada para reproduzir streams diferentes. Quando desativado, cada stream abre em uma nova janela do VLC.", "REMOTE_CONTROL": "Controle remoto", "REMOTE_CONTROL_DESCRIPTION": "Ativar controle remoto do aplicativo.", "REMOTE_CONTROL_PORT": "Porta de controle remoto", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 5e5ba2f01..f6e23bedd 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "Путь к VLC плееру", "VLC_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю VLC в вашей системе. \nОн будет использовать предоставленный путь к binary файлу VLC вместо пути по умолчанию.", "VLC_PLAYER_PATH": "Путь к VLC плееру", + "VLC_REUSE_INSTANCE_LABEL": "Переиспользовать экземпляр VLC", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Когда включено, одно и то же окно VLC будет использоваться для воспроизведения разных потоков. Когда отключено, каждый поток открывается в новом окне VLC.", "REMOTE_CONTROL": "Удалённое управление", "REMOTE_CONTROL_DESCRIPTION": "Включить удалённое управление приложением.", "REMOTE_CONTROL_PORT": "Порт удалённого управления", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index b561f5331..2bd0dc14d 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "VLC yolu", "VLC_PLAYER_PATH_DESCRIPTION": "Sisteminizdeki VLC oynatıcı yolunu ayarlayın. Sistemimiz için varsayılan yolu kullanmak yerine, sağlanan VLC ikili dosyası yolunu kullanacaktır.", "VLC_PLAYER_PATH": "VLC oynatıcı yolu", + "VLC_REUSE_INSTANCE_LABEL": "VLC örneğini yeniden kullan", + "VLC_REUSE_INSTANCE_DESCRIPTION": "Etkinleştirildiğinde, farklı yayınları oynatmak için aynı VLC penceresi yeniden kullanılır. Devre dışı bırakıldığında, her yayın yeni bir VLC penceresinde açılır.", "REMOTE_CONTROL": "Uzaktan kontrol", "REMOTE_CONTROL_DESCRIPTION": "Uygulamanın uzaktan kontrolünü etkinleştirin.", "REMOTE_CONTROL_PORT": "Uzaktan kontrol portu", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 6852da029..943b53cb3 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "VLC路径", "VLC_PLAYER_PATH_DESCRIPTION": "设置系统中 VLC 播放器的路径。\n它将使用提供的 VLC 二进制文件路径,而不是我们系统的默认路径。", "VLC_PLAYER_PATH": "VLC播放器路径", + "VLC_REUSE_INSTANCE_LABEL": "复用 VLC 实例", + "VLC_REUSE_INSTANCE_DESCRIPTION": "启用后,将复用同一个 VLC 窗口播放不同的视频流。禁用后,每个视频流都会在新的 VLC 窗口中打开。", "REMOTE_CONTROL": "远程控制", "REMOTE_CONTROL_DESCRIPTION": "启用对应用程序的远程控制。", "REMOTE_CONTROL_PORT": "远程控制端口", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 3282fac06..8ffcb68ba 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -323,6 +323,8 @@ "VLC_PLAYER_PATH_LABEL": "VLC 路徑", "VLC_PLAYER_PATH_DESCRIPTION": "設定您系統中 VLC 播放器的路徑。將使用提供的 VLC 二進位檔路徑,而非系統預設路徑。", "VLC_PLAYER_PATH": "VLC 播放器路徑", + "VLC_REUSE_INSTANCE_LABEL": "重複使用 VLC 實體", + "VLC_REUSE_INSTANCE_DESCRIPTION": "啟用後,相同的 VLC 視窗將重複用於播放不同串流。停用時,每個串流會在新的 VLC 視窗中開啟。", "REMOTE_CONTROL": "遠端控制", "REMOTE_CONTROL_DESCRIPTION": "啟用應用程式遠端控制功能。", "REMOTE_CONTROL_PORT": "遠端控制連接埠", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 37d9af89d..2739de7be 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -105,6 +105,7 @@ Current contract: - AppImage, deb/rpm, snap, macOS, and Windows keep the existing direct process spawn flow. - VLC keeps the current external-session flow in Flatpak, including the RC port used for progress polling. - MPV is intentionally reduced in Flatpak: the app does not reuse an existing MPV instance there and does not open the Unix socket bridge used for non-Flatpak progress polling. +- VLC instance reuse is also gated off in Flatpak. Outside Flatpak the user can opt in via the "Reuse VLC instance" setting; the app then keeps a single tracked VLC process and drives subsequent stream loads through its RC interface (`clear` + `add :http-*`) instead of spawning a new window per click. This keeps non-Flatpak behavior unchanged while allowing Flatpak builds to open host-installed external players. diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index b6eb76e23..010551507 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -30,6 +30,7 @@ const DEFAULT_SETTINGS: Settings = { mpvPlayerPath: '', mpvReuseInstance: false, vlcPlayerPath: '', + vlcReuseInstance: false, remoteControl: false, remoteControlPort: 8765, epgUrl: [], @@ -133,6 +134,7 @@ export const SettingsStore = signalStore( mpvPlayerPath: store.mpvPlayerPath(), mpvReuseInstance: store.mpvReuseInstance(), vlcPlayerPath: store.vlcPlayerPath(), + vlcReuseInstance: store.vlcReuseInstance(), remoteControl: store.remoteControl(), remoteControlPort: store.remoteControlPort(), epgUrl: store.epgUrl(), diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 5103feeae..335d4e95a 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -34,6 +34,7 @@ export const SET_MPV_PLAYER_PATH = 'SET_MPV_PLAYER_PATH'; export const SET_MPV_REUSE_INSTANCE = 'SET_MPV_REUSE_INSTANCE'; export const OPEN_VLC_PLAYER = 'OPEN_VLC_PLAYER'; export const SET_VLC_PLAYER_PATH = 'SET_VLC_PLAYER_PATH'; +export const SET_VLC_REUSE_INSTANCE = 'SET_VLC_REUSE_INSTANCE'; export const CLOSE_EXTERNAL_PLAYER_SESSION = 'CLOSE_EXTERNAL_PLAYER_SESSION'; export const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE'; diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 20ac68129..61c713a62 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -38,6 +38,7 @@ export interface Settings { mpvPlayerPath: string; mpvReuseInstance: boolean; vlcPlayerPath: string; + vlcReuseInstance: boolean; remoteControl: boolean; remoteControlPort: number; /** Custom download folder path (uses system Downloads folder if not set) */