WIP. A little custom music server
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use zustand instead of jotai for audio player for better structure

+121 -112
+7
bun.lock
··· 71 71 "tw-animate-css": "^1.4.0", 72 72 "vite-tsconfig-paths": "^5.1.4", 73 73 "waku": "0.26.1", 74 + "zustand": "^5.0.8", 74 75 }, 75 76 "devDependencies": { 76 77 "@prettier/plugin-oxc": "^0.0.4", ··· 1100 1101 1101 1102 "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], 1102 1103 1104 + "zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], 1105 + 1103 1106 "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 1107 + 1108 + "@boombox/shared/@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], 1104 1109 1105 1110 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], 1106 1111 ··· 1145 1150 "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 1146 1151 1147 1152 "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 1153 + 1154 + "@boombox/shared/@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], 1148 1155 1149 1156 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], 1150 1157
+2 -1
web/package.json
··· 30 30 "tailwind-merge": "^3.3.1", 31 31 "tw-animate-css": "^1.4.0", 32 32 "vite-tsconfig-paths": "^5.1.4", 33 - "waku": "0.26.1" 33 + "waku": "0.26.1", 34 + "zustand": "^5.0.8" 34 35 }, 35 36 "devDependencies": { 36 37 "@prettier/plugin-oxc": "^0.0.4",
+1 -5
web/src/atoms.ts
··· 1 1 import { atom } from "jotai"; 2 - import { NormalizedFloatType } from "./utils"; 3 2 4 - export const isPlayingAtom = atom<boolean>(false); 5 - export const fileAtom = atom<string | null>(null); 6 - 7 - export const mainVolumeAtom = atom<NormalizedFloatType>(0.02); 3 + // Player atoms moved to Zustand store (src/stores/player.ts)
+13 -12
web/src/components/audio-player.tsx
··· 1 1 "use client"; 2 2 3 3 import { useEffect, useRef } from "react"; 4 - import { fileAtom, isPlayingAtom, mainVolumeAtom } from "@/atoms"; 5 - import { useAtom, useAtomValue } from "jotai"; 4 + import { usePlayerStore } from "@/stores/player"; 6 5 import { Controls } from "./player/controls"; 7 6 8 7 function usePlayer() { 9 8 const audioRef = useRef<HTMLAudioElement | null>(null); 10 9 11 - const file = useAtomValue(fileAtom); 12 - const [isPlaying, setIsPlaying] = useAtom(isPlayingAtom); 13 - const mainVolume = useAtomValue(mainVolumeAtom); 10 + const currentTrack = usePlayerStore((state) => state.currentTrack); 11 + const isPlaying = usePlayerStore((state) => state.isPlaying); 12 + const volume = usePlayerStore((state) => state.volume); 13 + const play = usePlayerStore((state) => state.play); 14 + const pause = usePlayerStore((state) => state.pause); 14 15 15 - const link = file ? `http://localhost:3003/file/${file}` : ""; 16 + const link = currentTrack ? `http://localhost:3003/file/${currentTrack}` : ""; 16 17 const player = audioRef.current; 17 18 18 19 useEffect(() => { ··· 20 21 return; 21 22 } 22 23 23 - player.volume = mainVolume; 24 + player.volume = volume; 24 25 25 26 if (isPlaying) { 26 27 player.play(); 27 28 } else { 28 29 player.pause(); 29 30 } 30 - }, [audioRef, file, isPlaying]); 31 + }, [audioRef, currentTrack, isPlaying]); 31 32 32 33 useEffect(() => { 33 34 if (!player) { 34 35 return; 35 36 } 36 - player.volume = mainVolume; 37 - }, [audioRef, mainVolume]); 37 + player.volume = volume; 38 + }, [audioRef, volume]); 38 39 39 40 const onPause = () => { 40 - setIsPlaying(false); 41 + pause(); 41 42 }; 42 43 const onPlay = () => { 43 - setIsPlaying(true); 44 + play(); 44 45 }; 45 46 46 47 return {
+11 -14
web/src/components/player/controls.tsx
··· 1 - import { useAtomValue } from "jotai"; 2 - import { fileAtom } from "@/atoms"; 3 - 4 1 import { Pause, Play } from "lucide-react"; 5 2 import { Slider } from "@/components/ui/slider"; 6 - import { useControls } from "./use-controls"; 7 3 import { Button } from "@/components/ui/button"; 8 4 import { formatDate } from "date-fns"; 5 + import { usePlayerStore, getVolumePercent } from "@/stores/player"; 9 6 10 7 export function Controls() { 11 - const file = useAtomValue(fileAtom); 8 + const currentTrack = usePlayerStore((state) => state.currentTrack); 9 + const isPlaying = usePlayerStore((state) => state.isPlaying); 10 + const volume = usePlayerStore((state) => state.volume); 11 + const togglePlayPause = usePlayerStore((state) => state.togglePlayPause); 12 + const setVolumePercent = usePlayerStore((state) => state.setVolumePercent); 12 13 13 - const { 14 - togglePlayPause, 15 - isPlaying, 16 - volumePercent, 17 - setVolumePercent, 14 + const volumePercent = getVolumePercent(volume); 18 15 19 - maxDurationMs, 20 - curDurationMs, 21 - } = useControls({}); 16 + // Hardcoded duration values (unchanged from original) 17 + const maxDurationMs = (12 * 60 + 47) * 1000; 18 + const curDurationMs = (0 * 60 + 0) * 1000; 22 19 23 - if (!file) { 20 + if (!currentTrack) { 24 21 return null; 25 22 } 26 23
-73
web/src/components/player/use-controls.tsx
··· 1 - import { useAtom } from "jotai"; 2 - import { isPlayingAtom, mainVolumeAtom } from "@/atoms"; 3 - import { scale } from "@/utils"; 4 - 5 - const MAX_VOLUME = 0.15; 6 - 7 - type Props = { 8 - maxVolume?: number; 9 - }; 10 - export function useControls({ maxVolume }: Props) { 11 - const [isPlaying, setIsPlaying] = useAtom(isPlayingAtom); 12 - 13 - const [mainVolume, setMainVolume] = useAtom(mainVolumeAtom); 14 - const volumePercent = volumeBackwards(mainVolume, maxVolume); 15 - 16 - const togglePlayPause = () => { 17 - setIsPlaying((x) => !x); 18 - }; 19 - 20 - const setVolumePercent = (volume: number) => { 21 - setMainVolume(volumeForward(volume, maxVolume)); 22 - }; 23 - 24 - return { 25 - togglePlayPause, 26 - isPlaying, 27 - volumePercent, 28 - volumeReal: mainVolume, 29 - setVolumePercent, 30 - 31 - maxDurationMs: (12 * 60 + 47) * 1000, 32 - curDurationMs: (0 * 60 + 0) * 1000, 33 - }; 34 - } 35 - 36 - // ## Helpers 37 - 38 - function perceptualVolume(x: number) { 39 - //return (Math.exp(x) - 1) / (Math.E - 1); 40 - return (Math.exp(x) - 1) * 0.5819767; // 1/(e-1) 41 - } 42 - 43 - function amplitudeVolume(x: number) { 44 - //return Math.log(x * (Math.E - 1) + 1); 45 - return Math.log(x * 1.71828 + 1); // e-1 46 - } 47 - 48 - function clamp(x: number, min: number, max: number) { 49 - if (x > max) { 50 - return max; 51 - } 52 - if (x < min) { 53 - return min; 54 - } 55 - return x; 56 - } 57 - 58 - function volumeForward(valuePercent: number, maxVolume = MAX_VOLUME) { 59 - const clamped = clamp(valuePercent, 0, 100); 60 - const normalized = scale(clamped, 100, 1); 61 - const perceptual = perceptualVolume(normalized); 62 - const scaled = scale(perceptual, 1, maxVolume); 63 - const clamped2 = clamp(scaled, 0, maxVolume); 64 - return clamped2; 65 - } 66 - function volumeBackwards(value: number, maxVolume = MAX_VOLUME) { 67 - const clamped = clamp(value, 0, maxVolume); 68 - const scaled = scale(clamped, maxVolume, 1); 69 - const actual = amplitudeVolume(scaled); 70 - const denormalized = scale(actual, 1, 100); 71 - const clamped2 = clamp(denormalized, 0, 100); 72 - return clamped2; 73 - }
+6 -7
web/src/components/song-row.tsx
··· 1 1 "use client"; 2 2 3 3 import { AudioLines, Pause, Play } from "lucide-react"; 4 - import { fileAtom, isPlayingAtom } from "@/atoms"; 5 - import { useAtom } from "jotai"; 4 + import { usePlayerStore } from "@/stores/player"; 6 5 7 6 type Props = Readonly<{ 8 7 title: string; ··· 13 12 }>; 14 13 15 14 export function SongRow(props: Props) { 16 - const [file, setFile] = useAtom(fileAtom); 17 - const [isPlaying, setIsPlaying] = useAtom(isPlayingAtom); 15 + const currentTrack = usePlayerStore((state) => state.currentTrack); 16 + const isPlaying = usePlayerStore((state) => state.isPlaying); 17 + const setTrack = usePlayerStore((state) => state.setTrack); 18 18 19 - const isCurrent = file === props.fileId; 19 + const isCurrent = currentTrack === props.fileId; 20 20 const isPaused = isCurrent && !isPlaying; 21 21 22 22 const handleClick = () => { 23 - setFile(props.fileId); 24 - setIsPlaying(true); 23 + setTrack(props.fileId); 25 24 }; 26 25 27 26 return (
+81
web/src/stores/player.ts
··· 1 + import { create } from "zustand"; 2 + import { scale } from "@/utils"; 3 + 4 + const MAX_VOLUME = 0.15; 5 + 6 + // ## Volume Helpers 7 + 8 + function perceptualVolume(x: number) { 9 + //return (Math.exp(x) - 1) / (Math.E - 1); 10 + return (Math.exp(x) - 1) * 0.5819767; // 1/(e-1) 11 + } 12 + 13 + function amplitudeVolume(x: number) { 14 + //return Math.log(x * (Math.E - 1) + 1); 15 + return Math.log(x * 1.71828 + 1); // e-1 16 + } 17 + 18 + function clamp(x: number, min: number, max: number) { 19 + if (x > max) { 20 + return max; 21 + } 22 + if (x < min) { 23 + return min; 24 + } 25 + return x; 26 + } 27 + 28 + function volumeForward(valuePercent: number, maxVolume = MAX_VOLUME) { 29 + const clamped = clamp(valuePercent, 0, 100); 30 + const normalized = scale(clamped, 100, 1); 31 + const perceptual = perceptualVolume(normalized); 32 + const scaled = scale(perceptual, 1, maxVolume); 33 + const clamped2 = clamp(scaled, 0, maxVolume); 34 + return clamped2; 35 + } 36 + 37 + function volumeBackwards(value: number, maxVolume = MAX_VOLUME) { 38 + const clamped = clamp(value, 0, maxVolume); 39 + const scaled = scale(clamped, maxVolume, 1); 40 + const actual = amplitudeVolume(scaled); 41 + const denormalized = scale(actual, 1, 100); 42 + const clamped2 = clamp(denormalized, 0, 100); 43 + return clamped2; 44 + } 45 + 46 + // ## Store 47 + 48 + interface PlayerState { 49 + // State 50 + isPlaying: boolean; 51 + currentTrack: string | null; 52 + volume: number; // 0-1 range 53 + 54 + // Actions 55 + play: () => void; 56 + pause: () => void; 57 + togglePlayPause: () => void; 58 + setTrack: (fileId: string) => void; 59 + setVolume: (volume: number) => void; 60 + setVolumePercent: (percent: number) => void; 61 + } 62 + 63 + export const usePlayerStore = create<PlayerState>((set) => ({ 64 + // Initial state 65 + isPlaying: false, 66 + currentTrack: null, 67 + volume: 0.02, 68 + 69 + // Actions 70 + play: () => set({ isPlaying: true }), 71 + pause: () => set({ isPlaying: false }), 72 + togglePlayPause: () => set((state) => ({ isPlaying: !state.isPlaying })), 73 + setTrack: (fileId: string) => set({ currentTrack: fileId, isPlaying: true }), 74 + setVolume: (volume: number) => set({ volume }), 75 + setVolumePercent: (percent: number) => set({ volume: volumeForward(percent) }), 76 + })); 77 + 78 + // Helper to get volume as percentage (0-100) 79 + export function getVolumePercent(volume: number): number { 80 + return volumeBackwards(volume); 81 + }