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
This commit is contained in:
Kendall Garner
2022-04-12 02:17:38 +00:00
committed by GitHub
parent e8395fe40d
commit b3de3be4a6
7 changed files with 186 additions and 6 deletions

View File

@@ -81,6 +81,7 @@ const PlayerConfig = ({ bordered }: any) => {
const [systemMediaTransportControls, setSystemMediaTransportControls] = useState(
Boolean(settings.getSync('systemMediaTransportControls'))
);
const [resume, setResume] = useState(Boolean(settings.getSync('resume')));
const [scrobble, setScrobble] = useState(Boolean(settings.getSync('scrobble')));
const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>();
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 && (
<ConfigOption
name={t('Allow Transcoding')}

View File

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

View File

@@ -1,11 +1,16 @@
import { useCallback, useEffect } from 'react';
import settings from 'electron-settings';
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 {
decrementCurrentIndex,
fixPlayer2Index,
incrementCurrentIndex,
PlayQueueSaveState,
restoreState,
setVolume,
toggleDisplayQueue,
toggleRepeat,
@@ -325,6 +330,82 @@ const usePlayerControls = (
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(() => {
ipcRenderer.on('player-next-track', () => {
handleNextTrack();
@@ -358,6 +439,14 @@ const usePlayerControls = (
handleRepeat();
});
ipcRenderer.on('save-queue-state', (_event, path: string) => {
handleSaveQueue(path);
});
ipcRenderer.on('restore-queue-state', (_event, path: string) => {
handleRestoreQueue(path);
});
return () => {
ipcRenderer.removeAllListeners('player-next-track');
ipcRenderer.removeAllListeners('player-prev-track');
@@ -367,6 +456,8 @@ const usePlayerControls = (
ipcRenderer.removeAllListeners('player-stop');
ipcRenderer.removeAllListeners('player-shuffle');
ipcRenderer.removeAllListeners('player-repeat');
ipcRenderer.removeAllListeners('save-queue-state');
ipcRenderer.removeAllListeners('restore-queue-state');
};
}, [
handleNextTrack,
@@ -377,6 +468,8 @@ const usePlayerControls = (
handleRepeat,
handleShuffle,
handleStop,
handleSaveQueue,
handleRestoreQueue,
]);
useEffect(() => {

View File

@@ -238,6 +238,7 @@
"Regular": "Regular",
"Related Artists": "Related Artists",
"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 selected": "Remove selected",
"Repeat": "Repeat",
@@ -249,6 +250,7 @@
"Reset to default": "Reset to default",
"Resizable": "Resizable",
"Restart?": "Restart?",
"Resume Playback": "Resume Playback",
"Rich Presence": "Rich Presence",
"Row Height {{type}}": "Row Height {{type}}",
"Save": "Save",

View File

@@ -50,6 +50,7 @@ let mainWindow = null;
let tray = null;
let exitFromTray = false;
let forceQuit = false;
let saved = false;
if (process.env.NODE_ENV === 'production') {
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 () => {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
await installExtensions();
@@ -517,6 +530,10 @@ const createWindow = async () => {
createWinThumbarButtons();
}
if (settings.getSync('resume')) {
restoreQueue();
}
});
mainWindow.on('minimize', (event) => {
@@ -540,8 +557,17 @@ const createWindow = async () => {
event.preventDefault();
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
// after all windows have been closed
globalShortcut.unregisterAll();
if (process.platform === 'darwin') {
if (isMacOS()) {
mainWindow = null;
} else {
app.quit();

View File

@@ -58,6 +58,19 @@ export interface PlayQueue {
sortedEntry: Song[];
}
export type PlayQueueSaveState = Pick<
PlayQueue,
| 'entry'
| 'shuffledEntry'
| 'current'
| 'currentIndex'
| 'currentSongId'
| 'currentSongUniqueId'
| 'player1'
| 'player2'
| 'currentPlayer'
>;
const initialState: PlayQueue = {
player1: {
src: './components/player/dummy.mp3',
@@ -975,6 +988,22 @@ const playQueueSlice = createSlice({
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,
setFadeData,
setPlaybackSetting,
restoreState,
} = playQueueSlice.actions;
export default playQueueSlice.reducer;

View File

@@ -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 playerReducer from './playerSlice';
import playQueueReducer, { PlayQueue } from './playQueueSlice';
@@ -11,7 +11,11 @@ import favoriteReducer from './favoriteSlice';
import artistReducer from './artistSlice';
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: {
player: playerReducer,
playQueue: playQueueReducer,