import console from 'console'; import { app, ipcMain } from 'electron'; import { rm } from 'fs/promises'; import uniq from 'lodash/uniq'; import MpvAPI from 'node-mpv'; import { pid } from 'node:process'; import process from 'process'; import { getMainWindow, sendToastToRenderer } from '../../../index'; import { createLog, isWindows } from '../../../utils'; import { store } from '../settings'; import { PlayerData } from '/@/shared/types/domain-types'; declare module 'node-mpv'; // function wait(timeout: number) { // return new Promise((resolve) => { // setTimeout(() => { // resolve('resolved'); // }, timeout); // }); // } let mpvInstance: MpvAPI | null = null; let currentPlayerData: null | PlayerData = null; const socketPath = isWindows() ? `\\\\.\\pipe\\mpvserver-${pid}` : `/tmp/node-mpv-${pid}.sock`; const NodeMpvErrorCode = { 0: 'Unable to load file or stream', 1: 'Invalid argument', 2: 'Binary not found', 3: 'IPC command invalid', 4: 'Unable to bind IPC socket', 5: 'Connection timeout', 6: 'MPV is already running', 7: 'Could not send IPC message', 8: 'MPV is not running', 9: 'Unsupported protocol', }; type NodeMpvError = { errcode: number; method: string; stackTrace: string; verbose: string; }; const mpvLog = ( data: { action: string; toast?: 'info' | 'success' | 'warning' }, err?: NodeMpvError, ) => { const { action, toast } = data; if (err) { const message = `[AUDIO PLAYER] ${action} - mpv errorcode ${err.errcode} - ${ NodeMpvErrorCode[err.errcode as keyof typeof NodeMpvErrorCode] }`; sendToastToRenderer({ message, type: 'error' }); createLog({ message, type: 'error' }); } const message = `[AUDIO PLAYER] ${action}`; createLog({ message, type: 'error' }); if (toast) { sendToastToRenderer({ message, type: toast }); } }; const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined; const prefetchPlaylistParams = [ '--prefetch-playlist=no', '--prefetch-playlist=yes', '--prefetch-playlist', ]; const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => { const parameters = ['--idle=yes', '--no-config', '--load-scripts=no']; if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) { parameters.push('--prefetch-playlist=yes'); } return parameters; }; const createMpv = async (data: { binaryPath?: string; extraParameters?: string[]; properties?: Record; }): Promise => { const { binaryPath, extraParameters, properties } = data; const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]); const mpv = new MpvAPI( { audio_only: true, auto_restart: false, binary: binaryPath || MPV_BINARY_PATH || undefined, socket: socketPath, time_update: 1, }, params, ); try { await mpv.start(); } catch (error: any) { console.error('mpv failed to start', error); } finally { await mpv.setMultipleProperties(properties || {}); } mpv.on('status', (status) => { if (status.property === 'playlist-pos') { // mpv uses playlist-pos = -1 when nothing is playing (ended, cleared, load failure, etc). if (status.value === -1) { mpv?.pause(); return; } // In our 2-item queue model, playlist-pos should normally be 0. // When mpv auto-advances to the next track it becomes > 0 (typically 1). if (typeof status.value === 'number' && status.value > 0) { getMainWindow()?.webContents.send('renderer-player-auto-next'); } } }); // Automatically updates the play button when the player is playing mpv.on('resumed', () => { getMainWindow()?.webContents.send('renderer-player-play'); }); // Automatically updates the play button when the player is stopped mpv.on('stopped', () => { getMainWindow()?.webContents.send('renderer-player-stop'); }); // Automatically updates the play button when the player is paused mpv.on('paused', () => { getMainWindow()?.webContents.send('renderer-player-pause'); }); // Event output every interval set by time_update, used to update the current time mpv.on('timeposition', (time: number) => { getMainWindow()?.webContents.send('renderer-player-current-time', time); }); return mpv; }; export const getMpvInstance = () => { return mpvInstance; }; const quit = async (instance?: MpvAPI | null) => { const mpv = instance || getMpvInstance(); if (mpv) { try { await mpv.quit(); } catch { // If quit() fails, try to kill the process directly const mpvProcess = (mpv as any).process || (mpv as any).mpvProcess; if (mpvProcess && typeof mpvProcess.kill === 'function') { try { mpvProcess.kill('SIGTERM'); } catch (killErr) { mpvLog({ action: 'Failed to kill mpv process' }, killErr as NodeMpvError); } } } if (!isWindows()) { try { await rm(socketPath); } catch { // Ignore errors when removing socket file } } } }; const setAudioPlayerFallback = (isError: boolean) => { getMainWindow()?.webContents.send('renderer-player-fallback', isError); }; ipcMain.on('player-set-properties', async (_event, data: Record) => { mpvLog({ action: `Setting properties: ${JSON.stringify(data)}` }); if (data.length === 0) { return; } try { if (data.length === 1) { getMpvInstance()?.setProperty(Object.keys(data)[0], Object.values(data)[0]); } else { getMpvInstance()?.setMultipleProperties(data); } } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to set properties: ${JSON.stringify(data)}` }, err); } }); ipcMain.handle( 'player-restart', async (_event, data: { extraParameters?: string[]; properties?: Record }) => { try { mpvLog({ action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`, }); // Clean up previous mpv instance getMpvInstance()?.stop(); getMpvInstance() ?.quit() .catch((error) => { mpvLog({ action: 'Failed to quit existing MPV' }, error); }); mpvInstance = null; mpvInstance = await createMpv(data); mpvLog({ action: 'Restarted mpv', toast: 'success' }); setAudioPlayerFallback(false); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to restart mpv, falling back to web player' }, err); setAudioPlayerFallback(true); } }, ); ipcMain.handle( 'player-initialize', async (_event, data: { extraParameters?: string[]; properties?: Record }) => { try { mpvLog({ action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`, }); mpvInstance = await createMpv(data); setAudioPlayerFallback(false); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to initialize mpv, falling back to web player' }, err); setAudioPlayerFallback(true); } }, ); ipcMain.on('player-quit', async () => { try { await getMpvInstance()?.stop(); await quit(); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to quit mpv' }, err); } finally { mpvInstance = null; } }); ipcMain.handle('player-is-running', async () => { return getMpvInstance()?.isRunning(); }); ipcMain.handle('player-clean-up', async () => { getMpvInstance()?.stop(); getMpvInstance()?.clearPlaylist(); }); ipcMain.on('player-start', async () => { try { await getMpvInstance()?.play(); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to start mpv playback' }, err); } }); // Starts the player ipcMain.on('player-play', async () => { try { await getMpvInstance()?.play(); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to start mpv playback' }, err); } }); // Pauses the player ipcMain.on('player-pause', async () => { try { await getMpvInstance()?.pause(); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to pause mpv playback' }, err); } }); // Stops the player ipcMain.on('player-stop', async () => { try { await getMpvInstance()?.stop(); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to stop mpv playback' }, err); } }); // Goes to the next track in the playlist ipcMain.on('player-next', async () => { try { await getMpvInstance()?.next(); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to go to next track' }, err); } }); // Goes to the previous track in the playlist ipcMain.on('player-previous', async () => { try { await getMpvInstance()?.prev(); } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to go to previous track' }, err); } }); // Seeks forward or backward by the given amount of seconds ipcMain.on('player-seek', async (_event, time: number) => { try { await getMpvInstance()?.seek(time); } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to seek by ${time} seconds` }, err); } }); // Seeks to the given time in seconds ipcMain.on('player-seek-to', async (_event, time: number) => { try { await getMpvInstance()?.goToPosition(time); } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to seek to ${time} seconds` }, err); } }); // Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, pause?: boolean) => { if (!current && !next) { try { await getMpvInstance()?.clearPlaylist(); await getMpvInstance()?.pause(); return; } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to clear play queue` }, err); } } try { if (current) { try { await getMpvInstance()?.load(current, 'replace'); } catch (error: any | NodeMpvError) { mpvLog({ action: `Failed to load current song` }, error); await getMpvInstance()?.play(); } if (next) { await getMpvInstance()?.load(next, 'append'); } } if (pause) { await getMpvInstance()?.pause(); } else if (pause === false) { // Only force play if pause is explicitly false await getMpvInstance()?.play(); } } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to set play queue` }, err); } }); // Replaces the queue in position 1 to the given data ipcMain.on('player-set-queue-next', async (_event, url?: string) => { try { const size = await getMpvInstance()?.getPlaylistSize(); if (size && size > 1) { await getMpvInstance()?.playlistRemove(1); } if (url) { getMpvInstance()?.load(url, 'append'); } } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to set play queue` }, err); } }); // Sets the next song in the queue when reaching the end of the queue ipcMain.on('player-auto-next', async (_event, url?: string) => { // Always keep the current song as position 0 in the mpv queue // This allows us to easily set update the next song in the queue without // disturbing the currently playing song try { await getMpvInstance() ?.playlistRemove(0) .catch(() => { getMpvInstance()?.pause(); }); if (url) { await getMpvInstance()?.load(url, 'append'); } } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to load next song` }, err); } }); // Sets the volume to the given value (0-100) ipcMain.on('player-volume', async (_event, value: number) => { try { if (!value || value < 0 || value > 100) { return; } await getMpvInstance()?.volume(value); } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to set volume to ${value}` }, err); } }); // Toggles the mute status ipcMain.on('player-mute', async (_event, mute: boolean) => { try { await getMpvInstance()?.mute(mute); } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to set mute status` }, err); } }); ipcMain.handle('player-get-time', async (): Promise => { try { return getMpvInstance()?.getTimePosition(); } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to get current time` }, err); return 0; } }); // Updates the current player metadata (song data) ipcMain.on('player-update-metadata', (_event, data: PlayerData) => { currentPlayerData = data; }); // Returns the current player metadata (song data) ipcMain.handle('player-metadata', async (): Promise => { return currentPlayerData; }); // Returns the stream metadata from mpv (for radio streams) ipcMain.handle( 'player-stream-metadata', async (): Promise => { try { const metadata = await getMpvInstance()?.getProperty('metadata'); if (metadata && typeof metadata === 'object') { // Try to get separate title and artist fields first let artist: null | string = (metadata['artist'] as string) || (metadata['ARTIST'] as string) || (metadata['icy-artist'] as string) || null; let title: null | string = (metadata['title'] as string) || (metadata['TITLE'] as string) || null; // If we don't have separate fields, try to parse from combined formats if (!title && !artist) { const combinedTitle = (metadata['icy-title'] as string) || (metadata['StreamTitle'] as string) || (metadata['stream-title'] as string) || null; if (combinedTitle && typeof combinedTitle === 'string') { // Try to parse "Artist - Title" format const match = combinedTitle.match(/^(.*?)\s*[-–—]\s*(.+)$/); if (match) { artist = match[1].trim() || null; title = match[2].trim() || null; } else { // If no separator found, treat the whole thing as title title = combinedTitle; } } } else if (!title) { // If we have artist but no title, try to get from combined format const combinedTitle = (metadata['icy-title'] as string) || (metadata['StreamTitle'] as string) || (metadata['stream-title'] as string) || null; if (combinedTitle && typeof combinedTitle === 'string') { title = combinedTitle; } } else if (!artist) { // If we have title but no artist, try to get from combined format const combinedTitle = (metadata['icy-title'] as string) || (metadata['StreamTitle'] as string) || (metadata['stream-title'] as string) || null; if ( combinedTitle && typeof combinedTitle === 'string' && combinedTitle !== title ) { // Try to parse artist from combined format const match = combinedTitle.match(/^(.*?)\s*[-–—]\s*(.+)$/); if (match && match[2].trim() === title) { artist = match[1].trim() || null; } } } return { artist, title }; } return null; } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to get stream metadata` }, err); return null; } }, ); ipcMain.handle( 'player-get-audio-devices', async (): Promise<{ label: string; value: string }[]> => { try { const instance = getMpvInstance(); let tempInstance: MpvAPI | null = null; let mpvToUse: MpvAPI | null = null; if (instance && instance.isRunning()) { mpvToUse = instance; } else { try { tempInstance = await createMpv({}); mpvToUse = tempInstance; } catch (err: any | NodeMpvError) { mpvLog( { action: 'Failed to create temporary MPV instance for audio device list' }, err, ); return []; } } try { const deviceList = await mpvToUse.getProperty('audio-device-list'); if (!deviceList || !Array.isArray(deviceList)) { return []; } const devices = deviceList.map((device: any) => { const name = device.name || device.description || 'Unknown Device'; const description = device.description || ''; const label = description ? `${name} (${description})` : name; return { label, value: name, }; }); return devices; } finally { if (tempInstance && tempInstance !== instance) { try { await quit(tempInstance); } catch { // Ignore } } } } catch (err: any | NodeMpvError) { mpvLog({ action: 'Failed to get audio devices' }, err); return []; } }, ); enum MpvState { STARTED, IN_PROGRESS, DONE, } let mpvState = MpvState.STARTED; // Cleanup function that can be called from multiple places const cleanupMpv = async (force = false) => { if (mpvState === MpvState.DONE && !force) { return; } const instance = getMpvInstance(); if (instance) { try { if (!force) { await instance.stop(); } await quit(instance); } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to cleanup mpv` }, err); // Force kill as fallback const mpvProcess = (instance as any).process || (instance as any).mpvProcess; if (mpvProcess && typeof mpvProcess.kill === 'function') { try { mpvProcess.kill('SIGKILL'); } catch { // Ignore kill errors } } } finally { mpvInstance = null; } } }; app.on('before-quit', async (event) => { switch (mpvState) { case MpvState.DONE: return; case MpvState.IN_PROGRESS: event.preventDefault(); break; case MpvState.STARTED: { try { mpvState = MpvState.IN_PROGRESS; event.preventDefault(); await cleanupMpv(); } catch (err: any | NodeMpvError) { mpvLog({ action: `Failed to cleanly before-quit` }, err); } finally { mpvState = MpvState.DONE; app.quit(); } break; } } }); // Handle process exit events to ensure mpv is killed even if app crashes process.on('exit', () => { const instance = getMpvInstance(); if (instance) { // Try to access and kill the process directly const mpvProcess = (instance as any).process || (instance as any).mpvProcess; if (mpvProcess && typeof mpvProcess.kill === 'function') { try { mpvProcess.kill('SIGKILL'); } catch { // Ignore errors during exit } } } }); // Handle signals that can terminate the process process.on('SIGINT', async () => { await cleanupMpv(true); process.exit(0); }); process.on('SIGTERM', async () => { await cleanupMpv(true); process.exit(0); }); // Handle uncaught exceptions - cleanup mpv before crashing process.on('uncaughtException', async (error) => { console.error('Uncaught exception:', error); await cleanupMpv(true).catch(() => { // Ignore cleanup errors during crash }); }); // Handle unhandled rejections - cleanup mpv process.on('unhandledRejection', async (reason) => { console.error('Unhandled rejection:', reason); await cleanupMpv(true).catch(() => { // Ignore cleanup errors }); });