Browse Source

Enable resuming of queue (#293)

* Enable resuming of queue

- on before-quit, save the queue state (entries, sorting, current song/player)
- state is compressed using brotli
- when the window is ready, restore the queue

* test

* Remove resume state and use async save

* Add default setting for resume
master
Kendall Garner 3 years ago
committed by GitHub
parent
commit
b3de3be4a6
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 23
      src/components/settings/ConfigPanels/PlayerConfig.tsx
  2. 4
      src/components/shared/setDefaultSettings.ts
  3. 93
      src/hooks/usePlayerControls.ts
  4. 2
      src/i18n/locales/en.json
  5. 32
      src/main.dev.js
  6. 30
      src/redux/playQueueSlice.ts
  7. 8
      src/redux/store.ts

23
src/components/settings/ConfigPanels/PlayerConfig.tsx

@ -81,6 +81,7 @@ const PlayerConfig = ({ bordered }: any) => {
const [systemMediaTransportControls, setSystemMediaTransportControls] = useState( const [systemMediaTransportControls, setSystemMediaTransportControls] = useState(
Boolean(settings.getSync('systemMediaTransportControls')) Boolean(settings.getSync('systemMediaTransportControls'))
); );
const [resume, setResume] = useState(Boolean(settings.getSync('resume')));
const [scrobble, setScrobble] = useState(Boolean(settings.getSync('scrobble'))); const [scrobble, setScrobble] = useState(Boolean(settings.getSync('scrobble')));
const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>(); const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>();
const audioDevicePickerContainerRef = useRef(null); const audioDevicePickerContainerRef = useRef(null);
@ -162,7 +163,27 @@ const PlayerConfig = ({ bordered }: any) => {
/> />
} }
/> />
<ConfigOption
name={t('Resume Playback')}
description={
<Trans>
Remember play queue on startup. The current Now Playing queue will be saved on exiting,
and will be restored when you reopen Sonixd. Be warned that you should manually close
Sonixd for the queue to be saved. An improper shutdown (such as the app closing during a
shutdown or force quitting) may result in history not being saved.
</Trans>
}
option={
<StyledToggle
defaultChecked={resume}
checked={resume}
onChange={(e: boolean) => {
settings.setSync('resume', e);
setResume(e);
}}
/>
}
/>
{config.serverType === Server.Jellyfin && ( {config.serverType === Server.Jellyfin && (
<ConfigOption <ConfigOption
name={t('Allow Transcoding')} name={t('Allow Transcoding')}

4
src/components/shared/setDefaultSettings.ts

@ -40,6 +40,10 @@ const setDefaultSettings = (force: boolean) => {
settings.setSync('transcode', false); settings.setSync('transcode', false);
} }
if (force || !settings.hasSync('resume')) {
settings.setSync('resume', false);
}
if (force || !settings.hasSync('autoUpdate')) { if (force || !settings.hasSync('autoUpdate')) {
settings.setSync('autoUpdate', true); settings.setSync('autoUpdate', true);
} }

93
src/hooks/usePlayerControls.ts

@ -1,11 +1,16 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import settings from 'electron-settings'; import settings from 'electron-settings';
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import { deflate, inflate } from 'zlib';
import { join } from 'path';
import { access, constants, readFile, writeFile } from 'fs';
import { useAppDispatch } from '../redux/hooks'; import { useAppDispatch } from '../redux/hooks';
import { import {
decrementCurrentIndex, decrementCurrentIndex,
fixPlayer2Index, fixPlayer2Index,
incrementCurrentIndex, incrementCurrentIndex,
PlayQueueSaveState,
restoreState,
setVolume, setVolume,
toggleDisplayQueue, toggleDisplayQueue,
toggleRepeat, toggleRepeat,
@ -325,6 +330,82 @@ const usePlayerControls = (
dispatch(toggleDisplayQueue()); dispatch(toggleDisplayQueue());
}; };
const handleSaveQueue = useCallback(
(path: string) => {
const queueLocation = join(path, 'queue');
const data: PlayQueueSaveState = {
entry: playQueue.entry,
shuffledEntry: playQueue.shuffledEntry,
// current song
current: playQueue.current,
currentIndex: playQueue.currentIndex,
currentSongId: playQueue.currentSongId,
currentSongUniqueId: playQueue.currentSongUniqueId,
// players
player1: playQueue.player1,
player2: playQueue.player2,
currentPlayer: playQueue.currentPlayer,
};
const dataString = JSON.stringify(data);
// This whole compression task is actually quite quick
// While we could add a notify toast, it would only show for a moment
// before compression would finish.
// Compression level 1 seems to give sufficient performance, as it was able to save
// around 10k songs by using ~3.5 MB while still being quite fast.
deflate(
dataString,
{
level: 1,
},
(error, deflated) => {
if (error) {
ipcRenderer.send('saved-state');
} else {
writeFile(queueLocation, deflated, (writeError) => {
if (writeError) console.error(writeError);
ipcRenderer.send('saved-state');
});
}
}
);
},
[playQueue]
);
const handleRestoreQueue = useCallback(
(path: string) => {
const queueLocation = join(path, 'queue');
access(queueLocation, constants.F_OK, (accessError) => {
// If the file doesn't exist or we can't access it, just don't try
if (accessError) {
console.error(accessError);
return;
}
readFile(queueLocation, (error, buffer) => {
if (error) {
console.error(error);
return;
}
inflate(buffer, (decompressError, data) => {
if (decompressError) {
console.error(decompressError);
} else {
dispatch(restoreState(JSON.parse(data.toString())));
}
});
});
});
},
[dispatch]
);
useEffect(() => { useEffect(() => {
ipcRenderer.on('player-next-track', () => { ipcRenderer.on('player-next-track', () => {
handleNextTrack(); handleNextTrack();
@ -358,6 +439,14 @@ const usePlayerControls = (
handleRepeat(); handleRepeat();
}); });
ipcRenderer.on('save-queue-state', (_event, path: string) => {
handleSaveQueue(path);
});
ipcRenderer.on('restore-queue-state', (_event, path: string) => {
handleRestoreQueue(path);
});
return () => { return () => {
ipcRenderer.removeAllListeners('player-next-track'); ipcRenderer.removeAllListeners('player-next-track');
ipcRenderer.removeAllListeners('player-prev-track'); ipcRenderer.removeAllListeners('player-prev-track');
@ -367,6 +456,8 @@ const usePlayerControls = (
ipcRenderer.removeAllListeners('player-stop'); ipcRenderer.removeAllListeners('player-stop');
ipcRenderer.removeAllListeners('player-shuffle'); ipcRenderer.removeAllListeners('player-shuffle');
ipcRenderer.removeAllListeners('player-repeat'); ipcRenderer.removeAllListeners('player-repeat');
ipcRenderer.removeAllListeners('save-queue-state');
ipcRenderer.removeAllListeners('restore-queue-state');
}; };
}, [ }, [
handleNextTrack, handleNextTrack,
@ -377,6 +468,8 @@ const usePlayerControls = (
handleRepeat, handleRepeat,
handleShuffle, handleShuffle,
handleStop, handleStop,
handleSaveQueue,
handleRestoreQueue,
]); ]);
useEffect(() => { useEffect(() => {

2
src/i18n/locales/en.json

@ -238,6 +238,7 @@
"Regular": "Regular", "Regular": "Regular",
"Related Artists": "Related Artists", "Related Artists": "Related Artists",
"Release Date": "Release Date", "Release Date": "Release Date",
"Remember play queue on startup. The current Now Playing queue will be saved on exiting, and will be restored when you reopen Sonixd. Be warned that you should manually close Sonixd for the queue to be saved. An improper shutdown (such as the app closing during a shutdown or force quitting) may result in history not being saved.": "Remember play queue on startup. The current Now Playing queue will be saved on exiting, and will be restored when you reopen Sonixd. Be warned that you should manually close Sonixd for the queue to be saved. An improper shutdown (such as the app closing during a shutdown or force quitting) may result in history not being saved.",
"Remove from favorites": "Remove from favorites", "Remove from favorites": "Remove from favorites",
"Remove selected": "Remove selected", "Remove selected": "Remove selected",
"Repeat": "Repeat", "Repeat": "Repeat",
@ -249,6 +250,7 @@
"Reset to default": "Reset to default", "Reset to default": "Reset to default",
"Resizable": "Resizable", "Resizable": "Resizable",
"Restart?": "Restart?", "Restart?": "Restart?",
"Resume Playback": "Resume Playback",
"Rich Presence": "Rich Presence", "Rich Presence": "Rich Presence",
"Row Height {{type}}": "Row Height {{type}}", "Row Height {{type}}": "Row Height {{type}}",
"Save": "Save", "Save": "Save",

32
src/main.dev.js

@ -50,6 +50,7 @@ let mainWindow = null;
let tray = null; let tray = null;
let exitFromTray = false; let exitFromTray = false;
let forceQuit = false; let forceQuit = false;
let saved = false;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
const sourceMapSupport = require('source-map-support'); const sourceMapSupport = require('source-map-support');
@ -394,6 +395,18 @@ const createWinThumbarButtons = () => {
} }
}; };
const saveQueue = (callback) => {
ipcMain.on('saved-state', () => {
callback();
});
mainWindow.webContents.send('save-queue-state', app.getPath('userData'));
};
const restoreQueue = () => {
mainWindow.webContents.send('restore-queue-state', app.getPath('userData'));
};
const createWindow = async () => { const createWindow = async () => {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') { if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
await installExtensions(); await installExtensions();
@ -517,6 +530,10 @@ const createWindow = async () => {
createWinThumbarButtons(); createWinThumbarButtons();
} }
if (settings.getSync('resume')) {
restoreQueue();
}
}); });
mainWindow.on('minimize', (event) => { mainWindow.on('minimize', (event) => {
@ -540,8 +557,17 @@ const createWindow = async () => {
event.preventDefault(); event.preventDefault();
mainWindow.hide(); mainWindow.hide();
} }
if (forceQuit) {
app.exit(); // If we have enabled saving the queue, we need to defer closing the main window until it has finished saving.
if (!saved && settings.getSync('resume')) {
event.preventDefault();
saved = true;
saveQueue(() => {
mainWindow.close();
if (forceQuit) {
app.exit();
}
});
} }
}); });
@ -706,7 +732,7 @@ app.on('window-all-closed', () => {
// Respect the OSX convention of having the application in memory even // Respect the OSX convention of having the application in memory even
// after all windows have been closed // after all windows have been closed
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
if (process.platform === 'darwin') { if (isMacOS()) {
mainWindow = null; mainWindow = null;
} else { } else {
app.quit(); app.quit();

30
src/redux/playQueueSlice.ts

@ -58,6 +58,19 @@ export interface PlayQueue {
sortedEntry: Song[]; sortedEntry: Song[];
} }
export type PlayQueueSaveState = Pick<
PlayQueue,
| 'entry'
| 'shuffledEntry'
| 'current'
| 'currentIndex'
| 'currentSongId'
| 'currentSongUniqueId'
| 'player1'
| 'player2'
| 'currentPlayer'
>;
const initialState: PlayQueue = { const initialState: PlayQueue = {
player1: { player1: {
src: './components/player/dummy.mp3', src: './components/player/dummy.mp3',
@ -975,6 +988,22 @@ const playQueueSlice = createSlice({
state.currentIndex = newCurrentSongIndex; state.currentIndex = newCurrentSongIndex;
}, },
restoreState: (state, action: PayloadAction<PlayQueueSaveState>) => {
const result = action.payload;
state.entry = result.entry;
state.shuffledEntry = result.shuffledEntry;
state.current = result.current;
state.currentIndex = result.currentIndex;
state.currentSongId = result.currentSongId;
state.currentSongUniqueId = result.currentSongUniqueId;
state.player1 = result.player1;
state.player2 = result.player2;
state.currentPlayer = result.currentPlayer;
},
}, },
}); });
@ -1014,5 +1043,6 @@ export const {
shuffleInPlace, shuffleInPlace,
setFadeData, setFadeData,
setPlaybackSetting, setPlaybackSetting,
restoreState,
} = playQueueSlice.actions; } = playQueueSlice.actions;
export default playQueueSlice.reducer; export default playQueueSlice.reducer;

8
src/redux/store.ts

@ -1,4 +1,4 @@
import { configureStore } from '@reduxjs/toolkit'; import { AnyAction, configureStore, Dispatch, EnhancedStore, Middleware } from '@reduxjs/toolkit';
import { forwardToMain, replayActionRenderer } from 'electron-redux'; import { forwardToMain, replayActionRenderer } from 'electron-redux';
import playerReducer from './playerSlice'; import playerReducer from './playerSlice';
import playQueueReducer, { PlayQueue } from './playQueueSlice'; import playQueueReducer, { PlayQueue } from './playQueueSlice';
@ -11,7 +11,11 @@ import favoriteReducer from './favoriteSlice';
import artistReducer from './artistSlice'; import artistReducer from './artistSlice';
import viewReducer from './viewSlice'; import viewReducer from './viewSlice';
export const store = configureStore<PlayQueue | any>({ export const store: EnhancedStore<
any,
AnyAction,
[Middleware<Record<string, any>, any, Dispatch<AnyAction>>]
> = configureStore<PlayQueue | any>({
reducer: { reducer: {
player: playerReducer, player: playerReducer,
playQueue: playQueueReducer, playQueue: playQueueReducer,

Loading…
Cancel
Save