Add ability to save/restore queue (#111)

* add ability to save/restore play queue

* Add restoreQueue action

* Add optional pause param on setQueue

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Kendall Garner 2023-05-21 09:29:58 +00:00 committed by GitHub
parent c1c6ce33e4
commit 106fc90c4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 180 additions and 7 deletions

0
.husky/pre-commit Normal file → Executable file
View File

View File

@ -52,7 +52,7 @@ ipcMain.on('player-seek-to', async (_event, time: number) => {
});
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
if (!data.queue.current && !data.queue.next) {
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
@ -84,6 +84,10 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
}
}
}
if (pause) {
await getMpvInstance()?.pause();
}
});
// Replaces the queue in position 1 to the given data

View File

@ -8,7 +8,9 @@
* When running `npm run build` or `npm run build:main`, this file is compiled to
* `./src/main.js` using webpack. This gives us some performance wins.
*/
import path from 'path';
import { access, constants, readFile, writeFile } from 'fs';
import path, { join } from 'path';
import { deflate, inflate } from 'zlib';
import {
app,
BrowserWindow,
@ -239,6 +241,36 @@ const createWindow = async () => {
disableMediaKeys();
});
ipcMain.on('player-restore-queue', () => {
if (store.get('resume')) {
const queueLocation = join(app.getPath('userData'), 'queue');
access(queueLocation, constants.F_OK, (accessError) => {
if (accessError) {
console.error('unable to access saved queue: ', accessError);
return;
}
readFile(queueLocation, (readError, buffer) => {
if (readError) {
console.error('failed to read saved queue: ', readError);
return;
}
inflate(buffer, (decompressError, data) => {
if (decompressError) {
console.error('failed to decompress queue: ', decompressError);
return;
}
const queue = JSON.parse(data.toString());
getMainWindow()?.webContents.send('renderer-player-restore-queue', queue);
});
});
});
}
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) {
@ -263,6 +295,8 @@ const createWindow = async () => {
mainWindow = null;
});
let saved = false;
mainWindow.on('close', (event) => {
if (!exitFromTray && store.get('window_exit_to_tray')) {
if (isMacOS() && !forceQuit) {
@ -271,6 +305,43 @@ const createWindow = async () => {
event.preventDefault();
mainWindow?.hide();
}
if (!saved && store.get('resume')) {
event.preventDefault();
saved = true;
getMainWindow()?.webContents.send('renderer-player-save-queue');
ipcMain.once('player-save-queue', async (_event, data: Record<string, any>) => {
const queueLocation = join(app.getPath('userData'), 'queue');
const serialized = JSON.stringify(data);
try {
await new Promise<void>((resolve, reject) => {
deflate(serialized, { level: 1 }, (error, deflated) => {
if (error) {
reject(error);
} else {
writeFile(queueLocation, deflated, (writeError) => {
if (writeError) {
reject(writeError);
} else {
resolve();
}
});
}
});
});
} catch (error) {
console.error('error saving queue state: ', error);
} finally {
mainWindow?.close();
if (forceQuit) {
app.exit();
}
}
});
}
});
mainWindow.on('minimize', (event: any) => {

View File

@ -42,6 +42,14 @@ const previous = () => {
ipcRenderer.send('player-previous');
};
const restoreQueue = () => {
ipcRenderer.send('player-restore-queue');
};
const saveQueue = (data: Record<string, any>) => {
ipcRenderer.send('player-save-queue', data);
};
const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds);
};
@ -50,8 +58,8 @@ const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds);
};
const setQueue = (data: PlayerData) => {
ipcRenderer.send('player-set-queue', data);
const setQueue = (data: PlayerData, pause?: boolean) => {
ipcRenderer.send('player-set-queue', data, pause);
};
const setQueueNext = (data: PlayerData) => {
@ -134,6 +142,14 @@ const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-quit', cb);
};
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-save-queue', cb);
};
const rendererRestoreQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-restore-queue', cb);
};
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
ipcRenderer.on('renderer-player-error', cb);
};
@ -149,6 +165,8 @@ export const mpvPlayer = {
previous,
quit,
restart,
restoreQueue,
saveQueue,
seek,
seekTo,
setProperties,
@ -168,6 +186,8 @@ export const mpvPlayerListener = {
rendererPlayPause,
rendererPrevious,
rendererQuit,
rendererRestoreQueue,
rendererSaveQueue,
rendererSkipBackward,
rendererSkipForward,
rendererStop,

View File

@ -17,14 +17,15 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
import isElectron from 'is-electron';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { usePlayerStore } from '/@/renderer/store';
import { PlaybackType } from '/@/renderer/types';
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
initSimpleImg({ threshold: 0.05 }, true);
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
const ipc = isElectron() ? window.electron.ipc : null;
export const App = () => {
@ -33,6 +34,7 @@ export const App = () => {
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { restoreQueue } = useQueueControls();
useEffect(() => {
const root = document.documentElement;
@ -65,6 +67,36 @@ export const App = () => {
}
}, [bindings]);
useEffect(() => {
if (isElectron()) {
mpvPlayer.restoreQueue();
mpvPlayerListener.rendererSaveQueue(() => {
const { current, queue } = usePlayerStore.getState();
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
current: {
...current,
status: PlayerStatus.PAUSED,
},
queue,
};
mpvPlayer.saveQueue(stateToSave);
});
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
const playerData = restoreQueue(data);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData, true);
}
});
}
return () => {
ipc?.removeAllListeners('renderer-player-restore-queue');
ipc?.removeAllListeners('renderer-player-save-queue');
};
}, [playbackType, restoreQueue]);
return (
<MantineProvider
withGlobalStyles

View File

@ -1,3 +1,5 @@
// import { write, writeFile } from 'fs';
// import { deflate } from 'zlib';
import { useCallback, useEffect } from 'react';
import isElectron from 'is-electron';
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';

View File

@ -1,3 +1,4 @@
import isElectron from 'is-electron';
import { Group } from '@mantine/core';
import { Select, Tooltip, NumberInput, Switch, Slider } from '/@/renderer/components';
import { SettingsSection } from '/@/renderer/features/settings/components/settings-section';
@ -8,6 +9,8 @@ import {
} from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
const localSettings = isElectron() ? window.electron.localSettings : null;
const SIDE_QUEUE_OPTIONS = [
{ label: 'Fixed', value: 'sideQueue' },
{ label: 'Floating', value: 'sideDrawerQueue' },
@ -170,6 +173,25 @@ export const ControlSettings = () => {
isHidden: false,
title: 'Volume wheel step',
},
{
control: (
<Switch
defaultChecked={settings.resume}
onChange={(e) => {
localSettings?.set('resume', e.target.checked);
setSettings({
general: {
...settings,
resume: e.currentTarget.checked,
},
});
}}
/>
),
description: 'When exiting, save the current play queue and restore it when reopening',
isHidden: !isElectron(),
title: 'Save play queue',
},
];
return <SettingsSection options={controlOptions} />;

View File

@ -1,5 +1,5 @@
import { IpcRendererEvent } from 'electron';
import { PlayerData } from './store';
import { PlayerData, PlayerState } from './store';
declare global {
interface Window {
@ -17,6 +17,8 @@ declare global {
PLAYER_PAUSE(): void;
PLAYER_PLAY(): void;
PLAYER_PREVIOUS(): void;
PLAYER_RESTORE_DATA(): void;
PLAYER_SAVE_QUEUE(data: PlayerState): void;
PLAYER_SEEK(seconds: number): void;
PLAYER_SEEK_TO(seconds: number): void;
PLAYER_SET_QUEUE(data: PlayerData): void;
@ -30,6 +32,8 @@ declare global {
RENDERER_PLAYER_PLAY(cb: (event: IpcRendererEvent, data: any) => void): void;
RENDERER_PLAYER_PLAY_PAUSE(cb: (event: IpcRendererEvent, data: any) => void): void;
RENDERER_PLAYER_PREVIOUS(cb: (event: IpcRendererEvent, data: any) => void): void;
RENDERER_PLAYER_RESTORE_QUEUE(cb: (event: IpcRendererEvent, data: any) => void): void;
RENDERER_PLAYER_SAVE_QUEUE(cb: (event: IpcRendererEvent, data: any) => void): void;
RENDERER_PLAYER_STOP(cb: (event: IpcRendererEvent, data: any) => void): void;
SETTINGS_GET(data: { property: string }): any;
SETTINGS_SET(data: { property: string; value: any }): void;

View File

@ -74,6 +74,7 @@ export interface PlayerSlice extends PlayerState {
previous: () => PlayerData;
removeFromQueue: (uniqueIds: string[]) => PlayerData;
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData;
restoreQueue: (data: Partial<PlayerState>) => PlayerData;
setCurrentIndex: (index: number) => PlayerData;
setCurrentTime: (time: number) => void;
setCurrentTrack: (uniqueId: string) => PlayerData;
@ -615,6 +616,20 @@ export const usePlayerStore = create<PlayerSlice>()(
return get().actions.getPlayerData();
},
restoreQueue: (data) => {
set((state) => {
state.current = {
...state.current,
...data.current,
};
state.queue = {
...state.queue,
...data.queue,
};
});
return get().actions.getPlayerData();
},
setCurrentIndex: (index) => {
if (get().shuffle === PlayerShuffle.TRACK) {
const foundSong = get().queue.default.find(
@ -874,6 +889,7 @@ export const useQueueControls = () =>
moveToTopOfQueue: state.actions.moveToTopOfQueue,
removeFromQueue: state.actions.removeFromQueue,
reorderQueue: state.actions.reorderQueue,
restoreQueue: state.actions.restoreQueue,
setCurrentIndex: state.actions.setCurrentIndex,
setCurrentTrack: state.actions.setCurrentTrack,
setShuffledIndex: state.actions.setShuffledIndex,

View File

@ -69,6 +69,7 @@ export interface SettingsState {
followSystemTheme: boolean;
fontContent: string;
playButtonBehavior: Play;
resume: boolean;
showQueueDrawerButton: boolean;
sideQueueType: SideQueueType;
skipButtons: {
@ -128,6 +129,7 @@ const initialState: SettingsState = {
followSystemTheme: false,
fontContent: 'Poppins',
playButtonBehavior: Play.NOW,
resume: false,
showQueueDrawerButton: false,
sideQueueType: 'sideQueue',
skipButtons: {