Files
feishin/src/main/features/core/player/index.ts
T
2026-02-27 00:39:27 -08:00

692 lines
22 KiB
TypeScript

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<string, any>;
}): Promise<MpvAPI> => {
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<string, any>) => {
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<string, any> }) => {
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<string, any> }) => {
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<number | undefined> => {
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<null | PlayerData> => {
return currentPlayerData;
});
// Returns the stream metadata from mpv (for radio streams)
ipcMain.handle(
'player-stream-metadata',
async (): Promise<null | { artist: null | string; title: null | string }> => {
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
});
});