diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index a09a13fa0..922f84e46 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -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; + } } }); diff --git a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx index 1af8cf766..14caf4dfc 100644 --- a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx @@ -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; } } diff --git a/src/renderer/features/player/components/player-config.tsx b/src/renderer/features/player/components/player-config.tsx index f022f86e6..6d750d074 100644 --- a/src/renderer/features/player/components/player-config.tsx +++ b/src/renderer/features/player/components/player-config.tsx @@ -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 ( - { + setSettings({ + playback: { ...playbackSettings, type: e as PlayerType }, + }); + ipc?.send('settings-set', { + property: 'playbackType', + value: e, + }); + }} + variant="filled" + width="100%" + /> + {showRefreshButton && ( + { + mediaStop(); + eventEmitter.emit('MPV_RELOAD', {}); + }} + tooltip={{ label: t('common.reload') }} + variant="transparent" + /> + )} + ); }; @@ -291,6 +311,7 @@ const AudioDeviceConfig = () => { comboboxProps={{ withinPortal: false }} data={audioDevices} disabled={status === PlayerStatus.PLAYING} + key={playbackType} onChange={(e) => { setSettings({ playback: { diff --git a/src/renderer/features/settings/components/playback/audio-settings.tsx b/src/renderer/features/settings/components/playback/audio-settings.tsx index a9da6cc74..87a06e1df 100644 --- a/src/renderer/features/settings/components/playback/audio-settings.tsx +++ b/src/renderer/features/settings/components/playback/audio-settings.tsx @@ -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: ( - { + setSettings({ playback: { type: e as PlayerType } }); + ipc?.send('settings-set', { property: 'playbackType', value: e }); + }} + /> + {showRefreshButton && ( + { + mediaStop(); + eventEmitter.emit('MPV_RELOAD', {}); + }} + tooltip={{ label: t('common.reload') }} + variant="transparent" + /> + )} + ), description: t('setting.audioPlayer', { context: 'description' }), isHidden: !isElectron() && !isJukeboxSupported,