diff --git a/src/components/settings/ConfigPanels/PlayerConfig.tsx b/src/components/settings/ConfigPanels/PlayerConfig.tsx index e4bc16a..c29c2bd 100644 --- a/src/components/settings/ConfigPanels/PlayerConfig.tsx +++ b/src/components/settings/ConfigPanels/PlayerConfig.tsx @@ -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(); const audioDevicePickerContainerRef = useRef(null); @@ -162,7 +163,27 @@ const PlayerConfig = ({ bordered }: any) => { /> } /> - + + 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. + + } + option={ + { + settings.setSync('resume', e); + setResume(e); + }} + /> + } + /> {config.serverType === Server.Jellyfin && ( { settings.setSync('transcode', false); } + if (force || !settings.hasSync('resume')) { + settings.setSync('resume', false); + } + if (force || !settings.hasSync('autoUpdate')) { settings.setSync('autoUpdate', true); } diff --git a/src/hooks/usePlayerControls.ts b/src/hooks/usePlayerControls.ts index b9d5ca1..31bc850 100644 --- a/src/hooks/usePlayerControls.ts +++ b/src/hooks/usePlayerControls.ts @@ -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(() => { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4aa98d9..ed1fd0f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/main.dev.js b/src/main.dev.js index d229481..c63b240 100644 --- a/src/main.dev.js +++ b/src/main.dev.js @@ -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(); diff --git a/src/redux/playQueueSlice.ts b/src/redux/playQueueSlice.ts index 9c519b6..4780138 100644 --- a/src/redux/playQueueSlice.ts +++ b/src/redux/playQueueSlice.ts @@ -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) => { + 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; diff --git a/src/redux/store.ts b/src/redux/store.ts index 2f1c414..c37b127 100644 --- a/src/redux/store.ts +++ b/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 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({ +export const store: EnhancedStore< + any, + AnyAction, + [Middleware, any, Dispatch>] +> = configureStore({ reducer: { player: playerReducer, playQueue: playQueueReducer,