diff --git a/src/@types.d.ts b/src/@types.d.ts index d2367d80..ade0c9b6 100644 --- a/src/@types.d.ts +++ b/src/@types.d.ts @@ -112,6 +112,7 @@ export type System = { // Settings table definition export type Settings = { volume: number; + sidebarWidth: number; audioDeviceId: string; osuSongsDir: string; "window.width": number; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a85f0e14..932af65f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -10,7 +10,8 @@ import DirSelectScene from "./scenes/dir-select-scene/DirSelectScene"; import LoadingScene from "./scenes/loading-scene/LoadingScene"; import MainScene from "./scenes/main-scene/MainScene"; import type { JSX } from "solid-js"; -import { createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; +import { createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; +import { sidebarWidth } from "./scenes/main-scene/main.utils"; export default function App(): JSX.Element { const [scene, setScene] = createSignal(""); @@ -38,8 +39,12 @@ export default function App(): JSX.Element { window.removeEventListener("changeScene", eventHandler); }); + const hasRequiredOptions = createMemo(() => { + return typeof os() !== "undefined" && typeof sidebarWidth() !== "undefined"; + }); + return ( - + }> diff --git a/src/renderer/src/components/InfiniteScroller.tsx b/src/renderer/src/components/InfiniteScroller.tsx index 4ac6c7d7..d309b609 100644 --- a/src/renderer/src/components/InfiniteScroller.tsx +++ b/src/renderer/src/components/InfiniteScroller.tsx @@ -223,7 +223,11 @@ const InfiniteScroller: Component = (props) => { }); return ( -
+
No items...
}> {(componentProps) => props.builder(componentProps)} diff --git a/src/renderer/src/components/resizable-panel/DragHandler.tsx b/src/renderer/src/components/resizable-panel/DragHandler.tsx new file mode 100644 index 00000000..663e2adc --- /dev/null +++ b/src/renderer/src/components/resizable-panel/DragHandler.tsx @@ -0,0 +1,43 @@ +import { Component } from "solid-js"; +import { useResizablePanel } from "./ResizablePanel"; +import { cn } from "@renderer/lib/css.utils"; + +type DragHandlerProps = { + class?: string; +}; +export const DragHandler: Component = (props) => { + const state = useResizablePanel(); + + return ( +
{ + const target = event.target as HTMLElement; + target.setPointerCapture(event.pointerId); + event.preventDefault(); + + state.handlePointerStart(event); + }} + onPointerMove={(event) => { + const target = event.target as HTMLElement; + if (!target.hasPointerCapture(event.pointerId)) { + return; + } + + state.handlePointerMove(event); + }} + onPointerUp={(event) => { + const target = event.target as HTMLElement; + if (target.hasPointerCapture(event.pointerId)) { + target.releasePointerCapture(event.pointerId); + state.handlePointerEnd(); + } + }} + class={cn( + "opacity-0 hover:opacity-100 h-full w-4 translate-x-[-50%] cursor-w-resize flex flex-col items-center justify-center", + props.class, + )} + > +
+
+ ); +}; diff --git a/src/renderer/src/components/resizable-panel/ResizablePanel.tsx b/src/renderer/src/components/resizable-panel/ResizablePanel.tsx new file mode 100644 index 00000000..30da4068 --- /dev/null +++ b/src/renderer/src/components/resizable-panel/ResizablePanel.tsx @@ -0,0 +1,97 @@ +import { Accessor, createContext, ParentComponent, useContext } from "solid-js"; +import { DragHandler } from "./DragHandler"; +import useControllableState from "@renderer/lib/controllable-state"; +import { DOMElement } from "solid-js/jsx-runtime"; +import { clamp } from "@renderer/lib/tungsten/math"; + +const DEFAULT_RESIZABLE_PANEL_VALUE = 100; +const DEFAULT_MIN = 0; +const DEFAULT_MAX = Infinity; + +type Event = PointerEvent & { + currentTarget: HTMLDivElement; + target: DOMElement; +}; + +export type Props = { + min?: number; + max?: number; + + offsetFromPanel?: number; + + defaultValue?: number; + value?: Accessor; + onValueChange?: (newValue: number) => void; + onValueCommit?: (value: number) => void; + onValueStart?: () => void; +}; + +export type Context = ReturnType; +function useProviderValue(props: Props) { + let startDragValue: number | undefined; + const [value, setValue] = useControllableState({ + defaultProp: props.defaultValue ?? DEFAULT_RESIZABLE_PANEL_VALUE, + prop: props.value, + onChange: props.onValueChange, + }); + + const getValueFromPointer = (pointerPosition: number) => { + if (!startDragValue) { + return; + } + + const min = props.min ?? DEFAULT_MIN; + const max = props.max ?? DEFAULT_MAX; + + const value = pointerPosition - (props.offsetFromPanel ?? 0); + return clamp(min, max, value); + }; + + const handlePointerStart = (event: Event) => { + startDragValue = value(); + const newValue = getValueFromPointer(event.clientX); + if (typeof newValue === "undefined") { + return; + } + + setValue(newValue); + props.onValueStart?.(); + }; + const handlePointerMove = (event: Event) => { + const newValue = getValueFromPointer(event.clientX); + if (typeof newValue === "undefined") { + return; + } + + setValue(newValue); + }; + const handlePointerEnd = () => { + props.onValueCommit?.(value()); + }; + + return { value, handlePointerStart, handlePointerMove, handlePointerEnd }; +} + +export const ResizablePanelContext = createContext(); +const ResizablePanelRoot: ParentComponent = (props) => { + const value = useProviderValue(props); + return ( + {props.children} + ); +}; + +export function useResizablePanel(): Context { + const state = useContext(ResizablePanelContext); + if (!state) { + throw new Error( + "useResizablePanel needs to be used inisde of the `ResizablePanelContext` component.", + ); + } + return state; +} + +const ResizablePanel = Object.assign(ResizablePanelRoot, { + DragHandler, +}); + +export default ResizablePanel; diff --git a/src/renderer/src/components/song/song-item/SongItem.tsx b/src/renderer/src/components/song/song-item/SongItem.tsx index aa49a5e2..dbfff93c 100644 --- a/src/renderer/src/components/song/song-item/SongItem.tsx +++ b/src/renderer/src/components/song/song-item/SongItem.tsx @@ -7,7 +7,7 @@ import { song as selectedSong } from "../song.utils"; import { transparentize } from "polished"; import Popover from "@renderer/components/popover/Popover"; import { EllipsisVerticalIcon } from "lucide-solid"; -import { Component, createSignal, JSXElement, onMount, createMemo } from "solid-js"; +import { Component, createSignal, JSXElement, onMount, createMemo, Show } from "solid-js"; import { twMerge } from "tailwind-merge"; type SongItemProps = { @@ -67,11 +67,11 @@ const SongItem: Component = (props) => { return "rgba(0, 0, 0, 0.72)"; } - if (isHovering() || localShow()) { - return `linear-gradient(to right, ${color} 20%, ${transparentize(0.9)(color)}), rgba(255, 255, 255, 0.2)`; + if (isHovering() || localShow() || isSelected()) { + return `linear-gradient(to right, ${transparentize(0.1)(color)} 20%, ${transparentize(0.9)(color)}), rgba(0, 0, 0, 0.1)`; } - return `linear-gradient(to right, ${color} 16%, ${transparentize(0.92)(color)})`; + return `linear-gradient(to right, ${color} 20%, ${transparentize(0.9)(color)}), rgba(0, 0, 0, 0.2)`; }); return ( @@ -103,14 +103,12 @@ const SongItem: Component = (props) => { onMouseLeave={() => { setIsHovering(false); }} - class="min-h-[72px] rounded-lg py-0.5 pl-1.5 pr-0.5 transition-colors active: group relative" + class="min-h-[72px] rounded-lg py-0.5 pl-1.5 pr-0.5 transition-colors active: group relative isolate overflow-hidden" classList={{ "shadow-glow-blue": isSelected(), - "pr-6": isHovering() || localShow(), }} style={{ background: borderColor(), - "transition-property": "padding, background", }} onContextMenu={(e) => { e.preventDefault(); @@ -119,22 +117,24 @@ const SongItem: Component = (props) => { }} >
+

{props.song.title}

@@ -142,30 +142,32 @@ const SongItem: Component = (props) => {
- { - e.stopPropagation(); - setMousePos(undefined); - setLocalShow(true); - }} - class="absolute right-0 top-0 h-full flex items-center text-subtext transition-colors hover:text-text" - title="Song options" - classList={{ - "text-text": localShow(), - }} - > -
+ { + e.stopPropagation(); + setMousePos(undefined); + setLocalShow(true); + }} + class="absolute right-0 top-0 h-full flex items-center text-subtext transition-colors hover:text-text rounded-r-lg animate-song-item-slide-in" + title="Song options" classList={{ - "opacity-100": isHovering() || localShow(), + "text-text": localShow(), }} style={{ - color: isSelected() ? secondaryColor() : undefined, + background: borderColor(), }} > - -
-
+
+ +
+ +
); diff --git a/src/renderer/src/components/song/song-item/SongItemAnimations.ts b/src/renderer/src/components/song/song-item/SongItemAnimations.ts new file mode 100644 index 00000000..86738fde --- /dev/null +++ b/src/renderer/src/components/song/song-item/SongItemAnimations.ts @@ -0,0 +1,11 @@ +export const songItemAnimations = { + keyframes: { + "song-item-slide-in": { + from: { transform: "translateX(100%)", opacity: 0 }, + to: { transform: "translateX(0)", opacity: 1 }, + }, + }, + animation: { + "song-item-slide-in": "song-item-slide-in 160ms cubic-bezier(0.4, 0, 0.2, 1) forwards", + }, +}; diff --git a/src/renderer/src/components/song/song-list/SongList.tsx b/src/renderer/src/components/song/song-list/SongList.tsx index 33be7556..2e15614f 100644 --- a/src/renderer/src/components/song/song-list/SongList.tsx +++ b/src/renderer/src/components/song/song-list/SongList.tsx @@ -88,14 +88,12 @@ const SongList: Component = (props) => { reset={resetListing} fallback={
No songs
} builder={(s) => ( -
- } - /> -
+ } + /> )} />
diff --git a/src/renderer/src/css/global.css b/src/renderer/src/css/global.css index 5032f012..165a3d51 100644 --- a/src/renderer/src/css/global.css +++ b/src/renderer/src/css/global.css @@ -2,57 +2,64 @@ @tailwind components; @tailwind utilities; -@font-face { - font-family: "Nunito"; - src: url("@renderer/assets/fonts/Nunito-Variable.ttf") format("truetype"); - font-weight: 100 900; - font-style: normal; -} +@layer base { + @font-face { + font-family: "Nunito"; + src: url("@renderer/assets/fonts/Nunito-Variable.ttf") format("truetype"); + font-weight: 100 900; + font-style: normal; + } -@font-face { - font-family: "Nunito"; - src: url("@renderer/assets/fonts/Nunito-Italic-Variable.ttf") format("truetype"); - font-weight: 100 900; - font-style: italic; -} + @font-face { + font-family: "Nunito"; + src: url("@renderer/assets/fonts/Nunito-Italic-Variable.ttf") format("truetype"); + font-weight: 100 900; + font-style: italic; + } -:focus-visible { - @apply outline outline-blue-500 outline-2; -} + :focus-visible { + @apply outline outline-blue-500 outline-2; + } -* { - margin: 0; - padding: 0; - box-sizing: border-box; - user-select: none; -} + .transition-base { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + } -html { - background: rgba(var(--color-thick-material)); - color: rgb(var(--color-text)); + * { + margin: 0; + padding: 0; + box-sizing: border-box; + user-select: none; + } - font-family: "Nunito", sans-serif; - font-optical-sizing: auto; -} + html { + background: rgba(var(--color-thick-material)); + color: rgb(var(--color-text)); -button { - all: unset; -} + font-family: "Nunito", sans-serif; + font-optical-sizing: auto; + } -::-webkit-scrollbar { - width: 8px; - height: 8px; -} + button { + all: unset; + } -::-webkit-scrollbar-track { - background-color: transparent; -} + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } -::-webkit-scrollbar-thumb { - background-color: rgba(var(--color-surface), 0.5); - border-radius: 999px; -} + ::-webkit-scrollbar-track { + background-color: transparent; + } + + ::-webkit-scrollbar-thumb { + background-color: rgba(var(--color-surface), 0.5); + border-radius: 999px; + } -::-webkit-scrollbar-thumb:hover { - background-color: rgba(var(--color-surface), 0.7); + ::-webkit-scrollbar-thumb:hover { + background-color: rgba(var(--color-surface), 0.7); + } } diff --git a/src/renderer/src/scenes/main-scene/MainScene.tsx b/src/renderer/src/scenes/main-scene/MainScene.tsx index 700113c5..07804bc3 100644 --- a/src/renderer/src/scenes/main-scene/MainScene.tsx +++ b/src/renderer/src/scenes/main-scene/MainScene.tsx @@ -6,12 +6,21 @@ import { song } from "@renderer/components/song/song.utils"; import { WindowsControls } from "@renderer/components/windows-control/WindowsControl"; import { os } from "@renderer/lib/os"; import { Layers3Icon } from "lucide-solid"; -import { Component, createSignal, Match, Switch } from "solid-js"; +import { Accessor, Component, createSignal, Match, Switch } from "solid-js"; import { Sidebar } from "./Sidebar"; import Popover from "@renderer/components/popover/Popover"; import SongQueue from "@renderer/components/song/song-queue/SongQueue"; +import ResizablePanel from "@renderer/components/resizable-panel/ResizablePanel"; +import { + setAnimateSidebar, + setSidebarWidth, + settingsWriteSidebarWidth, + sidebarWidth, + useMainResizableOptions, +} from "./main.utils"; const MainScene: Component = () => { + const { maxSidebarWidth, offsetFromPanel } = useMainResizableOptions(); return (
@@ -23,28 +32,39 @@ const MainScene: Component = () => { -
- - -
- - -
- + } + onValueChange={setSidebarWidth} + onValueStart={() => setAnimateSidebar(false)} + onValueCommit={(width) => { + setAnimateSidebar(true); + settingsWriteSidebarWidth(width); + }} + > +
+ +
+ + +
+ +
-
-
- + +
{ onValueChange={setIsOpen} isOpen={isOpen} > - setIsOpen(true)} class="no-drag absolute right-2 top-2"> + setIsOpen(true)} class="no-drag absolute right-2 top-2 z-10"> diff --git a/src/renderer/src/scenes/main-scene/Sidebar.tsx b/src/renderer/src/scenes/main-scene/Sidebar.tsx index f3630b05..0d3526bb 100644 --- a/src/renderer/src/scenes/main-scene/Sidebar.tsx +++ b/src/renderer/src/scenes/main-scene/Sidebar.tsx @@ -1,6 +1,7 @@ import Tabs from "@renderer/components/tabs/Tabs"; -import { Component, createMemo, For } from "solid-js"; +import { Component, createMemo, For, Show } from "solid-js"; import { + animateSidebar, NAV_ITEMS, setSidebarActiveTab, setSidebarExpanded, @@ -13,21 +14,35 @@ import { SettingsIcon, SidebarIcon } from "lucide-solid"; import Button from "@renderer/components/button/Button"; import Settings from "@renderer/components/settings/Settings"; import { os } from "@renderer/lib/os"; +import ResizablePanel, { + useResizablePanel, +} from "@renderer/components/resizable-panel/ResizablePanel"; + +function toPx(value: number) { + return `${value}px`; +} export const Sidebar: Component = () => { + const state = useResizablePanel(); + const toggleSidebarShow = () => { setSidebarExpanded((a) => !a); }; return (