mirror of
https://github.com/jeffvli/feishin.git
synced 2024-11-20 06:27:09 +01:00
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:
parent
c1c6ce33e4
commit
106fc90c4a
0
.husky/pre-commit
Normal file → Executable file
0
.husky/pre-commit
Normal file → Executable 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
|
// 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) {
|
if (!data.queue.current && !data.queue.next) {
|
||||||
await getMpvInstance()?.clearPlaylist();
|
await getMpvInstance()?.clearPlaylist();
|
||||||
await getMpvInstance()?.pause();
|
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
|
// Replaces the queue in position 1 to the given data
|
||||||
|
@ -8,7 +8,9 @@
|
|||||||
* When running `npm run build` or `npm run build:main`, this file is compiled to
|
* 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.
|
* `./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 {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
@ -239,6 +241,36 @@ const createWindow = async () => {
|
|||||||
disableMediaKeys();
|
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;
|
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
|
||||||
|
|
||||||
if (globalMediaKeysEnabled) {
|
if (globalMediaKeysEnabled) {
|
||||||
@ -263,6 +295,8 @@ const createWindow = async () => {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let saved = false;
|
||||||
|
|
||||||
mainWindow.on('close', (event) => {
|
mainWindow.on('close', (event) => {
|
||||||
if (!exitFromTray && store.get('window_exit_to_tray')) {
|
if (!exitFromTray && store.get('window_exit_to_tray')) {
|
||||||
if (isMacOS() && !forceQuit) {
|
if (isMacOS() && !forceQuit) {
|
||||||
@ -271,6 +305,43 @@ const createWindow = async () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
mainWindow?.hide();
|
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) => {
|
mainWindow.on('minimize', (event: any) => {
|
||||||
|
@ -42,6 +42,14 @@ const previous = () => {
|
|||||||
ipcRenderer.send('player-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) => {
|
const seek = (seconds: number) => {
|
||||||
ipcRenderer.send('player-seek', seconds);
|
ipcRenderer.send('player-seek', seconds);
|
||||||
};
|
};
|
||||||
@ -50,8 +58,8 @@ const seekTo = (seconds: number) => {
|
|||||||
ipcRenderer.send('player-seek-to', seconds);
|
ipcRenderer.send('player-seek-to', seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQueue = (data: PlayerData) => {
|
const setQueue = (data: PlayerData, pause?: boolean) => {
|
||||||
ipcRenderer.send('player-set-queue', data);
|
ipcRenderer.send('player-set-queue', data, pause);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQueueNext = (data: PlayerData) => {
|
const setQueueNext = (data: PlayerData) => {
|
||||||
@ -134,6 +142,14 @@ const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
|
|||||||
ipcRenderer.on('renderer-player-quit', cb);
|
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) => {
|
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
|
||||||
ipcRenderer.on('renderer-player-error', cb);
|
ipcRenderer.on('renderer-player-error', cb);
|
||||||
};
|
};
|
||||||
@ -149,6 +165,8 @@ export const mpvPlayer = {
|
|||||||
previous,
|
previous,
|
||||||
quit,
|
quit,
|
||||||
restart,
|
restart,
|
||||||
|
restoreQueue,
|
||||||
|
saveQueue,
|
||||||
seek,
|
seek,
|
||||||
seekTo,
|
seekTo,
|
||||||
setProperties,
|
setProperties,
|
||||||
@ -168,6 +186,8 @@ export const mpvPlayerListener = {
|
|||||||
rendererPlayPause,
|
rendererPlayPause,
|
||||||
rendererPrevious,
|
rendererPrevious,
|
||||||
rendererQuit,
|
rendererQuit,
|
||||||
|
rendererRestoreQueue,
|
||||||
|
rendererSaveQueue,
|
||||||
rendererSkipBackward,
|
rendererSkipBackward,
|
||||||
rendererSkipForward,
|
rendererSkipForward,
|
||||||
rendererStop,
|
rendererStop,
|
||||||
|
@ -17,14 +17,15 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
|||||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||||
import { usePlayerStore } from '/@/renderer/store';
|
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||||
import { PlaybackType } from '/@/renderer/types';
|
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||||
|
|
||||||
initSimpleImg({ threshold: 0.05 }, true);
|
initSimpleImg({ threshold: 0.05 }, true);
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
|
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
||||||
const ipc = isElectron() ? window.electron.ipc : null;
|
const ipc = isElectron() ? window.electron.ipc : null;
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
@ -33,6 +34,7 @@ export const App = () => {
|
|||||||
const { type: playbackType } = usePlaybackSettings();
|
const { type: playbackType } = usePlaybackSettings();
|
||||||
const { bindings } = useHotkeySettings();
|
const { bindings } = useHotkeySettings();
|
||||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||||
|
const { restoreQueue } = useQueueControls();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@ -65,6 +67,36 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
}, [bindings]);
|
}, [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 (
|
return (
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
withGlobalStyles
|
withGlobalStyles
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// import { write, writeFile } from 'fs';
|
||||||
|
// import { deflate } from 'zlib';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
|
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import isElectron from 'is-electron';
|
||||||
import { Group } from '@mantine/core';
|
import { Group } from '@mantine/core';
|
||||||
import { Select, Tooltip, NumberInput, Switch, Slider } from '/@/renderer/components';
|
import { Select, Tooltip, NumberInput, Switch, Slider } from '/@/renderer/components';
|
||||||
import { SettingsSection } from '/@/renderer/features/settings/components/settings-section';
|
import { SettingsSection } from '/@/renderer/features/settings/components/settings-section';
|
||||||
@ -8,6 +9,8 @@ import {
|
|||||||
} from '/@/renderer/store/settings.store';
|
} from '/@/renderer/store/settings.store';
|
||||||
import { Play } from '/@/renderer/types';
|
import { Play } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||||
|
|
||||||
const SIDE_QUEUE_OPTIONS = [
|
const SIDE_QUEUE_OPTIONS = [
|
||||||
{ label: 'Fixed', value: 'sideQueue' },
|
{ label: 'Fixed', value: 'sideQueue' },
|
||||||
{ label: 'Floating', value: 'sideDrawerQueue' },
|
{ label: 'Floating', value: 'sideDrawerQueue' },
|
||||||
@ -170,6 +173,25 @@ export const ControlSettings = () => {
|
|||||||
isHidden: false,
|
isHidden: false,
|
||||||
title: 'Volume wheel step',
|
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} />;
|
return <SettingsSection options={controlOptions} />;
|
||||||
|
6
src/renderer/preload.d.ts
vendored
6
src/renderer/preload.d.ts
vendored
@ -1,5 +1,5 @@
|
|||||||
import { IpcRendererEvent } from 'electron';
|
import { IpcRendererEvent } from 'electron';
|
||||||
import { PlayerData } from './store';
|
import { PlayerData, PlayerState } from './store';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -17,6 +17,8 @@ declare global {
|
|||||||
PLAYER_PAUSE(): void;
|
PLAYER_PAUSE(): void;
|
||||||
PLAYER_PLAY(): void;
|
PLAYER_PLAY(): void;
|
||||||
PLAYER_PREVIOUS(): void;
|
PLAYER_PREVIOUS(): void;
|
||||||
|
PLAYER_RESTORE_DATA(): void;
|
||||||
|
PLAYER_SAVE_QUEUE(data: PlayerState): void;
|
||||||
PLAYER_SEEK(seconds: number): void;
|
PLAYER_SEEK(seconds: number): void;
|
||||||
PLAYER_SEEK_TO(seconds: number): void;
|
PLAYER_SEEK_TO(seconds: number): void;
|
||||||
PLAYER_SET_QUEUE(data: PlayerData): 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(cb: (event: IpcRendererEvent, data: any) => void): void;
|
||||||
RENDERER_PLAYER_PLAY_PAUSE(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_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;
|
RENDERER_PLAYER_STOP(cb: (event: IpcRendererEvent, data: any) => void): void;
|
||||||
SETTINGS_GET(data: { property: string }): any;
|
SETTINGS_GET(data: { property: string }): any;
|
||||||
SETTINGS_SET(data: { property: string; value: any }): void;
|
SETTINGS_SET(data: { property: string; value: any }): void;
|
||||||
|
@ -74,6 +74,7 @@ export interface PlayerSlice extends PlayerState {
|
|||||||
previous: () => PlayerData;
|
previous: () => PlayerData;
|
||||||
removeFromQueue: (uniqueIds: string[]) => PlayerData;
|
removeFromQueue: (uniqueIds: string[]) => PlayerData;
|
||||||
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData;
|
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData;
|
||||||
|
restoreQueue: (data: Partial<PlayerState>) => PlayerData;
|
||||||
setCurrentIndex: (index: number) => PlayerData;
|
setCurrentIndex: (index: number) => PlayerData;
|
||||||
setCurrentTime: (time: number) => void;
|
setCurrentTime: (time: number) => void;
|
||||||
setCurrentTrack: (uniqueId: string) => PlayerData;
|
setCurrentTrack: (uniqueId: string) => PlayerData;
|
||||||
@ -615,6 +616,20 @@ export const usePlayerStore = create<PlayerSlice>()(
|
|||||||
|
|
||||||
return get().actions.getPlayerData();
|
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) => {
|
setCurrentIndex: (index) => {
|
||||||
if (get().shuffle === PlayerShuffle.TRACK) {
|
if (get().shuffle === PlayerShuffle.TRACK) {
|
||||||
const foundSong = get().queue.default.find(
|
const foundSong = get().queue.default.find(
|
||||||
@ -874,6 +889,7 @@ export const useQueueControls = () =>
|
|||||||
moveToTopOfQueue: state.actions.moveToTopOfQueue,
|
moveToTopOfQueue: state.actions.moveToTopOfQueue,
|
||||||
removeFromQueue: state.actions.removeFromQueue,
|
removeFromQueue: state.actions.removeFromQueue,
|
||||||
reorderQueue: state.actions.reorderQueue,
|
reorderQueue: state.actions.reorderQueue,
|
||||||
|
restoreQueue: state.actions.restoreQueue,
|
||||||
setCurrentIndex: state.actions.setCurrentIndex,
|
setCurrentIndex: state.actions.setCurrentIndex,
|
||||||
setCurrentTrack: state.actions.setCurrentTrack,
|
setCurrentTrack: state.actions.setCurrentTrack,
|
||||||
setShuffledIndex: state.actions.setShuffledIndex,
|
setShuffledIndex: state.actions.setShuffledIndex,
|
||||||
|
@ -69,6 +69,7 @@ export interface SettingsState {
|
|||||||
followSystemTheme: boolean;
|
followSystemTheme: boolean;
|
||||||
fontContent: string;
|
fontContent: string;
|
||||||
playButtonBehavior: Play;
|
playButtonBehavior: Play;
|
||||||
|
resume: boolean;
|
||||||
showQueueDrawerButton: boolean;
|
showQueueDrawerButton: boolean;
|
||||||
sideQueueType: SideQueueType;
|
sideQueueType: SideQueueType;
|
||||||
skipButtons: {
|
skipButtons: {
|
||||||
@ -128,6 +129,7 @@ const initialState: SettingsState = {
|
|||||||
followSystemTheme: false,
|
followSystemTheme: false,
|
||||||
fontContent: 'Poppins',
|
fontContent: 'Poppins',
|
||||||
playButtonBehavior: Play.NOW,
|
playButtonBehavior: Play.NOW,
|
||||||
|
resume: false,
|
||||||
showQueueDrawerButton: false,
|
showQueueDrawerButton: false,
|
||||||
sideQueueType: 'sideQueue',
|
sideQueueType: 'sideQueue',
|
||||||
skipButtons: {
|
skipButtons: {
|
||||||
|
Loading…
Reference in New Issue
Block a user