diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index 601b398e..e1e97f85 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -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 diff --git a/src/main/main.ts b/src/main/main.ts index cb5efe18..c7401a62 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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) => { + const queueLocation = join(app.getPath('userData'), 'queue'); + const serialized = JSON.stringify(data); + + try { + await new Promise((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) => { diff --git a/src/main/preload/mpv-player.ts b/src/main/preload/mpv-player.ts index bde8d91b..d06cc2eb 100644 --- a/src/main/preload/mpv-player.ts +++ b/src/main/preload/mpv-player.ts @@ -42,6 +42,14 @@ const previous = () => { ipcRenderer.send('player-previous'); }; +const restoreQueue = () => { + ipcRenderer.send('player-restore-queue'); +}; + +const saveQueue = (data: Record) => { + 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, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index c02251cd..9949bee2 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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> = { + current: { + ...current, + status: PlayerStatus.PAUSED, + }, + queue, + }; + mpvPlayer.saveQueue(stateToSave); + }); + + mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial) => { + 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 ( { isHidden: false, title: 'Volume wheel step', }, + { + control: ( + { + 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 ; diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index 4f39b07a..0aec949b 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -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; diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index 67ae782c..12da5323 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -74,6 +74,7 @@ export interface PlayerSlice extends PlayerState { previous: () => PlayerData; removeFromQueue: (uniqueIds: string[]) => PlayerData; reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData; + restoreQueue: (data: Partial) => PlayerData; setCurrentIndex: (index: number) => PlayerData; setCurrentTime: (time: number) => void; setCurrentTrack: (uniqueId: string) => PlayerData; @@ -615,6 +616,20 @@ export const usePlayerStore = create()( 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, diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 3be8e189..232c8a73 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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: {