add MPV reload button to audio player selector

This commit is contained in:
jeffvli
2026-08-07 22:02:32 -07:00
parent 3053b004e0
commit de07ea7b2f
4 changed files with 135 additions and 42 deletions
+51 -5
View File
@@ -26,6 +26,21 @@ let mpvInstance: MpvAPI | null = null;
let currentPlayerData: null | PlayerData = null;
const socketPath = isWindows() ? `\\\\.\\pipe\\mpvserver-${pid}` : `/tmp/node-mpv-${pid}.sock`;
// While quitting/restarting mpv, playlist-pos goes to -1 and node-mpv emits stopped/paused/
// resumed. Those look identical to a real track end and must not reach the renderer — otherwise
// handleTrackEnded runs mediaAutoNext while status is STOPPED and flips the UI back to Playing
// on the next queue item (e.g. MPV reload after mediaStop).
let suppressRendererPlaybackEvents = false;
// Bumped on quit so late events from a dying instance are ignored after a new one starts.
let playbackEventGeneration = 0;
const sendRendererPlaybackEvent = (channel: string, ...args: unknown[]) => {
if (suppressRendererPlaybackEvents) {
return;
}
getMainWindow()?.webContents.send(channel, ...args);
};
const NodeMpvErrorCode = {
0: 'Unable to load file or stream',
1: 'Invalid argument',
@@ -167,6 +182,16 @@ const createMpv = async (data: {
}
let previousPlaylistPos: number | undefined;
const eventGeneration = playbackEventGeneration;
suppressRendererPlaybackEvents = false;
const sendIfCurrent = (channel: string, ...args: unknown[]) => {
if (eventGeneration !== playbackEventGeneration) {
return;
}
sendRendererPlaybackEvent(channel, ...args);
};
mpv.on('status', (status) => {
if (status.property === 'playlist-pos') {
@@ -175,7 +200,7 @@ const createMpv = async (data: {
// mpv uses playlist-pos = -1 when nothing is playing (ended, cleared, load failure, etc).
if (currentPos === -1) {
if (previousPlaylistPos === 0) {
getMainWindow()?.webContents.send('renderer-player-track-ended');
sendIfCurrent('renderer-player-track-ended');
}
mpv?.pause();
previousPlaylistPos = currentPos;
@@ -185,7 +210,7 @@ const createMpv = async (data: {
// 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 currentPos === 'number' && currentPos > 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next');
sendIfCurrent('renderer-player-auto-next');
}
previousPlaylistPos = currentPos;
@@ -194,21 +219,24 @@ const createMpv = async (data: {
// Automatically updates the play button when the player is playing
mpv.on('resumed', () => {
getMainWindow()?.webContents.send('renderer-player-play');
sendIfCurrent('renderer-player-play');
});
// Automatically updates the play button when the player is stopped
mpv.on('stopped', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
sendIfCurrent('renderer-player-stop');
});
// Automatically updates the play button when the player is paused
mpv.on('paused', () => {
getMainWindow()?.webContents.send('renderer-player-pause');
sendIfCurrent('renderer-player-pause');
});
// Event output every interval set by time_update, used to update the current time
mpv.on('timeposition', (time: number) => {
if (eventGeneration !== playbackEventGeneration) {
return;
}
getMainWindow()?.webContents.send('renderer-player-current-time', time);
});
@@ -235,6 +263,8 @@ const killMpvProcess = (mpv: MpvAPI) => {
const quit = async (instance?: MpvAPI | null) => {
const mpv = instance || getMpvInstance();
if (mpv) {
suppressRendererPlaybackEvents = true;
playbackEventGeneration += 1;
try {
// mpv.quit() resolves only when mpv replies over IPC. If mpv's command queue
// is wedged (e.g. blocked on a dead network stream after the system resumes
@@ -302,6 +332,8 @@ ipcMain.handle(
});
// Clean up previous mpv instance
suppressRendererPlaybackEvents = true;
playbackEventGeneration += 1;
getMpvInstance()?.stop();
getMpvInstance()
?.quit()
@@ -338,6 +370,9 @@ ipcMain.handle(
);
ipcMain.on('player-quit', async () => {
// stop() also drives playlist-pos to -1; suppress before that so reload does not look like a track end.
suppressRendererPlaybackEvents = true;
playbackEventGeneration += 1;
try {
await getMpvInstance()?.stop();
await quit();
@@ -440,6 +475,13 @@ ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, p
}
}
// When pause is requested (e.g. preload after reload while UI is STOPPED/PAUSED), mpv still
// briefly resumes on load. Suppress those events so they do not overwrite renderer status.
const shouldSuppressLoadEvents = pause === true;
if (shouldSuppressLoadEvents) {
suppressRendererPlaybackEvents = true;
}
try {
if (current) {
try {
@@ -462,6 +504,10 @@ ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, p
}
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to set play queue` }, err);
} finally {
if (shouldSuppressLoadEvents) {
suppressRendererPlaybackEvents = false;
}
}
});
@@ -147,7 +147,9 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
: undefined;
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true);
const shouldPause =
usePlayerStore.getState().player.status !== PlayerStatus.PLAYING;
mpvPlayer.setQueue(currentSongUrl, nextSongUrl, shouldPause);
hasPopulatedQueueRef.current = true;
}
}
@@ -2,6 +2,8 @@ import isElectron from 'is-electron';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
getDefaultAudioDevice,
useAudioDevices,
@@ -241,36 +243,54 @@ export const PlayerConfig = () => {
};
const AudioPlayerTypeConfig = () => {
const { t } = useTranslation();
const status = usePlayerStatus();
const playbackSettings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const { mediaStop } = usePlayer();
const showRefreshButton = playbackSettings.type === PlayerType.LOCAL;
return (
<Select
comboboxProps={{ withinPortal: false }}
data={[
{
disabled: !isElectron(),
label: 'MPV',
value: PlayerType.LOCAL,
},
{ label: 'Web', value: PlayerType.WEB },
{ label: 'Jukebox', value: PlayerType.JUKEBOX },
]}
defaultValue={playbackSettings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({
playback: { ...playbackSettings, type: e as PlayerType },
});
ipc?.send('settings-set', {
property: 'playbackType',
value: e,
});
}}
variant="filled"
width="100%"
/>
<Group gap="xs" wrap="nowrap">
<Select
comboboxProps={{ withinPortal: false }}
data={[
{
disabled: !isElectron(),
label: 'MPV',
value: PlayerType.LOCAL,
},
{ label: 'Web', value: PlayerType.WEB },
{ label: 'Jukebox', value: PlayerType.JUKEBOX },
]}
defaultValue={playbackSettings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({
playback: { ...playbackSettings, type: e as PlayerType },
});
ipc?.send('settings-set', {
property: 'playbackType',
value: e,
});
}}
variant="filled"
width="100%"
/>
{showRefreshButton && (
<ActionIcon
icon="refresh"
iconProps={{ size: 'md' }}
onClick={() => {
mediaStop();
eventEmitter.emit('MPV_RELOAD', {});
}}
tooltip={{ label: t('common.reload') }}
variant="transparent"
/>
)}
</Group>
);
};
@@ -291,6 +311,7 @@ const AudioDeviceConfig = () => {
comboboxProps={{ withinPortal: false }}
data={audioDevices}
disabled={status === PlayerStatus.PLAYING}
key={playbackType}
onChange={(e) => {
setSettings({
playback: {
@@ -3,14 +3,22 @@ import isElectron from 'is-electron';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useCurrentServer, usePlaybackType, usePlayerStatus } from '/@/renderer/store';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { useCurrentServer, usePlayerStatus } from '/@/renderer/store';
import {
usePlaybackSettings,
usePlaybackType,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { logger } from '/@/renderer/utils/logger';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { Select } from '/@/shared/components/select/select';
import { Switch } from '/@/shared/components/switch/switch';
import { toast } from '/@/shared/components/toast/toast';
@@ -100,10 +108,12 @@ export const AudioSettings = memo(() => {
const { setSettings } = useSettingsStoreActions();
const status = usePlayerStatus();
const playbackType = usePlaybackType();
const { mediaStop } = usePlayer();
// Cleaned up server feature logic via requested hooks/utilities
const currentServer = useCurrentServer();
const isJukeboxSupported = hasFeature(currentServer, ServerFeature.JUKEBOX);
const showRefreshButton = settings.type === PlayerType.LOCAL;
const audioDevices = useAudioDevices(playbackType);
const audioDeviceId =
@@ -126,15 +136,29 @@ export const AudioSettings = memo(() => {
const audioOptions: SettingOption[] = [
{
control: (
<Select
data={selectData}
defaultValue={settings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({ playback: { type: e as PlayerType } });
ipc?.send('settings-set', { property: 'playbackType', value: e });
}}
/>
<Group gap="xs" wrap="nowrap">
<Select
data={selectData}
defaultValue={settings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({ playback: { type: e as PlayerType } });
ipc?.send('settings-set', { property: 'playbackType', value: e });
}}
/>
{showRefreshButton && (
<ActionIcon
icon="refresh"
iconProps={{ size: 'md' }}
onClick={() => {
mediaStop();
eventEmitter.emit('MPV_RELOAD', {});
}}
tooltip={{ label: t('common.reload') }}
variant="transparent"
/>
)}
</Group>
),
description: t('setting.audioPlayer', { context: 'description' }),
isHidden: !isElectron() && !isJukeboxSupported,