mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
* fix(electron-backend): make test suite and lint host-agnostic across Windows/Linux checkouts Windows checkouts (core.autocrlf=true) had 13 pre-existing jest failures and 5 Windows-only lint errors in electron-backend while Linux CI was green: - embedded-mpv-native-source.spec: normalize CRLF after readFileSync so multi-line source assertions match on autocrlf checkouts - worker-runtime-paths.spec: build expected candidate paths with path.join instead of hardcoded POSIX strings - external-player-launch-context: join darwin-only paths (.app bundle executables, Homebrew Caskroom) with path.posix.join so simulated darwin platforms resolve correctly on win32 hosts (no-op on macOS) - app.spec: build packaged-navigation fixtures with path.resolve + pathToFileURL; file:///tmp/... is not a valid win32 file URL - lint target: quote the eslint glob. The unquoted ** was expanded by the POSIX shell on Linux (shallow match), so CI linted only a subset of files while Windows passed the literal pattern to ESLint and linted the full tree - the hosts checked different file sets - fix the 5 errors full-tree linting surfaces: prefer-const in epg-worker.service and database.worker-connection, no-unsafe-finally in external-player-session-registry and embedded-mpv-native.service (rewritten as catch-swallow with identical semantics, pinned by new regression tests), intentional no-control-regex in the recording filename sanitizer; drop stale unused disable directives - document the quoted-glob convention in CLAUDE.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: mirror the quoted-lint-glob convention into AGENTS.md AGENTS.md already mirrors the neighbouring max-lines/baseline paragraph from CLAUDE.md, and it requires coding conventions to stay in sync between the two files. The quoted-glob rule landed only in CLAUDE.md, so agents bootstrapping from AGENTS.md could reintroduce a host-dependent lint target. Also corrects the wording in both copies: the shallow expansion happens on macOS as well as Linux — /bin/sh has no globstar on either — and the target still exits 0 with a broken glob, which is why this went unnoticed. Adds the file-count check that catches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1198 lines
41 KiB
TypeScript
1198 lines
41 KiB
TypeScript
import { app, dialog, powerSaveBlocker, screen } from 'electron';
|
|
import { spawnSync } from 'child_process';
|
|
import {
|
|
closeSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readFileSync,
|
|
unlinkSync,
|
|
} from 'fs';
|
|
import { createRequire } from 'module';
|
|
import path from 'path';
|
|
import App from '../app';
|
|
import {
|
|
EmbeddedMpvAudioTrack,
|
|
EmbeddedMpvBounds,
|
|
EmbeddedMpvCapabilities,
|
|
EmbeddedMpvRecordingStartOptions,
|
|
EmbeddedMpvRecordingState,
|
|
EmbeddedMpvSession,
|
|
EmbeddedMpvSessionStatus,
|
|
EmbeddedMpvSubtitleTrack,
|
|
EmbeddedMpvEngine,
|
|
EmbeddedMpvFrameSource,
|
|
EmbeddedMpvSupport,
|
|
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
|
|
EMBEDDED_MPV_SESSION_UPDATE,
|
|
ResolvedPortalPlayback,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
|
|
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
|
|
import {
|
|
getFrameCopyRuntimeAvailability,
|
|
getEmbeddedMpvAddonCandidatePaths,
|
|
isFrameCopyRuntimeUsable,
|
|
resolveFrameCopyHelperPath,
|
|
} from './embedded-mpv-frame-copy-platform.util';
|
|
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
|
|
import {
|
|
EMBEDDED_MPV_EXPERIMENT_ENV,
|
|
isEmbeddedMpvFeatureEnabled,
|
|
} from './embedded-mpv-runtime-policy.util';
|
|
|
|
export interface NativeEmbeddedMpvSessionSnapshot {
|
|
status: EmbeddedMpvSessionStatus;
|
|
positionSeconds: number;
|
|
durationSeconds: number | null;
|
|
volume: number;
|
|
streamUrl: string;
|
|
audioTracks?: EmbeddedMpvAudioTrack[];
|
|
selectedAudioTrackId?: number | null;
|
|
subtitleTracks?: EmbeddedMpvSubtitleTrack[];
|
|
selectedSubtitleTrackId?: number | null;
|
|
playbackSpeed?: number;
|
|
aspectOverride?: string;
|
|
videoWidth?: number;
|
|
videoHeight?: number;
|
|
recording?: EmbeddedMpvRecordingState;
|
|
error?: string;
|
|
}
|
|
|
|
export interface NativeEmbeddedMpvAddon {
|
|
isSupported(): boolean;
|
|
createSession(
|
|
windowHandle: Buffer,
|
|
bounds: EmbeddedMpvBounds,
|
|
title?: string,
|
|
initialVolume?: number
|
|
): string;
|
|
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void;
|
|
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void;
|
|
setPaused(sessionId: string, paused: boolean): void;
|
|
seek(sessionId: string, seconds: number): void;
|
|
setVolume(sessionId: string, volume: number): void;
|
|
setAudioTrack(sessionId: string, trackId: number): void;
|
|
setSubtitleTrack?(sessionId: string, trackId: number): void;
|
|
setSpeed?(sessionId: string, speed: number): void;
|
|
setAspect?(sessionId: string, aspect: string): void;
|
|
startRecording?(sessionId: string, targetPath: string): void;
|
|
stopRecording?(sessionId: string): void;
|
|
getSessionSnapshot(
|
|
sessionId: string
|
|
): NativeEmbeddedMpvSessionSnapshot | null;
|
|
disposeSession(sessionId: string): void;
|
|
}
|
|
|
|
interface EmbeddedMpvRuntimeSession {
|
|
id: string;
|
|
title: string;
|
|
streamUrl: string;
|
|
startedAt: string;
|
|
updatedAt: string;
|
|
lastPayloadKey: string;
|
|
lastStatus: EmbeddedMpvSessionStatus | null;
|
|
}
|
|
|
|
const EMBEDDED_MPV_FRAME_COPY_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
|
|
const SUPPORTED_EMBEDDED_MPV_PLATFORMS = new Set<NodeJS.Platform>([
|
|
'darwin',
|
|
'win32',
|
|
'linux',
|
|
]);
|
|
|
|
export class EmbeddedMpvNativeService {
|
|
private addon: NativeEmbeddedMpvAddon | null = null;
|
|
private addonLoadError: Error | null = null;
|
|
private readonly sessions = new Map<string, EmbeddedMpvRuntimeSession>();
|
|
private pollingTimer: NodeJS.Timeout | null = null;
|
|
private readonly pollFailuresLogged = new Set<string>();
|
|
private powerBlockerId: number | null = null;
|
|
private readonly loadAddonModule = createRequire(__filename);
|
|
private cachedLinuxMpvExecutableReason: string | null | undefined;
|
|
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
|
|
|
|
/**
|
|
* Frame-copy engine: helper process + shm ring + renderer canvas.
|
|
* Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux
|
|
* x64 and Windows, opted into with
|
|
* IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of the regular
|
|
* embedded MPV experiment flag.
|
|
*/
|
|
private isFrameCopyEngineRequested(): boolean {
|
|
return ['1', 'true', 'yes', 'on'].includes(
|
|
(process.env[EMBEDDED_MPV_FRAME_COPY_ENV] ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
);
|
|
}
|
|
|
|
private isFrameCopyEngineActive(): boolean {
|
|
// Requires the helper binary too: a stale opt-in (cleaned native
|
|
// build, bad install) must fall back to the native engine instead
|
|
// of leaving embedded MPV unsupported with no way to recover.
|
|
return this.isFrameCopyEngineRequested() && isFrameCopyRuntimeUsable();
|
|
}
|
|
|
|
getActiveEngine(): EmbeddedMpvEngine {
|
|
return this.isFrameCopyEngineActive() ? 'frame-copy' : 'native';
|
|
}
|
|
|
|
isFrameCopyAvailable(): boolean {
|
|
return isFrameCopyRuntimeUsable();
|
|
}
|
|
|
|
private getFrameCopySupportDetails(): Pick<
|
|
EmbeddedMpvSupport,
|
|
'frameCopyAvailable' | 'frameCopyUnavailableReason'
|
|
> {
|
|
const availability = getFrameCopyRuntimeAvailability();
|
|
if (!('reason' in availability)) {
|
|
return { frameCopyAvailable: true };
|
|
}
|
|
return {
|
|
frameCopyAvailable: false,
|
|
frameCopyUnavailableReason: availability.reason,
|
|
};
|
|
}
|
|
|
|
private resolveFrameCopyRuntimeMode(): EmbeddedMpvFrameCopyRuntimeMode | null {
|
|
if (process.platform !== 'linux') {
|
|
return null;
|
|
}
|
|
const availability = getFrameCopyRuntimeAvailability();
|
|
return availability.usable && 'runtimeMode' in availability
|
|
? availability.runtimeMode
|
|
: null;
|
|
}
|
|
|
|
getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null {
|
|
return this.frameCopyAdapter?.getFrameSource(sessionId) ?? null;
|
|
}
|
|
|
|
private getFrameCopyAdapter(): EmbeddedMpvFrameCopyAdapter {
|
|
if (!this.frameCopyAdapter) {
|
|
this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({
|
|
resolveHelperPath: resolveFrameCopyHelperPath,
|
|
resolveRuntimeMode: () => this.resolveFrameCopyRuntimeMode(),
|
|
getScaleFactor: () => this.getMainWindowScaleFactor(),
|
|
onFrameSourceChanged: (sessionId, source) => {
|
|
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
|
|
return;
|
|
}
|
|
App.mainWindow.webContents.send(
|
|
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
|
|
{ sessionId, source }
|
|
);
|
|
},
|
|
});
|
|
}
|
|
return this.frameCopyAdapter;
|
|
}
|
|
|
|
private getMainWindowScaleFactor(): number {
|
|
try {
|
|
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
|
|
return 1;
|
|
}
|
|
return screen.getDisplayMatching(App.mainWindow.getBounds())
|
|
.scaleFactor;
|
|
} catch {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private getMainWindowZoomFactor(): number {
|
|
try {
|
|
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
|
|
return 1;
|
|
}
|
|
return App.mainWindow.webContents.getZoomFactor();
|
|
} catch {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renderer bounds arrive in CSS pixels; the native-view engines position
|
|
* OS windows in physical pixels (win32/linux) or points (macOS), so at
|
|
* page zoom or display scale ≠ 100% the raw values land the video toward
|
|
* the window's top-left corner at a fraction of its size (#1145). The
|
|
* frame-copy engine must bypass this: it paints into a DOM canvas laid
|
|
* out in CSS pixels, and its adapter already applies the display scale
|
|
* to the render size itself.
|
|
*/
|
|
private scaleBoundsForNativeView(
|
|
bounds: EmbeddedMpvBounds
|
|
): EmbeddedMpvBounds {
|
|
return toNativeViewBounds(bounds, {
|
|
platform: process.platform,
|
|
zoomFactor: this.getMainWindowZoomFactor(),
|
|
displayScaleFactor: this.getMainWindowScaleFactor(),
|
|
});
|
|
}
|
|
|
|
private detectCapabilities(): EmbeddedMpvCapabilities {
|
|
if (this.isFrameCopyEngineActive()) {
|
|
return {
|
|
subtitles: true,
|
|
playbackSpeed: true,
|
|
aspectOverride: true,
|
|
screenshot: false,
|
|
recording: true,
|
|
};
|
|
}
|
|
const addon = this.addon;
|
|
return {
|
|
subtitles: typeof addon?.setSubtitleTrack === 'function',
|
|
playbackSpeed: typeof addon?.setSpeed === 'function',
|
|
aspectOverride: typeof addon?.setAspect === 'function',
|
|
screenshot: false,
|
|
recording:
|
|
typeof addon?.startRecording === 'function' &&
|
|
typeof addon?.stopRecording === 'function',
|
|
};
|
|
}
|
|
|
|
getSupport(): EmbeddedMpvSupport {
|
|
if (!SUPPORTED_EMBEDDED_MPV_PLATFORMS.has(process.platform)) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: 'Embedded MPV is currently available on macOS, Windows, and Linux only.',
|
|
};
|
|
}
|
|
|
|
// The frame-copy engine renders offscreen (headless EGL on Linux,
|
|
// WGL on Windows) into a renderer canvas: the Linux X11/Xwayland and
|
|
// system-mpv requirements below only bind the native --wid engine. Both
|
|
// native-engine failure returns still advertise frameCopyAvailable
|
|
// so the Settings toggle stays reachable — otherwise the states the
|
|
// frame-copy engine exists to fix would hide the way to enable it.
|
|
if (
|
|
this.isUnsupportedLinuxDisplayServer() &&
|
|
!this.isFrameCopyEngineActive()
|
|
) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
|
|
...this.getFrameCopySupportDetails(),
|
|
};
|
|
}
|
|
|
|
if (!isEmbeddedMpvFeatureEnabled()) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: `Embedded MPV is an experimental desktop player. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable it for local development builds, or use a packaged build with the bundled runtime.`,
|
|
};
|
|
}
|
|
|
|
// A requested-but-unavailable frame-copy engine (wrong platform or
|
|
// missing helper) intentionally falls through to the native path so
|
|
// embedded MPV keeps working and Settings can clear the opt-in.
|
|
if (this.isFrameCopyEngineActive()) {
|
|
return {
|
|
supported: true,
|
|
platform: process.platform,
|
|
engine: 'frame-copy',
|
|
...this.getFrameCopySupportDetails(),
|
|
capabilities: this.detectCapabilities(),
|
|
};
|
|
}
|
|
|
|
const missingLinuxMpvExecutableReason =
|
|
this.getMissingLinuxMpvExecutableReason();
|
|
if (missingLinuxMpvExecutableReason) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: missingLinuxMpvExecutableReason,
|
|
...this.getFrameCopySupportDetails(),
|
|
};
|
|
}
|
|
|
|
if (this.addon) {
|
|
try {
|
|
if (!this.addon.isSupported()) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: 'The native embedded MPV addon reported itself as unsupported.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
supported: true,
|
|
platform: process.platform,
|
|
engine: this.getActiveEngine(),
|
|
...this.getFrameCopySupportDetails(),
|
|
capabilities: this.detectCapabilities(),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason:
|
|
error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (this.addonLoadError) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: this.addonLoadError.message,
|
|
};
|
|
}
|
|
|
|
const candidatePaths = this.getAddonCandidatePaths();
|
|
const existingCandidatePath = candidatePaths.find((candidatePath) =>
|
|
existsSync(candidatePath)
|
|
);
|
|
|
|
if (!existingCandidatePath) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason:
|
|
this.readUnavailableReason(candidatePaths) ??
|
|
[
|
|
'Unable to locate the embedded MPV native addon.',
|
|
...candidatePaths.map(
|
|
(candidatePath) => `- ${candidatePath}`
|
|
),
|
|
].join('\n'),
|
|
};
|
|
}
|
|
|
|
const missingRuntimeReason = this.getMissingRuntimeReason(
|
|
existingCandidatePath
|
|
);
|
|
if (missingRuntimeReason) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: missingRuntimeReason,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const addon = this.getAddon();
|
|
if (!addon.isSupported()) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: 'The native embedded MPV addon reported itself as unsupported.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
supported: true,
|
|
platform: process.platform,
|
|
engine: this.getActiveEngine(),
|
|
...this.getFrameCopySupportDetails(),
|
|
capabilities: this.detectCapabilities(),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
prepareAddon(): EmbeddedMpvSupport {
|
|
const support = this.getSupport();
|
|
if (!support.supported) {
|
|
return support;
|
|
}
|
|
|
|
try {
|
|
const addon = this.getAddon();
|
|
if (!addon.isSupported()) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: 'The native embedded MPV addon reported itself as unsupported.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
supported: true,
|
|
platform: process.platform,
|
|
engine: this.getActiveEngine(),
|
|
...this.getFrameCopySupportDetails(),
|
|
capabilities: this.detectCapabilities(),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
supported: false,
|
|
platform: process.platform,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
createSession(
|
|
bounds: EmbeddedMpvBounds,
|
|
title = '',
|
|
initialVolume = 1
|
|
): EmbeddedMpvSession {
|
|
this.assertEmbeddedMpvEnabled();
|
|
const addon = this.getAddon();
|
|
// The frame-copy adapter ignores the native window handle (frames go
|
|
// through shm to a DOM canvas), so skip resolving it — under native
|
|
// Wayland the handle assertion would reject an engine that does not
|
|
// embed into the window at all. Derive the skip from the dispatched
|
|
// addon rather than re-evaluating the engine gate, so the two
|
|
// decisions cannot disagree.
|
|
const usesFrameCopyAddon =
|
|
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
|
|
const windowHandle = usesFrameCopyAddon
|
|
? Buffer.alloc(0)
|
|
: this.getMainWindowHandle();
|
|
const startedAt = new Date().toISOString();
|
|
const sessionId = addon.createSession(
|
|
windowHandle,
|
|
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds),
|
|
title,
|
|
initialVolume
|
|
);
|
|
|
|
this.sessions.set(sessionId, {
|
|
id: sessionId,
|
|
title,
|
|
streamUrl: '',
|
|
startedAt,
|
|
updatedAt: startedAt,
|
|
lastPayloadKey: '',
|
|
lastStatus: null,
|
|
});
|
|
|
|
this.ensurePolling();
|
|
this.ensureRendererLifecycleWatch();
|
|
return (
|
|
this.refreshSession(sessionId) ?? {
|
|
id: sessionId,
|
|
title,
|
|
streamUrl: '',
|
|
status: 'idle',
|
|
positionSeconds: 0,
|
|
durationSeconds: null,
|
|
volume: 1,
|
|
audioTracks: [],
|
|
selectedAudioTrackId: null,
|
|
subtitleTracks: [],
|
|
selectedSubtitleTrackId: null,
|
|
playbackSpeed: 1,
|
|
aspectOverride: 'no',
|
|
recording: { active: false },
|
|
startedAt,
|
|
updatedAt: startedAt,
|
|
}
|
|
);
|
|
}
|
|
|
|
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
|
|
this.assertEmbeddedMpvEnabled();
|
|
const addon = this.getAddon();
|
|
const session = this.getRuntimeSession(sessionId);
|
|
session.title = playback.title ?? session.title;
|
|
session.streamUrl = playback.streamUrl ?? session.streamUrl;
|
|
session.updatedAt = new Date().toISOString();
|
|
addon.loadPlayback(sessionId, playback);
|
|
this.refreshSession(sessionId);
|
|
}
|
|
|
|
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
|
|
this.assertEmbeddedMpvEnabled();
|
|
const addon = this.getAddon();
|
|
const usesFrameCopyAddon =
|
|
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
|
|
addon.setBounds(
|
|
sessionId,
|
|
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds)
|
|
);
|
|
}
|
|
|
|
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
this.getAddon().setPaused(sessionId, paused);
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
seek(sessionId: string, seconds: number): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
this.getAddon().seek(sessionId, seconds);
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
this.getAddon().setVolume(sessionId, volume);
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
setAudioTrack(
|
|
sessionId: string,
|
|
trackId: number
|
|
): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
this.getAddon().setAudioTrack(sessionId, trackId);
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
setSubtitleTrack(
|
|
sessionId: string,
|
|
trackId: number
|
|
): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
const addon = this.getAddon();
|
|
if (typeof addon.setSubtitleTrack !== 'function') {
|
|
throw new Error(
|
|
'Embedded MPV addon does not support subtitle tracks. Rebuild the native addon to enable this feature.'
|
|
);
|
|
}
|
|
addon.setSubtitleTrack(sessionId, trackId);
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
setSpeed(sessionId: string, speed: number): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
const addon = this.getAddon();
|
|
if (typeof addon.setSpeed !== 'function') {
|
|
throw new Error(
|
|
'Embedded MPV addon does not support playback speed. Rebuild the native addon to enable this feature.'
|
|
);
|
|
}
|
|
addon.setSpeed(sessionId, speed);
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
setAspect(sessionId: string, aspect: string): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
const addon = this.getAddon();
|
|
if (typeof addon.setAspect !== 'function') {
|
|
throw new Error(
|
|
'Embedded MPV addon does not support aspect override. Rebuild the native addon to enable this feature.'
|
|
);
|
|
}
|
|
addon.setAspect(sessionId, aspect);
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
startRecording(
|
|
sessionId: string,
|
|
options: EmbeddedMpvRecordingStartOptions = {}
|
|
): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
const addon = this.getAddon();
|
|
if (
|
|
typeof addon.startRecording !== 'function' ||
|
|
typeof addon.stopRecording !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'Embedded MPV addon does not support stream recording. Rebuild the native addon to enable this feature.'
|
|
);
|
|
}
|
|
|
|
const session = this.getRuntimeSession(sessionId);
|
|
const directory =
|
|
options.directory?.trim() || this.getDefaultRecordingFolder();
|
|
mkdirSync(directory, { recursive: true });
|
|
|
|
const targetPath = this.reserveRecordingTargetPath(
|
|
directory,
|
|
options.title || session.title || 'IPTVnator recording'
|
|
);
|
|
try {
|
|
addon.startRecording(sessionId, targetPath);
|
|
} catch (error) {
|
|
this.releaseReservedRecordingTargetPath(targetPath);
|
|
throw error;
|
|
}
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
stopRecording(sessionId: string): EmbeddedMpvSession | null {
|
|
this.assertEmbeddedMpvEnabled();
|
|
const addon = this.getAddon();
|
|
if (typeof addon.stopRecording !== 'function') {
|
|
throw new Error(
|
|
'Embedded MPV addon does not support stream recording. Rebuild the native addon to enable this feature.'
|
|
);
|
|
}
|
|
addon.stopRecording(sessionId);
|
|
return this.refreshSession(sessionId);
|
|
}
|
|
|
|
getDefaultRecordingFolder(): string {
|
|
return app.getPath('downloads');
|
|
}
|
|
|
|
async selectRecordingFolder(): Promise<string | null> {
|
|
const result = await dialog.showOpenDialog({
|
|
properties: ['openDirectory', 'createDirectory'],
|
|
title: 'Select Recording Folder',
|
|
defaultPath: this.getDefaultRecordingFolder(),
|
|
});
|
|
|
|
if (result.canceled || result.filePaths.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return result.filePaths[0];
|
|
}
|
|
|
|
disposeSession(sessionId: string): EmbeddedMpvSession | null {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
let lastRecording: EmbeddedMpvRecordingState | undefined;
|
|
try {
|
|
const addon = this.getAddon();
|
|
try {
|
|
lastRecording = addon.getSessionSnapshot(sessionId)?.recording;
|
|
} catch {
|
|
lastRecording = undefined;
|
|
}
|
|
addon.disposeSession(sessionId);
|
|
} catch {
|
|
// Native dispose failures must not block registry cleanup or the
|
|
// closed-session broadcast below.
|
|
}
|
|
|
|
this.sessions.delete(sessionId);
|
|
this.pollFailuresLogged.delete(sessionId);
|
|
const payload: EmbeddedMpvSession = {
|
|
id: session.id,
|
|
title: session.title,
|
|
streamUrl: session.streamUrl,
|
|
status: 'closed',
|
|
positionSeconds: 0,
|
|
durationSeconds: null,
|
|
volume: 1,
|
|
audioTracks: [],
|
|
selectedAudioTrackId: null,
|
|
subtitleTracks: [],
|
|
selectedSubtitleTrackId: null,
|
|
playbackSpeed: 1,
|
|
aspectOverride: 'no',
|
|
recording: this.createClosedRecordingState(lastRecording),
|
|
startedAt: session.startedAt,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
this.sendSessionUpdate(payload);
|
|
this.stopPollingIfIdle();
|
|
this.updatePowerBlocker();
|
|
return payload;
|
|
}
|
|
|
|
shutdown(): void {
|
|
const sessionIds = [...this.sessions.keys()];
|
|
sessionIds.forEach((sessionId) => this.disposeSession(sessionId));
|
|
if (this.pollingTimer) {
|
|
clearInterval(this.pollingTimer);
|
|
this.pollingTimer = null;
|
|
}
|
|
this.updatePowerBlocker();
|
|
}
|
|
|
|
private ensurePolling(): void {
|
|
if (this.pollingTimer) {
|
|
return;
|
|
}
|
|
|
|
this.pollingTimer = setInterval(() => {
|
|
[...this.sessions.keys()].forEach((sessionId) => {
|
|
// An addon-side throw must not escape the interval callback:
|
|
// it would surface as an uncaughtException in the main
|
|
// process every 500 ms while the timer keeps running. Log
|
|
// each session's first failure only — tracked per session so
|
|
// a healthy session in the same tick cannot reset the
|
|
// suppression for a failing one.
|
|
try {
|
|
this.refreshSession(sessionId);
|
|
this.pollFailuresLogged.delete(sessionId);
|
|
} catch (error) {
|
|
if (!this.pollFailuresLogged.has(sessionId)) {
|
|
this.pollFailuresLogged.add(sessionId);
|
|
console.error(
|
|
`[Embedded MPV] Polling session "${sessionId}" failed:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}, 500);
|
|
}
|
|
|
|
/**
|
|
* Sessions are torn down by the renderer's Angular lifecycle, which
|
|
* never runs when the renderer crashes or hard-reloads (dev-server HMR,
|
|
* Cmd+R). Without this watch, native mpv handles or frame-copy helper
|
|
* processes leak until app shutdown.
|
|
*/
|
|
private rendererWatchInstalled = false;
|
|
|
|
private ensureRendererLifecycleWatch(): void {
|
|
if (
|
|
this.rendererWatchInstalled ||
|
|
!App.mainWindow ||
|
|
App.mainWindow.isDestroyed()
|
|
) {
|
|
return;
|
|
}
|
|
this.rendererWatchInstalled = true;
|
|
|
|
const disposeAll = (reason: string) => {
|
|
if (this.sessions.size === 0) {
|
|
return;
|
|
}
|
|
console.warn(
|
|
`[Embedded MPV] Disposing ${this.sessions.size} session(s): renderer ${reason}`
|
|
);
|
|
[...this.sessions.keys()].forEach((sessionId) => {
|
|
try {
|
|
this.disposeSession(sessionId);
|
|
} catch {
|
|
// best-effort reaping; polling cleanup handles the rest
|
|
}
|
|
});
|
|
};
|
|
|
|
App.mainWindow.webContents.on(
|
|
'render-process-gone',
|
|
(_event, details) => disposeAll(`process gone (${details.reason})`)
|
|
);
|
|
// Full navigations/reloads only — in-app Angular routing emits
|
|
// did-navigate-in-page and must not kill the active session.
|
|
App.mainWindow.webContents.on('did-navigate', () =>
|
|
disposeAll('reloaded')
|
|
);
|
|
}
|
|
|
|
private stopPollingIfIdle(): void {
|
|
if (this.sessions.size > 0 || !this.pollingTimer) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(this.pollingTimer);
|
|
this.pollingTimer = null;
|
|
}
|
|
|
|
private refreshSession(sessionId: string): EmbeddedMpvSession | null {
|
|
const addon = this.getAddon();
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
const snapshot = addon.getSessionSnapshot(sessionId);
|
|
if (!snapshot) {
|
|
return null;
|
|
}
|
|
|
|
const payload: EmbeddedMpvSession = {
|
|
id: session.id,
|
|
title: session.title,
|
|
streamUrl: snapshot.streamUrl || session.streamUrl,
|
|
status: snapshot.status,
|
|
positionSeconds: Math.max(0, Math.floor(snapshot.positionSeconds)),
|
|
durationSeconds:
|
|
typeof snapshot.durationSeconds === 'number'
|
|
? Math.max(0, Math.floor(snapshot.durationSeconds))
|
|
: null,
|
|
volume: typeof snapshot.volume === 'number' ? snapshot.volume : 1,
|
|
audioTracks: Array.isArray(snapshot.audioTracks)
|
|
? snapshot.audioTracks
|
|
: [],
|
|
selectedAudioTrackId:
|
|
typeof snapshot.selectedAudioTrackId === 'number'
|
|
? snapshot.selectedAudioTrackId
|
|
: null,
|
|
subtitleTracks: Array.isArray(snapshot.subtitleTracks)
|
|
? snapshot.subtitleTracks
|
|
: [],
|
|
selectedSubtitleTrackId:
|
|
typeof snapshot.selectedSubtitleTrackId === 'number'
|
|
? snapshot.selectedSubtitleTrackId
|
|
: null,
|
|
playbackSpeed:
|
|
typeof snapshot.playbackSpeed === 'number'
|
|
? snapshot.playbackSpeed
|
|
: 1,
|
|
aspectOverride:
|
|
typeof snapshot.aspectOverride === 'string'
|
|
? snapshot.aspectOverride
|
|
: 'no',
|
|
...(typeof snapshot.videoWidth === 'number' &&
|
|
typeof snapshot.videoHeight === 'number'
|
|
? {
|
|
videoWidth: snapshot.videoWidth,
|
|
videoHeight: snapshot.videoHeight,
|
|
}
|
|
: {}),
|
|
recording: snapshot.recording ?? { active: false },
|
|
startedAt: session.startedAt,
|
|
updatedAt: new Date().toISOString(),
|
|
...(snapshot.error ? { error: snapshot.error } : {}),
|
|
};
|
|
|
|
session.streamUrl = payload.streamUrl;
|
|
session.updatedAt = payload.updatedAt;
|
|
session.lastStatus = payload.status;
|
|
// The refresh timestamp must not participate in the diff key,
|
|
// otherwise every poll tick looks like a change and the renderer
|
|
// receives an IPC update every 500 ms even while paused.
|
|
const nextPayloadKey = JSON.stringify({ ...payload, updatedAt: '' });
|
|
if (session.lastPayloadKey !== nextPayloadKey) {
|
|
session.lastPayloadKey = nextPayloadKey;
|
|
this.sendSessionUpdate(payload);
|
|
}
|
|
|
|
this.updatePowerBlocker();
|
|
return payload;
|
|
}
|
|
|
|
private hasPlayingSession(): boolean {
|
|
for (const session of this.sessions.values()) {
|
|
if (session.lastStatus === 'playing') {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private updatePowerBlocker(): void {
|
|
const shouldBlock = this.hasPlayingSession();
|
|
|
|
if (shouldBlock && this.powerBlockerId === null) {
|
|
try {
|
|
this.powerBlockerId = powerSaveBlocker.start(
|
|
'prevent-display-sleep'
|
|
);
|
|
} catch {
|
|
this.powerBlockerId = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!shouldBlock && this.powerBlockerId !== null) {
|
|
const blockerId = this.powerBlockerId;
|
|
this.powerBlockerId = null;
|
|
try {
|
|
if (powerSaveBlocker.isStarted(blockerId)) {
|
|
powerSaveBlocker.stop(blockerId);
|
|
}
|
|
} catch {
|
|
// ignore — assertion will be cleaned up when the process exits
|
|
}
|
|
}
|
|
}
|
|
|
|
private sendSessionUpdate(session: EmbeddedMpvSession): void {
|
|
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
App.mainWindow.webContents.send(EMBEDDED_MPV_SESSION_UPDATE, session);
|
|
}
|
|
|
|
private getRuntimeSession(sessionId: string): EmbeddedMpvRuntimeSession {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
throw new Error(
|
|
`Embedded MPV session "${sessionId}" was not found.`
|
|
);
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
private getMainWindowHandle(): Buffer {
|
|
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
|
|
throw new Error('The Electron main window is not available.');
|
|
}
|
|
|
|
const windowHandle = App.mainWindow.getNativeWindowHandle();
|
|
if (this.isInvalidLinuxWaylandWindowHandle(windowHandle)) {
|
|
throw new Error(
|
|
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland. Native Wayland embedding is not supported yet. Start IPTVnator with --ozone-platform=x11.'
|
|
);
|
|
}
|
|
|
|
return windowHandle;
|
|
}
|
|
|
|
private reserveRecordingTargetPath(
|
|
directory: string,
|
|
title: string
|
|
): string {
|
|
const baseName = this.sanitizeRecordingFileName(title);
|
|
const timestamp = this.formatRecordingTimestamp(new Date());
|
|
let candidate = path.join(directory, `${baseName}-${timestamp}.ts`);
|
|
let suffix = 2;
|
|
|
|
while (true) {
|
|
try {
|
|
const fd = openSync(candidate, 'wx');
|
|
closeSync(fd);
|
|
return candidate;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
candidate = path.join(
|
|
directory,
|
|
`${baseName}-${timestamp}-${suffix}.ts`
|
|
);
|
|
suffix += 1;
|
|
continue;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
private releaseReservedRecordingTargetPath(targetPath: string): void {
|
|
try {
|
|
unlinkSync(targetPath);
|
|
} catch {
|
|
// Ignore cleanup failures; the start error is more useful.
|
|
}
|
|
}
|
|
|
|
private createClosedRecordingState(
|
|
recording?: EmbeddedMpvRecordingState
|
|
): EmbeddedMpvRecordingState {
|
|
return {
|
|
active: false,
|
|
...(recording?.targetPath
|
|
? { targetPath: recording.targetPath }
|
|
: {}),
|
|
...(recording?.error ? { error: recording.error } : {}),
|
|
};
|
|
}
|
|
|
|
private sanitizeRecordingFileName(title: string): string {
|
|
const normalized = title
|
|
// Control characters are invalid in file names on Windows; the
|
|
// range is matched intentionally.
|
|
// eslint-disable-next-line no-control-regex
|
|
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return (normalized || 'IPTVnator recording').slice(0, 120);
|
|
}
|
|
|
|
private formatRecordingTimestamp(date: Date): string {
|
|
const parts = [
|
|
date.getFullYear(),
|
|
date.getMonth() + 1,
|
|
date.getDate(),
|
|
date.getHours(),
|
|
date.getMinutes(),
|
|
date.getSeconds(),
|
|
].map((part) => String(part).padStart(2, '0'));
|
|
|
|
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
|
|
}
|
|
|
|
private assertEmbeddedMpvEnabled(): void {
|
|
if (!isEmbeddedMpvFeatureEnabled()) {
|
|
throw new Error(
|
|
`Embedded MPV is disabled. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable the local desktop harness, or use a packaged build with the bundled runtime.`
|
|
);
|
|
}
|
|
}
|
|
|
|
private isUnsupportedLinuxDisplayServer(): boolean {
|
|
if (process.platform !== 'linux' || !process.env.WAYLAND_DISPLAY) {
|
|
return false;
|
|
}
|
|
|
|
return !process.env.DISPLAY || !this.isLinuxX11OzoneRequested();
|
|
}
|
|
|
|
private isLinuxX11OzoneRequested(): boolean {
|
|
return (
|
|
app.commandLine.getSwitchValue('ozone-platform').toLowerCase() ===
|
|
'x11'
|
|
);
|
|
}
|
|
|
|
private getMissingLinuxMpvExecutableReason(): string | null {
|
|
if (process.platform !== 'linux') {
|
|
return null;
|
|
}
|
|
|
|
if (this.cachedLinuxMpvExecutableReason !== undefined) {
|
|
return this.cachedLinuxMpvExecutableReason;
|
|
}
|
|
|
|
const result = spawnSync('mpv', ['--version'], {
|
|
stdio: 'ignore',
|
|
timeout: 3000,
|
|
});
|
|
this.cachedLinuxMpvExecutableReason =
|
|
result.status === 0 ? null : this.getLinuxMpvMissingMessage();
|
|
return this.cachedLinuxMpvExecutableReason;
|
|
}
|
|
|
|
private getLinuxMpvMissingMessage(): string {
|
|
// Flatpak/Snap sandboxes cannot see a host-installed mpv, so telling
|
|
// the user to install it would send them down a dead end.
|
|
if (process.env.FLATPAK_ID || process.env.SNAP) {
|
|
return 'Embedded MPV is not available in sandboxed Flatpak/Snap packages because they cannot access a system mpv executable. Use the built-in player, or install the .deb/.rpm/AppImage package to enable Embedded MPV.';
|
|
}
|
|
|
|
return 'Embedded MPV on Linux requires the mpv executable on PATH. Install the mpv package for your distribution and restart IPTVnator.';
|
|
}
|
|
|
|
private isInvalidLinuxWaylandWindowHandle(windowHandle: Buffer): boolean {
|
|
if (
|
|
process.platform !== 'linux' ||
|
|
!process.env.WAYLAND_DISPLAY ||
|
|
!process.env.DISPLAY
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (windowHandle.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const windowHandleValue =
|
|
windowHandle.length >= 4
|
|
? windowHandle.readUInt32LE(0)
|
|
: windowHandle.readUIntLE(0, windowHandle.length);
|
|
return windowHandleValue <= 1;
|
|
}
|
|
|
|
private getAddon(): NativeEmbeddedMpvAddon {
|
|
if (this.isFrameCopyEngineActive()) {
|
|
return this.getFrameCopyAdapter();
|
|
}
|
|
|
|
if (this.addon) {
|
|
return this.addon;
|
|
}
|
|
|
|
if (this.addonLoadError) {
|
|
throw this.addonLoadError;
|
|
}
|
|
|
|
const candidatePaths = this.getAddonCandidatePaths();
|
|
|
|
const existingCandidatePaths = candidatePaths.filter((candidatePath) =>
|
|
existsSync(candidatePath)
|
|
);
|
|
|
|
if (existingCandidatePaths.length === 0) {
|
|
this.addonLoadError = new Error(
|
|
[
|
|
'Unable to locate the embedded MPV native addon.',
|
|
...candidatePaths.map(
|
|
(candidatePath) => `- ${candidatePath}`
|
|
),
|
|
].join('\n')
|
|
);
|
|
throw this.addonLoadError;
|
|
}
|
|
|
|
const loadErrors: string[] = [];
|
|
for (const candidatePath of existingCandidatePaths) {
|
|
try {
|
|
this.addon = this.loadAddonModule(
|
|
candidatePath
|
|
) as NativeEmbeddedMpvAddon;
|
|
return this.addon;
|
|
} catch (error) {
|
|
loadErrors.push(
|
|
`${candidatePath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
|
|
this.addonLoadError = new Error(
|
|
[
|
|
'Unable to load the embedded MPV native addon.',
|
|
...loadErrors.map((error) => `- ${error}`),
|
|
].join('\n')
|
|
);
|
|
throw this.addonLoadError;
|
|
}
|
|
|
|
private getAddonCandidatePaths(): string[] {
|
|
return getEmbeddedMpvAddonCandidatePaths();
|
|
}
|
|
|
|
private readUnavailableReason(candidatePaths: string[]): string | null {
|
|
for (const candidatePath of candidatePaths) {
|
|
const unavailablePath = path.join(
|
|
path.dirname(candidatePath),
|
|
'embedded-mpv-unavailable.txt'
|
|
);
|
|
|
|
if (existsSync(unavailablePath)) {
|
|
return readFileSync(unavailablePath, 'utf8').trim();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private getMissingRuntimeReason(addonPath: string): string | null {
|
|
const nativeDir = path.dirname(addonPath);
|
|
const runtimeCandidates = this.getRuntimeLibraryCandidates(nativeDir);
|
|
|
|
if (runtimeCandidates.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (!runtimeCandidates.some((candidate) => existsSync(candidate))) {
|
|
return [
|
|
'Embedded MPV runtime is incomplete. Missing one of:',
|
|
...runtimeCandidates.map((candidate) => `- ${candidate}`),
|
|
].join('\n');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private getRuntimeLibraryCandidates(nativeDir: string): string[] {
|
|
switch (process.platform) {
|
|
case 'darwin':
|
|
return [
|
|
path.join(nativeDir, 'lib', 'libmpv.2.dylib'),
|
|
path.join(nativeDir, 'lib', 'libmpv.dylib'),
|
|
];
|
|
case 'win32':
|
|
return [
|
|
path.join(nativeDir, 'lib', 'mpv-2.dll'),
|
|
path.join(nativeDir, 'lib', 'libmpv-2.dll'),
|
|
path.join(nativeDir, 'mpv-2.dll'),
|
|
path.join(nativeDir, 'libmpv-2.dll'),
|
|
path.join(nativeDir, 'lib', 'mpv.dll'),
|
|
path.join(nativeDir, 'lib', 'libmpv.dll'),
|
|
path.join(nativeDir, 'mpv.dll'),
|
|
path.join(nativeDir, 'libmpv.dll'),
|
|
];
|
|
case 'linux':
|
|
return [];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
export const embeddedMpvNativeService = new EmbeddedMpvNativeService();
|