import { spawn, type ChildProcess } from 'node:child_process'; import { Socket } from 'node:net'; import { EventEmitter } from 'node:events'; import path from 'node:path'; import { app } from 'electron'; export class QzpController extends EventEmitter { private process: ChildProcess | null = null; private socket: Socket | null = null; private readonly ipcPath: string; private messageBuffer = ''; private connectTimer: ReturnType | null = null; private restartTimer: ReturnType | null = null; private pendingCommands: any[][] = []; private currentUrl: string | null = null; private lastKnownTime = 0; private desiredPause = true; private volume = 50; private isConnected = false; private isRestarting = false; private isShuttingDown = false; private readonly connectRetryDelay = 250; private readonly maxConnectRetries = 20; private readonly maxPendingCommands = 50; private readonly restartDelay = 150; constructor(ipcPath?: string) { super(); this.ipcPath = ipcPath || this.getIpcPath(); } private getIpcPath(): string { if (process.platform === 'win32') { return '\\\\.\\pipe\\qzplayer'; } return '/tmp/qzmusic_mpv_socket'; } private getCorePath(): string { const appRoot = process.env.APP_ROOT || process.cwd(); if (app.isPackaged) { return path.join(process.resourcesPath, 'core', 'qzplayer.exe'); } return path.join(appRoot, 'core', 'qzplayer.exe'); } start(): void { if (this.process || this.connectTimer || this.isRestarting) return; this.isShuttingDown = false; this.messageBuffer = ''; const playerPath = this.getCorePath(); console.log('Starting QZPlayer from:', playerPath); try { const child = spawn(playerPath, [], { stdio: 'ignore', windowsHide: true, }); this.process = child; child.once('error', (err) => { if (this.process === child) this.process = null; console.error('Failed to start QZPlayer:', err); this.emitError(err); this.restart(`process error: ${err.message}`); }); child.once('exit', (code, signal) => { if (this.process === child) this.process = null; console.log(`QZPlayer exited with code ${code} and signal ${signal}`); this.emit('exit', { code, signal }); this.destroySocket(); this.restart(`process exited: code=${code}, signal=${signal}`); }); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); console.error('Failed to spawn QZPlayer:', error); this.emitError(error); this.restart(`spawn failed: ${error.message}`); return; } this.tryConnect(); } private tryConnect(retries = this.maxConnectRetries): void { if (this.isShuttingDown || this.isRestarting) return; this.clearConnectTimer(); this.connectTimer = setTimeout(() => { this.connectTimer = null; if (this.isShuttingDown || this.isRestarting) return; const socket = new Socket(); let connected = false; let handledConnectFailure = false; this.socket = socket; socket.once('connect', () => { connected = true; this.isConnected = true; this.messageBuffer = ''; console.log('Connected to QZPlayer IPC socket'); this.emit('ready'); this.initPlayerState(); this.restorePlaybackState(); this.flushPendingCommands(); }); socket.on('data', (data) => { this.handleData(data); }); socket.once('error', (err) => { if (!connected) { handledConnectFailure = true; this.destroySocket(socket); if (retries > 1) { this.tryConnect(retries - 1); } else { this.restart(`socket connect failed: ${err.message}`); } return; } console.warn('QZPlayer IPC socket error:', err); this.restart(`socket error: ${err.message}`); }); socket.once('close', () => { if (this.socket === socket) { this.socket = null; this.isConnected = false; } if (this.isShuttingDown || this.isRestarting) return; if (connected) { this.restart('socket closed'); } else if (!handledConnectFailure) { if (retries > 1) { this.tryConnect(retries - 1); } else { this.restart('socket closed before connect'); } } }); socket.connect(this.ipcPath); }, this.connectRetryDelay); } private initPlayerState(): void { this.send(['observe_property', 1, 'pause']); this.send(['observe_property', 2, 'time-pos']); this.send(['observe_property', 3, 'duration']); this.send(['observe_property', 4, 'idle-active']); this.send(['observe_property', 5, 'eof-reached']); this.send(['set_property', 'volume', this.volume]); } private restorePlaybackState(): void { const hasPendingLoad = this.pendingCommands.some((command) => command[0] === 'loadfile'); if (!this.currentUrl || hasPendingLoad) return; const restoreTime = this.lastKnownTime; this.send(['loadfile', this.currentUrl]); if (restoreTime > 0) { this.send(['seek', restoreTime, 'absolute']); } this.send(['set_property', 'pause', this.desiredPause]); } private restart(reason: string): void { if (this.isShuttingDown || this.isRestarting) return; console.warn(`Restarting QZPlayer: ${reason}`); this.isRestarting = true; this.emit('restart', { reason }); this.clearConnectTimer(); this.clearRestartTimer(); this.destroySocket(); this.killProcess(); this.restartTimer = setTimeout(() => { this.restartTimer = null; this.isRestarting = false; if (!this.isShuttingDown) { this.start(); } }, this.restartDelay); } private handleData(data: Buffer): void { const raw = data.toString(); this.messageBuffer += raw; const messages = this.messageBuffer.split('\n'); this.messageBuffer = messages.pop() || ''; for (const msg of messages) { if (!msg.trim()) continue; try { const json = JSON.parse(msg); this.trackPlayerState(json); this.emit('message', json); if (json.event) { this.emit('event', json); } } catch { console.error('Failed to parse QZPlayer message:', msg); } } } async send(command: any[]): Promise { this.trackOutgoingCommand(command); if (!this.socket || this.socket.destroyed || !this.isConnected) { console.warn('QZPlayer socket not connected'); this.queueCommand(command); if (!this.process && !this.isRestarting && !this.isShuttingDown) { this.start(); } return; } this.sendNow(command); } private sendNow(command: any[]): void { if (!this.socket || this.socket.destroyed || !this.isConnected) { this.queueCommand(command); return; } const payload = JSON.stringify({ command }); console.log('[QZPlayer TX]', payload); try { this.socket.write(payload + '\n'); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); this.queueCommand(command); this.restart(`socket write failed: ${error.message}`); } } async load(url: string): Promise { return this.send(['loadfile', url]); } async play(): Promise { return this.send(['set_property', 'pause', false]); } async pause(): Promise { return this.send(['set_property', 'pause', true]); } async togglePause(): Promise { return this.send(['cycle', 'pause']); } async stop(): Promise { return this.send(['stop']); } async setVolume(vol: number): Promise { return this.send(['set_property', 'volume', vol]); } async seek(seconds: number): Promise { return this.send(['seek', seconds, 'absolute']); } destroy(): void { this.isShuttingDown = true; this.isRestarting = false; this.clearConnectTimer(); this.clearRestartTimer(); this.destroySocket(); this.killProcess(); } private destroySocket(socket = this.socket): void { if (!socket) return; if (this.socket === socket) { this.socket = null; this.isConnected = false; } socket.removeAllListeners('data'); socket.removeAllListeners('error'); socket.removeAllListeners('close'); socket.destroy(); } private killProcess(): void { const child = this.process; this.process = null; if (!child) return; console.log('Killing QZPlayer process...'); child.removeAllListeners('error'); child.removeAllListeners('exit'); if (!child.killed) { child.kill('SIGKILL'); } } private clearConnectTimer(): void { if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; } } private clearRestartTimer(): void { if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null; } } private queueCommand(command: any[]): void { if (command[0] === 'observe_property') return; this.pendingCommands.push(command); if (this.pendingCommands.length > this.maxPendingCommands) { this.pendingCommands.shift(); } } private flushPendingCommands(): void { const commands = this.pendingCommands.splice(0); for (const command of commands) { this.sendNow(command); } } private trackPlayerState(json: any): void { if (json?.event !== 'property-change') return; if (json.name === 'pause') { this.desiredPause = Boolean(json.data); } else if (json.name === 'time-pos' && typeof json.data === 'number') { this.lastKnownTime = json.data; } } private trackOutgoingCommand(command: any[]): void { const [name, ...args] = command; if (name === 'loadfile' && typeof args[0] === 'string') { this.currentUrl = args[0]; this.lastKnownTime = 0; } else if (name === 'seek' && typeof args[0] === 'number') { this.lastKnownTime = args[0]; } else if (name === 'stop') { this.currentUrl = null; this.lastKnownTime = 0; this.desiredPause = true; } else if (name === 'cycle' && args[0] === 'pause') { this.desiredPause = !this.desiredPause; } else if (name === 'set_property') { if (args[0] === 'pause') { this.desiredPause = Boolean(args[1]); } else if (args[0] === 'volume' && typeof args[1] === 'number') { this.volume = args[1]; } } } private emitError(err: Error): void { if (this.listenerCount('error') > 0) { this.emit('error', err); } } }