diff --git a/dashboard/kbdsrct.md b/dashboard/kbdsrct.md new file mode 100644 index 000000000..cd47c81fe --- /dev/null +++ b/dashboard/kbdsrct.md @@ -0,0 +1,56 @@ +# Spotify Shortcuts + +For reference, only a few are gonna be implemented: + + +#### Basic + + - Create new playlist Alt+Shift+P + - Create new folder Ctrl+Alt+Shift+P + - Open context menu Alt+J + - Open Quick Search Ctrl+K + - Search in Your Library Shift+Ctrl+Alt+F + - Log out Alt+Shift+F6 + + +#### Playback + + - Play / Pause Space + - Like Alt+Shift+B + - Shuffle Alt+S + - Repeat Alt+R + - Skip to previous Alt+← + - Skip to next Alt+→ + - Seek backward Shift+← + - Seek forward Shift+→ + - Raise volume Alt+↑ + - Lower volume Alt+↓ + + +#### Navigation + + - Home Alt+Shift+H + - Back in history Ctrl+← + - Forward in history Ctrl+→ + - Currently playing Alt+Shift+J + - Search Ctrl+Shift+L + - Liked songs Alt+Shift+S + - Queue Alt+Shift+Q + - Your Library Alt+Shift+0 + - Your playlists Alt+Shift+1 + - Your podcasts Alt+Shift+2 + - Your artists Alt+Shift+3 + - Your albums Alt+Shift+4 + - Made for you Alt+Shift+M + - New Releases Alt+Shift+N + - Charts Alt+Shift+C + + +#### Layout + + - Toggle left sidebar Alt+Shift+L + - Decrease navigation bar width Alt+Shift+← + - Increase navigation bar width Alt+Shift+→ + - Toggle right sidebar Alt+Shift+R + - Decrease activity tab width Alt+Shift+↓ + - Increase activity tab width Alt+Shift+↑ diff --git a/dashboard/src/assets/icons/modal-close.svg b/dashboard/src/assets/icons/modal-close.svg new file mode 100644 index 000000000..87aaef7f4 --- /dev/null +++ b/dashboard/src/assets/icons/modal-close.svg @@ -0,0 +1 @@ +Close diff --git a/dashboard/src/components/Modal.tsx b/dashboard/src/components/Modal.tsx new file mode 100644 index 000000000..74a88f455 --- /dev/null +++ b/dashboard/src/components/Modal.tsx @@ -0,0 +1,22 @@ +import { IModalProps } from '@/interfaces/components/Modal'; + +const containerStyle: React.HTMLAttributes['style'] = { + position: 'fixed', + width: '100vw', + height: '100vh', + top: 0, + left: 0, + display: 'flex', + justifyContent: 'center', + zIndex: 500, +}; + +export default function Modal({ children, fullHeight, open }: IModalProps) { + if (!open) return null; + + const cpyStyles = { ...containerStyle }; + + if (!fullHeight) cpyStyles.alignItems = 'center'; + + return
{children}
; +} diff --git a/dashboard/src/components/ModalShortcut.tsx b/dashboard/src/components/ModalShortcut.tsx new file mode 100644 index 000000000..5eaba5732 --- /dev/null +++ b/dashboard/src/components/ModalShortcut.tsx @@ -0,0 +1,84 @@ +import Modal from '@/components/Modal'; +import { IModalShortcutProps } from '@/interfaces/components/Modal'; +import { Container } from '@nextui-org/react'; +import ModalCloseIcon from '@/assets/icons/modal-close.svg'; +import Key from '@/components/shortcuts/Key'; +import ShortcutEntry from './shortcuts/ShortcutEntry'; + +export default function ModalShortcut({ + open, + onClose, + modalContainerProps = {}, +}: IModalShortcutProps) { + return ( + + + +
+

Keyboard Shortcuts

+ + +
+
+ Press Ctrl + / to toggle this modal. +
+
+ +
+
+

Playback

+ + + +
+
+
+
+ ); +} diff --git a/dashboard/src/components/shortcuts/Key.tsx b/dashboard/src/components/shortcuts/Key.tsx new file mode 100644 index 000000000..396a4a8bc --- /dev/null +++ b/dashboard/src/components/shortcuts/Key.tsx @@ -0,0 +1,16 @@ +import { IKeyProps } from '@/interfaces/components/Shortcuts'; + +export default function Key({ children }: IKeyProps) { + return ( + + {children} + + ); +} diff --git a/dashboard/src/components/shortcuts/ShortcutEntry.tsx b/dashboard/src/components/shortcuts/ShortcutEntry.tsx new file mode 100644 index 000000000..5e1424ced --- /dev/null +++ b/dashboard/src/components/shortcuts/ShortcutEntry.tsx @@ -0,0 +1,28 @@ +import { IShortcutEntryProps } from '@/interfaces/components/Shortcuts'; +import Key from '@/components/shortcuts/Key'; + +export default function ShortcutEntry({ + name, + comb = [], +}: IShortcutEntryProps) { + return ( +
+
{name}
+
+ {comb.map((v, i) => ( + {v} + ))} +
+
+ ); +} diff --git a/dashboard/src/interfaces/components/Modal.ts b/dashboard/src/interfaces/components/Modal.ts new file mode 100644 index 000000000..1a1ced70b --- /dev/null +++ b/dashboard/src/interfaces/components/Modal.ts @@ -0,0 +1,11 @@ +export interface IModalProps { + children?: React.ReactNode; + fullHeight?: boolean; + open?: boolean; +} + +export interface IModalShortcutProps { + open?: IModalProps['open']; + onClose?: React.ComponentProps<'svg'>['onClick']; + modalContainerProps?: IModalProps; +} diff --git a/dashboard/src/interfaces/components/Shortcuts.ts b/dashboard/src/interfaces/components/Shortcuts.ts new file mode 100644 index 000000000..fcff82d7e --- /dev/null +++ b/dashboard/src/interfaces/components/Shortcuts.ts @@ -0,0 +1,8 @@ +export interface IKeyProps { + children?: React.ReactNode; +} + +export interface IShortcutEntryProps { + name?: React.ReactNode; + comb?: IKeyProps['children'][]; +} diff --git a/dashboard/src/interfaces/kbdsrct.ts b/dashboard/src/interfaces/kbdsrct.ts new file mode 100644 index 000000000..a0ff44675 --- /dev/null +++ b/dashboard/src/interfaces/kbdsrct.ts @@ -0,0 +1,4 @@ +export interface IKbdsrct { + comb: string[]; + cb: () => void; +} diff --git a/dashboard/src/layouts/AppLayout.tsx b/dashboard/src/layouts/AppLayout.tsx index 6ba50cfa5..1406df0fc 100644 --- a/dashboard/src/layouts/AppLayout.tsx +++ b/dashboard/src/layouts/AppLayout.tsx @@ -1,5 +1,12 @@ +import ModalShortcut from '@/components/ModalShortcut'; import { PageLayout } from '@/interfaces/layouts'; +import { + execKbdsrct, + registerKbdsrct, + unregisterKbdsrct, +} from '@/libs/kbdsrct'; import { createTheme, NextUIProvider } from '@nextui-org/react'; +import { useEffect, useRef, useState } from 'react'; const AppLayout: PageLayout = ({ children, @@ -13,6 +20,81 @@ const AppLayout: PageLayout = ({ overflow: 'hidden', }, }) => { + const [modalShortcutOpen, setModalShortcutOpen] = useState(false); + + const pressesRef = useRef([]); + + const removePresses = (key: string) => { + const rmIdx = pressesRef.current.findIndex((v) => key === v); + if (rmIdx < 0) return false; + + pressesRef.current.splice(rmIdx, 1); + return true; + }; + + /** + * Modifier detection may not work depends on user's keyboard configuration, there's at least + * OS level and app level capture, each that can block browser javascript from getting any key press event + * or preventing some modifier to give its current actual state (mostly Alt key for me) + * + * The browser might not even fire keyboard event cuz it's already captured by the OS or other app + */ + const kbdsrctInHandler = (e: KeyboardEvent) => { + if (e.shiftKey) { + pressesRef.current.push('Shift'); + } + + if (e.ctrlKey) { + pressesRef.current.push('Control'); + } + + if (e.altKey) { + pressesRef.current.push('Alt'); + } + + pressesRef.current.push(e.key); + + if (execKbdsrct(pressesRef.current)) e.preventDefault(); + }; + + const kbdsrctOutHandler = (e: KeyboardEvent) => { + if (removePresses(e.key)) { + e.preventDefault(); + } + + // remove modifier keys too + if (e.shiftKey) { + removePresses('Shift'); + } + + if (e.ctrlKey) { + removePresses('Control'); + } + + if (e.altKey) { + removePresses('Alt'); + } + }; + + const modalShortcutKbdsrct = { + comb: ['Control', '/'], + cb: () => setModalShortcutOpen((v) => !v), + }; + + useEffect(() => { + document.body.addEventListener('keydown', kbdsrctInHandler); + document.body.addEventListener('keyup', kbdsrctOutHandler); + + registerKbdsrct(modalShortcutKbdsrct); + + return () => { + document.body.removeEventListener('keydown', kbdsrctInHandler); + document.body.removeEventListener('keyup', kbdsrctOutHandler); + + unregisterKbdsrct(modalShortcutKbdsrct); + }; + }, []); + return (
{children}
+ modalShortcutOpen && setModalShortcutOpen(false)} + />
); }; diff --git a/dashboard/src/libs/kbdsrct.ts b/dashboard/src/libs/kbdsrct.ts new file mode 100644 index 000000000..876e359de --- /dev/null +++ b/dashboard/src/libs/kbdsrct.ts @@ -0,0 +1,89 @@ +import { IKbdsrct } from '@/interfaces/kbdsrct'; + +const kbdsrcts: IKbdsrct[] = []; + +export function findRegistered(kbdsrct: IKbdsrct) { + for (const v of kbdsrcts) { + const vcl = v.comb.length; + + if (vcl !== kbdsrct.comb.length) continue; + + let hasAll = true; + + for (let i = 0; i < vcl; i++) { + const vc = v.comb[i]; + + if (kbdsrct.comb.includes(vc)) continue; + + hasAll = false; + break; + } + + if (!hasAll) continue; + + return v; + } +} + +export function registerKbdsrct(kbdsrct: IKbdsrct) { + if (!kbdsrct.comb.length) throw new Error("Shortcut key can't be empty"); + + // warn if the same shortcut key already registered + const dup = findRegistered(kbdsrct); + if (dup) { + console.warn( + `Shortcut with key combination '${kbdsrct.comb.join( + '+', + )}' already registered with ${ + dup.cb === kbdsrct.cb ? 'the same' : 'different' + } callback`, + ); + } + + kbdsrcts.push(kbdsrct); +} + +/** + * Returns true if successfully unregistered + */ +export function unregisterKbdsrct(kbdsrct: IKbdsrct) { + const rmIdx = kbdsrcts.findIndex((v) => v === kbdsrct); + if (rmIdx < 0) return false; + + return kbdsrcts.splice(rmIdx, 1).length > 0; +} + +/** + * Check and execute appropriate callback based on current keypresses + * Returns true when shortcut that matches keypresses combination exists + */ +export function execKbdsrct(keypresses: string[]) { + if (!keypresses.length) return false; + + let hasSrct = false; + + for (const v of kbdsrcts) { + let exec = true; + for (const k of v.comb) { + if (!keypresses.includes(k)) { + exec = false; + break; + } + } + + if (!exec) continue; + + v.cb(); + hasSrct = true; + + // remove executed keypresses + for (const k of v.comb) { + // keypresses guaranteed contains comb entries here + // else smt is wrong w ur algorithm! + const rmIdx = keypresses.findIndex((kp) => kp === k); + keypresses.splice(rmIdx, 1); + } + } + + return hasSrct; +} diff --git a/dashboard/src/pages/index.tsx b/dashboard/src/pages/index.tsx index 0115ce9b6..0dd74d43b 100644 --- a/dashboard/src/pages/index.tsx +++ b/dashboard/src/pages/index.tsx @@ -17,6 +17,7 @@ const Home: NextPageWithLayout = () => { css={{ overflow: 'auto', }} + responsive={false} > Discord Music Bot diff --git a/dashboard/src/pages/servers/[id]/player.tsx b/dashboard/src/pages/servers/[id]/player.tsx index 31d9f2788..f7e353213 100644 --- a/dashboard/src/pages/servers/[id]/player.tsx +++ b/dashboard/src/pages/servers/[id]/player.tsx @@ -34,6 +34,7 @@ import { import Image from 'next/image'; import { getImageOnErrorHandler } from '@/utils/image'; import useAbortDelay from '@/hooks/useAbortDelay'; +import { registerKbdsrct, unregisterKbdsrct } from '@/libs/kbdsrct'; const FALLBACK_MAX_PROGRESS_VALUE = 1; const SOCKET_WAIT_RES_TIMEOUT = 3000; @@ -440,6 +441,42 @@ const Player: NextPageWithLayout = () => { runNextBack(() => {}, SOCKET_WAIT_RES_TIMEOUT); }; + const spacePP = { + // literally a space + comb: [' '], + cb: togglePlayPause, + }; + + const arrowLeftPrev = { + comb: ['Alt', 'ArrowLeft'], + cb: handlePrevious, + }; + + const arrowRightNext = { + comb: ['Alt', 'ArrowRight'], + cb: handleNext, + }; + + const registerAllSrct = () => { + registerKbdsrct(spacePP); + registerKbdsrct(arrowLeftPrev); + registerKbdsrct(arrowRightNext); + }; + + const unregisterAllSrct = () => { + unregisterKbdsrct(spacePP); + unregisterKbdsrct(arrowLeftPrev); + unregisterKbdsrct(arrowRightNext); + }; + + useEffect(() => { + registerAllSrct(); + + return () => { + unregisterAllSrct(); + }; + }, [registerAllSrct, unregisterAllSrct]); + const mainImg = !playing?.thumbnail?.length ? SampleThumb.src : playing.thumbnail;