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; let currentPlayerData: null | PlayerData = null;
const socketPath = isWindows() ? `\\\\.\\pipe\\mpvserver-${pid}` : `/tmp/node-mpv-${pid}.sock`; 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 = { const NodeMpvErrorCode = {
0: 'Unable to load file or stream', 0: 'Unable to load file or stream',
1: 'Invalid argument', 1: 'Invalid argument',
@@ -167,6 +182,16 @@ const createMpv = async (data: {
} }
let previousPlaylistPos: number | undefined; 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) => { mpv.on('status', (status) => {
if (status.property === 'playlist-pos') { 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). // mpv uses playlist-pos = -1 when nothing is playing (ended, cleared, load failure, etc).
if (currentPos === -1) { if (currentPos === -1) {
if (previousPlaylistPos === 0) { if (previousPlaylistPos === 0) {
getMainWindow()?.webContents.send('renderer-player-track-ended'); sendIfCurrent('renderer-player-track-ended');
} }
mpv?.pause(); mpv?.pause();
previousPlaylistPos = currentPos; previousPlaylistPos = currentPos;
@@ -185,7 +210,7 @@ const createMpv = async (data: {
// In our 2-item queue model, playlist-pos should normally be 0. // 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). // When mpv auto-advances to the next track it becomes > 0 (typically 1).
if (typeof currentPos === 'number' && currentPos > 0) { if (typeof currentPos === 'number' && currentPos > 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next'); sendIfCurrent('renderer-player-auto-next');
} }
previousPlaylistPos = currentPos; previousPlaylistPos = currentPos;
@@ -194,21 +219,24 @@ const createMpv = async (data: {
// Automatically updates the play button when the player is playing // Automatically updates the play button when the player is playing
mpv.on('resumed', () => { mpv.on('resumed', () => {
getMainWindow()?.webContents.send('renderer-player-play'); sendIfCurrent('renderer-player-play');
}); });
// Automatically updates the play button when the player is stopped // Automatically updates the play button when the player is stopped
mpv.on('stopped', () => { mpv.on('stopped', () => {
getMainWindow()?.webContents.send('renderer-player-stop'); sendIfCurrent('renderer-player-stop');
}); });
// Automatically updates the play button when the player is paused // Automatically updates the play button when the player is paused
mpv.on('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 // Event output every interval set by time_update, used to update the current time
mpv.on('timeposition', (time: number) => { mpv.on('timeposition', (time: number) => {
if (eventGeneration !== playbackEventGeneration) {
return;
}
getMainWindow()?.webContents.send('renderer-player-current-time', time); getMainWindow()?.webContents.send('renderer-player-current-time', time);
}); });
@@ -235,6 +263,8 @@ const killMpvProcess = (mpv: MpvAPI) => {
const quit = async (instance?: MpvAPI | null) => { const quit = async (instance?: MpvAPI | null) => {
const mpv = instance || getMpvInstance(); const mpv = instance || getMpvInstance();
if (mpv) { if (mpv) {
suppressRendererPlaybackEvents = true;
playbackEventGeneration += 1;
try { try {
// mpv.quit() resolves only when mpv replies over IPC. If mpv's command queue // 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 // 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 // Clean up previous mpv instance
suppressRendererPlaybackEvents = true;
playbackEventGeneration += 1;
getMpvInstance()?.stop(); getMpvInstance()?.stop();
getMpvInstance() getMpvInstance()
?.quit() ?.quit()
@@ -338,6 +370,9 @@ ipcMain.handle(
); );
ipcMain.on('player-quit', async () => { 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 { try {
await getMpvInstance()?.stop(); await getMpvInstance()?.stop();
await quit(); 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 { try {
if (current) { if (current) {
try { try {
@@ -462,6 +504,10 @@ ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, p
} }
} catch (err: any | NodeMpvError) { } catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to set play queue` }, err); mpvLog({ action: `Failed to set play queue` }, err);
} finally {
if (shouldSuppressLoadEvents) {
suppressRendererPlaybackEvents = false;
}
} }
}); });
@@ -147,7 +147,9 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
: undefined; : undefined;
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) { 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; hasPopulatedQueueRef.current = true;
} }
} }
@@ -2,6 +2,8 @@ import isElectron from 'is-electron';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { import {
getDefaultAudioDevice, getDefaultAudioDevice,
useAudioDevices, useAudioDevices,
@@ -241,36 +243,54 @@ export const PlayerConfig = () => {
}; };
const AudioPlayerTypeConfig = () => { const AudioPlayerTypeConfig = () => {
const { t } = useTranslation();
const status = usePlayerStatus(); const status = usePlayerStatus();
const playbackSettings = usePlaybackSettings(); const playbackSettings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const { mediaStop } = usePlayer();
const showRefreshButton = playbackSettings.type === PlayerType.LOCAL;
return ( return (
<Select <Group gap="xs" wrap="nowrap">
comboboxProps={{ withinPortal: false }} <Select
data={[ comboboxProps={{ withinPortal: false }}
{ data={[
disabled: !isElectron(), {
label: 'MPV', disabled: !isElectron(),
value: PlayerType.LOCAL, label: 'MPV',
}, value: PlayerType.LOCAL,
{ label: 'Web', value: PlayerType.WEB }, },
{ label: 'Jukebox', value: PlayerType.JUKEBOX }, { label: 'Web', value: PlayerType.WEB },
]} { label: 'Jukebox', value: PlayerType.JUKEBOX },
defaultValue={playbackSettings.type} ]}
disabled={status === PlayerStatus.PLAYING} defaultValue={playbackSettings.type}
onChange={(e) => { disabled={status === PlayerStatus.PLAYING}
setSettings({ onChange={(e) => {
playback: { ...playbackSettings, type: e as PlayerType }, setSettings({
}); playback: { ...playbackSettings, type: e as PlayerType },
ipc?.send('settings-set', { });
property: 'playbackType', ipc?.send('settings-set', {
value: e, property: 'playbackType',
}); value: e,
}} });
variant="filled" }}
width="100%" 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 }} comboboxProps={{ withinPortal: false }}
data={audioDevices} data={audioDevices}
disabled={status === PlayerStatus.PLAYING} disabled={status === PlayerStatus.PLAYING}
key={playbackType}
onChange={(e) => { onChange={(e) => {
setSettings({ setSettings({
playback: { playback: {
@@ -3,14 +3,22 @@ import isElectron from 'is-electron';
import { memo, useEffect, useState } from 'react'; import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { import {
SettingOption, SettingOption,
SettingsSection, SettingsSection,
} from '/@/renderer/features/settings/components/settings-section'; } from '/@/renderer/features/settings/components/settings-section';
import { useCurrentServer, usePlaybackType, usePlayerStatus } from '/@/renderer/store'; import { useCurrentServer, usePlayerStatus } from '/@/renderer/store';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; import {
usePlaybackSettings,
usePlaybackType,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { logger } from '/@/renderer/utils/logger'; import { logger } from '/@/renderer/utils/logger';
import { hasFeature } from '/@/shared/api/utils'; 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 { Select } from '/@/shared/components/select/select';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
@@ -100,10 +108,12 @@ export const AudioSettings = memo(() => {
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const status = usePlayerStatus(); const status = usePlayerStatus();
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const { mediaStop } = usePlayer();
// Cleaned up server feature logic via requested hooks/utilities // Cleaned up server feature logic via requested hooks/utilities
const currentServer = useCurrentServer(); const currentServer = useCurrentServer();
const isJukeboxSupported = hasFeature(currentServer, ServerFeature.JUKEBOX); const isJukeboxSupported = hasFeature(currentServer, ServerFeature.JUKEBOX);
const showRefreshButton = settings.type === PlayerType.LOCAL;
const audioDevices = useAudioDevices(playbackType); const audioDevices = useAudioDevices(playbackType);
const audioDeviceId = const audioDeviceId =
@@ -126,15 +136,29 @@ export const AudioSettings = memo(() => {
const audioOptions: SettingOption[] = [ const audioOptions: SettingOption[] = [
{ {
control: ( control: (
<Select <Group gap="xs" wrap="nowrap">
data={selectData} <Select
defaultValue={settings.type} data={selectData}
disabled={status === PlayerStatus.PLAYING} defaultValue={settings.type}
onChange={(e) => { disabled={status === PlayerStatus.PLAYING}
setSettings({ playback: { type: e as PlayerType } }); onChange={(e) => {
ipc?.send('settings-set', { property: 'playbackType', value: 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' }), description: t('setting.audioPlayer', { context: 'description' }),
isHidden: !isElectron() && !isJukeboxSupported, isHidden: !isElectron() && !isJukeboxSupported,