diff --git a/.changes/playback-vlc-windows-console.md b/.changes/playback-vlc-windows-console.md new file mode 100644 index 000000000..c29a9b86a --- /dev/null +++ b/.changes/playback-vlc-windows-console.md @@ -0,0 +1,7 @@ +--- +type: fix +area: playback +issues: [871] +--- + +VLC now opens without an extra console window on Windows when playback progress tracking or Reuse VLC instance is enabled. diff --git a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts index f7f50de94..5b7c0f145 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts @@ -47,6 +47,7 @@ import { externalPlayerSessions } from './external-player-runtime'; import { externalPlayerProcessTeardownGate } from './external-player-process'; import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service'; +const originalPlatform = process.platform; const spawnMock = spawn as unknown as jest.Mock; const streamUrl = 'https://example.com/stream.m3u8'; const rcWrites: string[] = []; @@ -136,6 +137,7 @@ describe('vlc-session.service process lifecycle', () => { // Drop any process tracked for reuse so tests stay isolated. shutdownVlcSession(); consoleErrorSpy.mockRestore(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); }); describe('instance reuse', () => { @@ -1219,12 +1221,14 @@ describe('vlc-session.service process lifecycle', () => { }); it('retries without the RC interface when VLC exits with code 1', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); mockStoreValues({ [VLC_PLAYER_PATH]: '/usr/bin/vlc', [VLC_REUSE_INSTANCE]: true, }); const proc = createMockChildProcess(); await openTrackedVlcInstance(proc); + expect(spawnMock.mock.calls[0][1]).toContain('--rc-quiet'); const retryProc = createMockChildProcess(); spawnMock.mockReturnValueOnce(retryProc); @@ -1234,6 +1238,7 @@ describe('vlc-session.service process lifecycle', () => { const retryArgs = spawnMock.mock.calls[1][1] as string[]; expect(retryArgs.join(' ')).not.toContain('--extraintf'); expect(retryArgs.join(' ')).not.toContain('--rc-host'); + expect(retryArgs).not.toContain('--rc-quiet'); // Retry processes are never tracked for reuse. expect(spawnMock.mock.calls[1][2]).toMatchObject({ detached: true, @@ -1262,6 +1267,7 @@ describe('vlc-session.service process lifecycle', () => { }); it('retries without the RC interface after a start error', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); mockStoreValues({ [VLC_PLAYER_PATH]: '/usr/bin/vlc', [VLC_REUSE_INSTANCE]: true, @@ -1272,11 +1278,14 @@ describe('vlc-session.service process lifecycle', () => { const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); await waitForSpawnCallCount(1); + expect(spawnMock.mock.calls[0][1]).toContain('--rc-quiet'); proc.emit('error', new Error('rc unsupported')); await waitForSpawnCallCount(2); const retryArgs = spawnMock.mock.calls[1][1] as string[]; expect(retryArgs.join(' ')).not.toContain('--extraintf'); + expect(retryArgs.join(' ')).not.toContain('--rc-host'); + expect(retryArgs).not.toContain('--rc-quiet'); retryProc.emit('spawn'); const session = await openPromise; expect(session.status).toBe('opened'); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts index 0d7fb8208..e7043479c 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts @@ -39,6 +39,7 @@ import { spawn, type ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import { createServer } from 'net'; import { + VLC_PLAYER_ARGUMENTS, VLC_PLAYER_PATH, VLC_REUSE_INSTANCE, store, @@ -51,6 +52,7 @@ import { shutdownVlcSession, } from './vlc-session.service'; +const originalPlatform = process.platform; const spawnMock = spawn as unknown as jest.Mock; const streamUrl = 'https://example.com/stream.m3u8'; @@ -86,6 +88,8 @@ describe('vlc-session.service helpers and launch args', () => { beforeEach(() => { jest.clearAllMocks(); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); (createServer as unknown as jest.Mock).mockImplementation(() => ({ unref: jest.fn(), @@ -103,7 +107,10 @@ describe('vlc-session.service helpers and launch args', () => { afterEach(() => { // Drop any process tracked for reuse so tests stay isolated. shutdownVlcSession(); + jest.clearAllTimers(); + jest.useRealTimers(); consoleErrorSpy.mockRestore(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); }); describe('buildVlcEnqueueCommands', () => { @@ -177,6 +184,152 @@ describe('vlc-session.service helpers and launch args', () => { }); describe('openVlcPlayer launch args', () => { + it.each(['error', 'exit'])( + 'preserves data containing --rc-quiet during an RC retry (%s)', + async (event) => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: true, + [VLC_PLAYER_ARGUMENTS]: '--fullscreen\n--rc-quiet', + }); + const proc = createMockChildProcess(); + const retryProc = createMockChildProcess(); + spawnMock + .mockReturnValueOnce(proc) + .mockReturnValueOnce(retryProc); + const url = `${streamUrl}?option=--rc-quiet`; + const openPromise = openVlcPlayer({ + title: 'Movie --rc-quiet', + url, + userAgent: 'Agent --rc-quiet', + }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + await openPromise; + proc.emit(event, event === 'exit' ? 1 : new Error('RC failed')); + await waitForSpawnCallCount(2); + + expect(spawnMock.mock.lastCall[1]).toEqual([ + '--fullscreen', + // Custom arguments are preserved; only the managed flag goes. + '--rc-quiet', + ':http-user-agent=Agent --rc-quiet', + url, + ':meta-title=Movie --rc-quiet', + ]); + retryProc.emit('spawn'); + retryProc.emit('exit', 0); + } + ); + + it.each(['win32', 'darwin', 'linux'] as const)( + 'adds quiet mode only for managed Windows RC launches (%s)', + async (platform) => { + Object.defineProperty(process, 'platform', { value: platform }); + for (const reuseInstance of [false, true]) { + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: reuseInstance, + }); + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ + title: 'RC Stream', + url: streamUrl, + // Exercise progress-only and reuse-only RC separately. + ...(!reuseInstance && { + contentInfo: { + playlistId: 'playlist', + contentXtreamId: 1, + contentType: 'vod' as const, + }, + }), + }); + await waitForSpawnCallCount(reuseInstance ? 2 : 1); + proc.emit('spawn'); + await openPromise; + + expect(spawnMock.mock.lastCall[1]).toEqual([ + '--extraintf=rc', + '--rc-host=127.0.0.1:43210', + ...(platform === 'win32' ? ['--rc-quiet'] : []), + streamUrl, + ':meta-title=RC Stream', + ]); + expect(spawnMock.mock.lastCall[2].detached).toBe( + !reuseInstance + ); + proc.emit('exit', 0); + } + } + ); + + it.each(['--rc-quiet', '--no-rc-quiet'])( + 'keeps custom arguments before managed quiet mode (%s)', + async (customQuiet) => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: true, + [VLC_PLAYER_ARGUMENTS]: `--fullscreen\n${customQuiet}`, + }); + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ + title: 'Custom', + url: streamUrl, + }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + await openPromise; + + expect(spawnMock.mock.lastCall[1]).toEqual([ + '--fullscreen', + customQuiet, + '--extraintf=rc', + '--rc-host=127.0.0.1:43210', + '--rc-quiet', + streamUrl, + ':meta-title=Custom', + ]); + proc.emit('exit', 0); + } + ); + + it.each([false, true])( + 'does not add quiet mode without an RC port (reuse: %s)', + async (reuseInstance) => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: reuseInstance, + }); + (createServer as unknown as jest.Mock).mockImplementation( + () => { + throw new Error('No free port'); + } + ); + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ + title: 'No RC', + url: streamUrl, + }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + await openPromise; + + expect(spawnMock.mock.lastCall[1]).toEqual([ + streamUrl, + ':meta-title=No RC', + ]); + expect(createServer).toHaveBeenCalledTimes( + reuseInstance ? 1 : 0 + ); + } + ); + it('spawns a detached VLC process with http options and start time', async () => { const proc = createMockChildProcess(); spawnMock.mockReturnValueOnce(proc); 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 3ec688255..7ca52da0f 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -249,6 +249,10 @@ export async function openVlcPlayer({ if (rcPort > 0) { args.push('--extraintf=rc'); args.push(`--rc-host=127.0.0.1:${rcPort}`); + if (process.platform === 'win32') { + // Keep TCP control without VLC's separate DOS console. + args.push('--rc-quiet'); + } } if (effectiveUserAgent) { @@ -521,7 +525,7 @@ export async function openVlcPlayer({ (arg) => !arg.includes('--extraintf') && !arg.includes('--rc-host') && - !arg.includes('--rc-quiet') + arg !== '--rc-quiet' ); spawnVlc(retryArgs, true); } else { @@ -572,7 +576,7 @@ export async function openVlcPlayer({ (arg) => !arg.includes('--extraintf') && !arg.includes('--rc-host') && - !arg.includes('--rc-quiet') + arg !== '--rc-quiet' ); spawnVlc(retryArgs, true); return; diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 537b89e90..ed317b278 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -867,15 +867,22 @@ path-only setting; extra flags are stored separately as `mpvPlayerArguments` and Argument fields are line-oriented: one non-empty trimmed line becomes one argv entry. IPTVnator prepends those custom entries before its stream-specific runtime -arguments, then keeps the stream URL last. This avoids shell parsing, keeps paths -with spaces safe, and preserves existing settings for users who never configured -extra arguments. +arguments. This avoids shell parsing, keeps paths with spaces safe, and preserves +existing settings for users who never configured extra arguments. The arguments apply only when IPTVnator spawns a new external player process. If MPV or VLC instance reuse is active and an existing process is reused, subsequent streams are loaded through MPV IPC or VLC RC commands and new process arguments are not re-applied until a fresh process starts. +VLC enables its TCP RC interface when content metadata requires progress +tracking or instance reuse is enabled. On Windows, these managed RC launches +also append `--rc-quiet` after custom arguments to suppress VLC's DOS console +while retaining TCP control. macOS/Linux and launches without an allocated RC +port receive no automatic quiet flag. Both launch-error and exit-code-1 retries +remove the app-generated RC flags, including `--rc-quiet`; custom arguments +continue to be prepended unchanged. + ## Electron External Player Ownership External MPV/VLC integration is split across focused main-process modules: