From d89cc4a99b430c2b51771f7b308f0bcfa872c72b Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:06:30 -0300 Subject: [PATCH 01/23] chore(deps): add `@opennbs/nbsvis` --- apps/frontend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3b6a39e3..a5438a86 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -23,6 +23,7 @@ "@nbw/thumbnail": "workspace:*", "@next/mdx": "^16.0.8", "@next/third-parties": "^16.0.8", + "@opennbs/nbsvis": "^0.4.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", From 57af2bdffd8f07a3e09b3dc6cf159771ae395a42 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:07:14 -0300 Subject: [PATCH 02/23] feat: add song player widget on song page --- .../src/modules/song/components/Player.tsx | 203 ++++++++++++++++++ .../src/modules/song/components/SongPage.tsx | 15 +- .../src/modules/song/util/downloadSong.ts | 16 ++ 3 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/modules/song/components/Player.tsx diff --git a/apps/frontend/src/modules/song/components/Player.tsx b/apps/frontend/src/modules/song/components/Player.tsx new file mode 100644 index 00000000..79cbc9e6 --- /dev/null +++ b/apps/frontend/src/modules/song/components/Player.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { faPlay } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { PianoRollView, Player, Viewer } from '@opennbs/nbsvis'; +import { useRef, useEffect, useState, useCallback } from 'react'; + +import { getSongOpenUrl } from '../util/downloadSong'; + +const RENDER_WIDTH = 1280; +const RENDER_HEIGHT = 768; + +export const PlayerComponent = ({ + song, + children, +}: { + song: { publicId: string }; + children: React.ReactNode; +}) => { + const viewportRef = useRef(null); + const containerRef = useRef(null); + const isCancelledRef = useRef(false); + const viewerRef = useRef(null); + const playerRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [loadingPhase, setLoadingPhase] = useState('Preparing player...'); + const [error, setError] = useState(null); + + const loadSong = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + setLoadingPhase('Preparing player...'); + + if (!containerRef.current) { + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + + if (!containerRef.current) { + throw new Error('Player container not ready'); + } + + if (isCancelledRef.current || !containerRef.current) return; + + if (!viewerRef.current || !playerRef.current) { + setLoadingPhase('Initializing viewer...'); + const viewer = new Viewer(containerRef.current); + + await viewer.init(); + await viewer.setView(new PianoRollView()); + + // Render at a fixed internal resolution; CSS transform scales it to fit. + viewer.resize(RENDER_WIDTH, RENDER_HEIGHT); + + if (isCancelledRef.current) return; + + const player = new Player(viewer, { + audioEngine: { + urlBase: `${window.location.origin}/`, + }, + }); + + viewerRef.current = viewer; + playerRef.current = player; + } + + if (!playerRef.current) { + throw new Error('Player initialization failed'); + } + + setLoadingPhase('Loading song data...'); + + const songUrl = await getSongOpenUrl({ publicId: song.publicId }); + await Promise.resolve(playerRef.current.loadSong(songUrl)); + } catch (err) { + if (!isCancelledRef.current) { + setError(err instanceof Error ? err.message : 'Failed to load song'); + } + } finally { + if (!isCancelledRef.current) setIsLoading(false); + } + }, [song.publicId]); + + useEffect(() => { + isCancelledRef.current = false; + + loadSong(); + + return () => { + isCancelledRef.current = true; + }; + }, [loadSong]); + + useEffect(() => { + if (!viewportRef.current || !containerRef.current) return; + + const applyScale = () => { + if (!viewportRef.current || !containerRef.current) return; + + const viewportWidth = viewportRef.current.clientWidth; + const viewportHeight = viewportRef.current.clientHeight; + const scale = Math.min( + viewportWidth / RENDER_WIDTH, + viewportHeight / RENDER_HEIGHT, + ); + + containerRef.current.style.transform = `translate(-50%, -50%) scale(${scale})`; + }; + + applyScale(); + + const observer = new ResizeObserver(() => { + applyScale(); + }); + + observer.observe(viewportRef.current); + + return () => { + observer.disconnect(); + }; + }, []); + + return ( +
+
+
+
+ {isLoading ? ( +
+
{loadingPhase}
+ {children} +
+ ) : null} + {error ? ( +
+ Error: {error} +
+ ) : null} + +
+ ); +}; diff --git a/apps/frontend/src/modules/song/components/SongPage.tsx b/apps/frontend/src/modules/song/components/SongPage.tsx index eca3878c..c3ca7d6e 100644 --- a/apps/frontend/src/modules/song/components/SongPage.tsx +++ b/apps/frontend/src/modules/song/components/SongPage.tsx @@ -14,6 +14,7 @@ import { ErrorBox } from '@web/modules/shared/components/client/ErrorBox'; import { formatTimeAgo } from '@web/modules/shared/util/format'; import { LicenseInfo } from './client/LicenseInfo'; +import { PlayerComponent } from './Player'; import { SongDetails } from './SongDetails'; import { DownloadSongButton, @@ -84,16 +85,20 @@ export async function SongPage({ id }: { id: string }) {
{/* Song thumbnail */} {/* TODO: implement loading https://github.com/vercel/next.js/discussions/50617 */} - +
Song thumbnail - +
+ + <> + +
+

{song.title}

diff --git a/apps/frontend/src/modules/song/util/downloadSong.ts b/apps/frontend/src/modules/song/util/downloadSong.ts index 6012e43f..846efd38 100644 --- a/apps/frontend/src/modules/song/util/downloadSong.ts +++ b/apps/frontend/src/modules/song/util/downloadSong.ts @@ -64,3 +64,19 @@ export const openSongInNBS = async (song: { publicId: string }) => { toast.error('Failed to open song in NBS'); }); }; + +export const getSongOpenUrl = async (song: { publicId: string }) => { + try { + const response = await axios.get(`/song/${song.publicId}/open`, { + headers: { + src: 'downloadButton', + }, + }); + + return response.data; + } catch (error) { + console.error('Failed to fetch song URL', error); + toast.error('Failed to fetch song URL'); + throw error; + } +}; From d2150324550efa282f2e8ff7aa14e935f237b3dc Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:09:25 -0300 Subject: [PATCH 03/23] chore(player): enable cross-origin isolation via COOP/COEP headers --- apps/frontend/next.config.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/frontend/next.config.mjs b/apps/frontend/next.config.mjs index b3992d56..553490f8 100644 --- a/apps/frontend/next.config.mjs +++ b/apps/frontend/next.config.mjs @@ -9,6 +9,23 @@ const nextConfig = { NEXT_PUBLIC_APP_ENV: appEnv, }, pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'Cross-Origin-Opener-Policy', + value: 'same-origin', + }, + { + key: 'Cross-Origin-Embedder-Policy', + value: 'require-corp', + }, + ], + }, + ]; + }, // Externalize packages that use Node.js built-in modules for server components serverExternalPackages: ['@nbw/database', '@nbw/config'], // See: https://github.com/Automattic/node-canvas/issues/867#issuecomment-1925284985 From 5831fc70fa4c74b381c6925bbbfe03ba533db1db Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:11:54 -0300 Subject: [PATCH 04/23] chore(player): set up static asset routing/serving from `nbsvis` package --- apps/frontend/next.config.mjs | 7 +++ .../src/app/assets/[...path]/route.ts | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 apps/frontend/src/app/assets/[...path]/route.ts diff --git a/apps/frontend/next.config.mjs b/apps/frontend/next.config.mjs index 553490f8..10c69307 100644 --- a/apps/frontend/next.config.mjs +++ b/apps/frontend/next.config.mjs @@ -41,6 +41,13 @@ const nextConfig = { config.externals.push('@nbw/thumbnail', '@nbw/database'); } + // Handle audio assets from @opennbs/nbsvis as static files + config.module.rules.push({ + test: /\.(ogg|mp3|wav)$/i, + include: /node_modules[\\/]@opennbs[\\/]nbsvis[\\/]/, + type: 'asset/resource', + }); + return config; }, images: { diff --git a/apps/frontend/src/app/assets/[...path]/route.ts b/apps/frontend/src/app/assets/[...path]/route.ts new file mode 100644 index 00000000..04c5ad33 --- /dev/null +++ b/apps/frontend/src/app/assets/[...path]/route.ts @@ -0,0 +1,53 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +const nbsvisAssetsRoot = path.resolve( + process.cwd(), + '../../node_modules/@opennbs/nbsvis/dist/assets', +); + +const contentTypeByExt: Record = { + '.js': 'text/javascript; charset=utf-8', + '.ogg': 'audio/ogg', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.png': 'image/png', +}; + +export async function GET( + _: Request, + context: { params: Promise<{ path: string[] }> }, +) { + const { path: parts } = await context.params; + + if (!parts?.length) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } + + const relativePath = parts.join('/'); + const fullPath = path.resolve(nbsvisAssetsRoot, relativePath); + + // Prevent path traversal outside the package asset directory. + if (!fullPath.startsWith(nbsvisAssetsRoot + path.sep)) { + return NextResponse.json({ error: 'Invalid asset path' }, { status: 400 }); + } + + try { + const file = await readFile(fullPath); + const ext = path.extname(fullPath).toLowerCase(); + + return new NextResponse(file, { + status: 200, + headers: { + 'Content-Type': contentTypeByExt[ext] ?? 'application/octet-stream', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } catch { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } +} From d13c517a2512ba361a01a938c85f3d10ee5a3b05 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:25:35 -0300 Subject: [PATCH 05/23] refactor(player): move player management to dedicated `SongPlayerContext` --- apps/frontend/src/app/layout.tsx | 9 +- .../src/modules/song/components/Player.tsx | 98 +++------- .../client/context/SongPlayer.context.tsx | 179 ++++++++++++++++++ 3 files changed, 207 insertions(+), 79 deletions(-) create mode 100644 apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index f3f6dbf5..250e1bb4 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -15,6 +15,7 @@ import DetectAdBlock from '../modules/shared/components/client/ads/DetectAdBlock import GoogleAdSense from '../modules/shared/components/GoogleAdSense'; import { isProductionAppEnv } from '@web/lib/appEnv'; import { TooltipProvider } from '../modules/shared/components/tooltip'; +import { SongPlayerProvider } from '../modules/song/components/client/context/SongPlayer.context'; // Pre-import FontAwesome CSS to avoid FOUC // See: https://fontawesome.com/docs/web/use-with/react/use-with#nextjs @@ -121,9 +122,11 @@ export default function RootLayout({ baseColor='rgb(39 39 42)' highlightColor='rgb(63 63 70)' > - - {children} - + + + {children} + + diff --git a/apps/frontend/src/modules/song/components/Player.tsx b/apps/frontend/src/modules/song/components/Player.tsx index 79cbc9e6..d612457a 100644 --- a/apps/frontend/src/modules/song/components/Player.tsx +++ b/apps/frontend/src/modules/song/components/Player.tsx @@ -2,10 +2,9 @@ import { faPlay } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { PianoRollView, Player, Viewer } from '@opennbs/nbsvis'; -import { useRef, useEffect, useState, useCallback } from 'react'; +import { useRef, useEffect, useCallback } from 'react'; -import { getSongOpenUrl } from '../util/downloadSong'; +import { useSongPlayer } from './client/context/SongPlayer.context'; const RENDER_WIDTH = 1280; const RENDER_HEIGHT = 768; @@ -19,79 +18,26 @@ export const PlayerComponent = ({ }) => { const viewportRef = useRef(null); const containerRef = useRef(null); - const isCancelledRef = useRef(false); - const viewerRef = useRef(null); - const playerRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [loadingPhase, setLoadingPhase] = useState('Preparing player...'); - const [error, setError] = useState(null); - - const loadSong = useCallback(async () => { - try { - setIsLoading(true); - setError(null); - setLoadingPhase('Preparing player...'); - - if (!containerRef.current) { - await new Promise((resolve) => - requestAnimationFrame(() => resolve()), - ); - } - - if (!containerRef.current) { - throw new Error('Player container not ready'); - } - - if (isCancelledRef.current || !containerRef.current) return; - - if (!viewerRef.current || !playerRef.current) { - setLoadingPhase('Initializing viewer...'); - const viewer = new Viewer(containerRef.current); - - await viewer.init(); - await viewer.setView(new PianoRollView()); - - // Render at a fixed internal resolution; CSS transform scales it to fit. - viewer.resize(RENDER_WIDTH, RENDER_HEIGHT); - - if (isCancelledRef.current) return; - - const player = new Player(viewer, { - audioEngine: { - urlBase: `${window.location.origin}/`, - }, - }); - - viewerRef.current = viewer; - playerRef.current = player; - } - - if (!playerRef.current) { - throw new Error('Player initialization failed'); - } - - setLoadingPhase('Loading song data...'); - - const songUrl = await getSongOpenUrl({ publicId: song.publicId }); - await Promise.resolve(playerRef.current.loadSong(songUrl)); - } catch (err) { - if (!isCancelledRef.current) { - setError(err instanceof Error ? err.message : 'Failed to load song'); - } - } finally { - if (!isCancelledRef.current) setIsLoading(false); - } - }, [song.publicId]); + const { + registerMount, + loadSong, + togglePlayback, + isLoading, + loadingPhase, + error, + } = useSongPlayer(); + + const mountRef = useCallback( + (node: HTMLDivElement | null) => { + containerRef.current = node; + registerMount(node); + }, + [registerMount], + ); useEffect(() => { - isCancelledRef.current = false; - - loadSong(); - - return () => { - isCancelledRef.current = true; - }; - }, [loadSong]); + loadSong(song.publicId); + }, [loadSong, song.publicId]); useEffect(() => { if (!viewportRef.current || !containerRef.current) return; @@ -143,7 +89,7 @@ export const PlayerComponent = ({ }} >
{ - playerRef.current?.togglePlayback(); + togglePlayback(); }} style={{ position: 'absolute', diff --git a/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx b/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx new file mode 100644 index 00000000..d4744d16 --- /dev/null +++ b/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { PianoRollView, Player, Viewer } from '@opennbs/nbsvis'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { getSongOpenUrl } from '../../../util/downloadSong'; + +const RENDER_WIDTH = 1280; +const RENDER_HEIGHT = 768; + +type SongPlayerContextType = { + registerMount: (node: HTMLDivElement | null) => void; + loadSong: (publicId: string) => Promise; + play: () => void; + pause: () => void; + togglePlayback: () => boolean; + isLoading: boolean; + loadingPhase: string; + error: string | null; +}; + +const SongPlayerContext = createContext(null); + +export const SongPlayerProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const mountRef = useRef(null); + const viewerRef = useRef(null); + const playerRef = useRef(null); + const isCancelledRef = useRef(false); + const [isLoading, setIsLoading] = useState(false); + const [loadingPhase, setLoadingPhase] = useState('Preparing player...'); + const [error, setError] = useState(null); + + const attachViewerToMount = useCallback((mountNode: HTMLDivElement) => { + const viewer = viewerRef.current; + + if (!viewer) return; + + viewer.setResponsive(false); + mountNode.appendChild(viewer.app.canvas); + viewer.container = mountNode; + viewer.setResponsive(true); + viewer.resize(RENDER_WIDTH, RENDER_HEIGHT); + }, []); + + const registerMount = useCallback( + (node: HTMLDivElement | null) => { + mountRef.current = node; + + if (node && viewerRef.current) { + attachViewerToMount(node); + } + }, + [attachViewerToMount], + ); + + const loadSong = useCallback( + async (publicId: string) => { + try { + setIsLoading(true); + setError(null); + setLoadingPhase('Preparing player...'); + + const mountNode = mountRef.current; + if (!mountNode) { + throw new Error('Player container not ready'); + } + + if (!viewerRef.current || !playerRef.current) { + setLoadingPhase('Initializing viewer...'); + const viewer = new Viewer(mountNode); + + await viewer.init(); + await viewer.setView(new PianoRollView()); + viewer.resize(RENDER_WIDTH, RENDER_HEIGHT); + + if (isCancelledRef.current) return; + + const player = new Player(viewer, { + audioEngine: { + urlBase: `${window.location.origin}/`, + }, + }); + + viewerRef.current = viewer; + playerRef.current = player; + } else if (viewerRef.current.container !== mountNode) { + attachViewerToMount(mountNode); + } + + if (!playerRef.current) { + throw new Error('Player initialization failed'); + } + + setLoadingPhase('Loading song data...'); + + const songUrl = await getSongOpenUrl({ publicId }); + await Promise.resolve(playerRef.current.loadSong(songUrl)); + } catch (err) { + if (!isCancelledRef.current) { + setError(err instanceof Error ? err.message : 'Failed to load song'); + } + } finally { + if (!isCancelledRef.current) setIsLoading(false); + } + }, + [attachViewerToMount], + ); + + const play = useCallback(() => { + playerRef.current?.play(); + }, []); + + const pause = useCallback(() => { + playerRef.current?.pause(); + }, []); + + const togglePlayback = useCallback(() => { + return playerRef.current?.togglePlayback() ?? false; + }, []); + + useEffect(() => { + isCancelledRef.current = false; + + return () => { + isCancelledRef.current = true; + }; + }, []); + + const value = useMemo( + () => ({ + registerMount, + loadSong, + play, + pause, + togglePlayback, + isLoading, + loadingPhase, + error, + }), + [ + error, + isLoading, + loadSong, + loadingPhase, + pause, + play, + registerMount, + togglePlayback, + ], + ); + + return ( + + {children} + + ); +}; + +export const useSongPlayer = () => { + const context = useContext(SongPlayerContext); + + if (!context) { + throw new Error('useSongPlayer must be used within SongPlayerProvider'); + } + + return context; +}; From 6746b0927b7c0383f7fb0de4cdf76b79a58af5fd Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:21:07 -0300 Subject: [PATCH 06/23] feat: add fixed control bar to bottom of page for cross-page playback --- .../modules/browse/components/SongCard.tsx | 62 ++++- .../shared/components/layout/NavbarLayout.tsx | 2 + .../src/modules/song/components/Player.tsx | 74 ++++-- .../components/client/SongPlayerBottomBar.tsx | 157 ++++++++++++ .../client/context/SongPlayer.context.tsx | 228 ++++++++++++++++-- 5 files changed, 463 insertions(+), 60 deletions(-) create mode 100644 apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx diff --git a/apps/frontend/src/modules/browse/components/SongCard.tsx b/apps/frontend/src/modules/browse/components/SongCard.tsx index a47c41c0..8bd72d90 100644 --- a/apps/frontend/src/modules/browse/components/SongCard.tsx +++ b/apps/frontend/src/modules/browse/components/SongCard.tsx @@ -1,12 +1,21 @@ 'use client'; -import { faPlay } from '@fortawesome/free-solid-svg-icons'; +import { + faCirclePause, + faCirclePlay, + faPlay, +} from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Link from 'next/link'; import Skeleton from 'react-loading-skeleton'; import type { SongPreviewDtoType } from '@nbw/database'; +import { cn } from '@web/lib/utils'; import { formatDuration, formatTimeAgo } from '@web/modules/shared/util/format'; +import { + previewToPlayingSong, + useSongPlayer, +} from '@web/modules/song/components/client/context/SongPlayer.context'; import SongThumbnail from '../../shared/components/layout/SongThumbnail'; @@ -23,7 +32,7 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { ) : ( <> -
+
{formatDuration(song.duration)} @@ -67,17 +76,50 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { }; const SongCard = ({ song }: { song: SongPreviewDtoType | null }) => { - return !song ? ( - - ) : ( - + const { playSong, currentSong, isPlaying, isLoading } = useSongPlayer(); + + if (!song) { + return ; + } + + const isCurrent = currentSong?.publicId === song.publicId; + const showPause = isCurrent && isPlaying; + + const handlePlay = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void playSong(previewToPlayingSong(song)); + }; + + return ( +
+ + +
- +
- +
); }; diff --git a/apps/frontend/src/modules/shared/components/layout/NavbarLayout.tsx b/apps/frontend/src/modules/shared/components/layout/NavbarLayout.tsx index edc7397f..eb4fdb40 100644 --- a/apps/frontend/src/modules/shared/components/layout/NavbarLayout.tsx +++ b/apps/frontend/src/modules/shared/components/layout/NavbarLayout.tsx @@ -1,4 +1,5 @@ import { MiniSurveyBanner } from '@web/modules/browse/SurveyBanner'; +import { SongPlayerBottomBar } from '@web/modules/song/components/client/SongPlayerBottomBar'; import { SideRailAdSlot } from '../client/ads/AdSlots'; import { CookieBanner } from '../client/CookieBanner'; @@ -27,6 +28,7 @@ async function Layout({ children }: TNavbarLayoutProps) {
{/* Footer */}
+
diff --git a/apps/frontend/src/modules/song/components/Player.tsx b/apps/frontend/src/modules/song/components/Player.tsx index d612457a..de807b1c 100644 --- a/apps/frontend/src/modules/song/components/Player.tsx +++ b/apps/frontend/src/modules/song/components/Player.tsx @@ -1,10 +1,13 @@ 'use client'; -import { faPlay } from '@fortawesome/free-solid-svg-icons'; +import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useRef, useEffect, useCallback } from 'react'; -import { useSongPlayer } from './client/context/SongPlayer.context'; +import { + type PlayingSong, + useSongPlayer, +} from './client/context/SongPlayer.context'; const RENDER_WIDTH = 1280; const RENDER_HEIGHT = 768; @@ -13,48 +16,60 @@ export const PlayerComponent = ({ song, children, }: { - song: { publicId: string }; + song: PlayingSong; children: React.ReactNode; }) => { const viewportRef = useRef(null); const containerRef = useRef(null); const { - registerMount, + registerPageMount, loadSong, togglePlayback, isLoading, loadingPhase, error, + currentSong, + isPlaying, + isSongRoute, } = useSongPlayer(); + const applyScale = useCallback(() => { + if (!viewportRef.current || !containerRef.current) return; + + const viewportWidth = viewportRef.current.clientWidth; + const viewportHeight = viewportRef.current.clientHeight; + const scale = Math.min( + viewportWidth / RENDER_WIDTH, + viewportHeight / RENDER_HEIGHT, + ); + + containerRef.current.style.transform = `translate(-50%, -50%) scale(${scale})`; + }, []); + const mountRef = useCallback( (node: HTMLDivElement | null) => { containerRef.current = node; - registerMount(node); + registerPageMount(node); + + if (node) { + requestAnimationFrame(() => { + applyScale(); + }); + } }, - [registerMount], + [applyScale, registerPageMount], ); useEffect(() => { - loadSong(song.publicId); - }, [loadSong, song.publicId]); + void loadSong(song); + // Only load when this page's song changes. Reloading on loadSong identity + // would overwrite a different song started from a card. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [song.publicId]); useEffect(() => { if (!viewportRef.current || !containerRef.current) return; - const applyScale = () => { - if (!viewportRef.current || !containerRef.current) return; - - const viewportWidth = viewportRef.current.clientWidth; - const viewportHeight = viewportRef.current.clientHeight; - const scale = Math.min( - viewportWidth / RENDER_WIDTH, - viewportHeight / RENDER_HEIGHT, - ); - - containerRef.current.style.transform = `translate(-50%, -50%) scale(${scale})`; - }; - applyScale(); const observer = new ResizeObserver(() => { @@ -66,7 +81,13 @@ export const PlayerComponent = ({ return () => { observer.disconnect(); }; - }, []); + }, [applyScale]); + + useEffect(() => { + requestAnimationFrame(() => { + applyScale(); + }); + }, [applyScale, currentSong?.publicId, isSongRoute]); return (
- +
); diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx new file mode 100644 index 00000000..0dd4a92a --- /dev/null +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { faExpand, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef } from 'react'; + +import { formatDuration } from '@web/modules/shared/util/format'; + +import { useSongPlayer } from './context/SongPlayer.context'; + +const RENDER_WIDTH = 1280; +const RENDER_HEIGHT = 768; + +export function SongPlayerBottomBar() { + const router = useRouter(); + const viewportRef = useRef(null); + const containerRef = useRef(null); + const { + registerDockMount, + togglePlayback, + isLoading, + loadingPhase, + error, + currentSong, + isPlaying, + isSongRoute, + } = useSongPlayer(); + + const isVisible = !!currentSong && !isSongRoute; + + const applyScale = useCallback(() => { + if (!viewportRef.current || !containerRef.current) return; + + const viewportWidth = viewportRef.current.clientWidth; + const viewportHeight = viewportRef.current.clientHeight; + const scale = Math.min( + viewportWidth / RENDER_WIDTH, + viewportHeight / RENDER_HEIGHT, + ); + + containerRef.current.style.transform = `translate(-50%, -50%) scale(${scale})`; + }, []); + + const mountRef = useCallback( + (node: HTMLDivElement | null) => { + containerRef.current = node; + registerDockMount(node); + + if (node) { + requestAnimationFrame(() => { + applyScale(); + }); + } + }, + [applyScale, registerDockMount], + ); + + useEffect(() => { + if (!isVisible || !viewportRef.current || !containerRef.current) return; + + applyScale(); + + const observer = new ResizeObserver(() => { + applyScale(); + }); + + observer.observe(viewportRef.current); + + return () => { + observer.disconnect(); + }; + }, [applyScale, isVisible]); + + useEffect(() => { + if (!isVisible) return; + + requestAnimationFrame(() => { + applyScale(); + }); + }, [applyScale, currentSong?.publicId, isVisible]); + + return ( +
+
+
+
+
+
+
+ +
+
+ {currentSong?.title ?? ''} +
+
+ {currentSong + ? `${currentSong.uploader.username} · ${formatDuration( + currentSong.stats.duration, + )}` + : ''} +
+ {isLoading ? ( +
{loadingPhase}
+ ) : null} + {error ? ( +
{error}
+ ) : null} +
+ + + + +
+
+ ); +} diff --git a/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx b/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx index d4744d16..020612f8 100644 --- a/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx +++ b/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx @@ -1,6 +1,7 @@ 'use client'; import { PianoRollView, Player, Viewer } from '@opennbs/nbsvis'; +import { usePathname } from 'next/navigation'; import { createContext, useCallback, @@ -11,20 +12,57 @@ import { useState, } from 'react'; +import type { SongPreviewDtoType, SongViewDtoType } from '@nbw/database'; + import { getSongOpenUrl } from '../../../util/downloadSong'; const RENDER_WIDTH = 1280; const RENDER_HEIGHT = 768; +export type PlayingSong = Pick< + SongViewDtoType, + 'publicId' | 'title' | 'originalAuthor' +> & { + uploader: Pick; + stats: Pick; +}; + +export type LoadSongOptions = { + autoPlay?: boolean; +}; + +const toPlayingSong = (song: PlayingSong): PlayingSong => ({ + publicId: song.publicId, + title: song.title, + originalAuthor: song.originalAuthor, + uploader: { username: song.uploader.username }, + stats: { duration: song.stats.duration }, +}); + +export const previewToPlayingSong = ( + song: SongPreviewDtoType, +): PlayingSong => ({ + publicId: song.publicId, + title: song.title, + originalAuthor: song.originalAuthor, + uploader: { username: song.uploader.username }, + stats: { duration: song.duration }, +}); + type SongPlayerContextType = { - registerMount: (node: HTMLDivElement | null) => void; - loadSong: (publicId: string) => Promise; + registerPageMount: (node: HTMLDivElement | null) => void; + registerDockMount: (node: HTMLDivElement | null) => void; + loadSong: (song: PlayingSong, options?: LoadSongOptions) => Promise; + playSong: (song: PlayingSong) => Promise; play: () => void; pause: () => void; togglePlayback: () => boolean; isLoading: boolean; loadingPhase: string; error: string | null; + currentSong: PlayingSong | null; + isPlaying: boolean; + isSongRoute: boolean; }; const SongPlayerContext = createContext(null); @@ -34,13 +72,27 @@ export const SongPlayerProvider = ({ }: { children: React.ReactNode; }) => { - const mountRef = useRef(null); + const pathname = usePathname(); + const pageMountRef = useRef(null); + const dockMountRef = useRef(null); const viewerRef = useRef(null); const playerRef = useRef(null); + const loadedSongPublicIdRef = useRef(null); + const loadingSongPublicIdRef = useRef(null); + const currentSongPublicIdRef = useRef(null); const isCancelledRef = useRef(false); const [isLoading, setIsLoading] = useState(false); const [loadingPhase, setLoadingPhase] = useState('Preparing player...'); const [error, setError] = useState(null); + const [currentSong, setCurrentSong] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const isPlayingRef = useRef(false); + + const isSongRoute = useMemo(() => { + if (!pathname || !currentSong) return false; + + return pathname === `/song/${currentSong.publicId}`; + }, [currentSong, pathname]); const attachViewerToMount = useCallback((mountNode: HTMLDivElement) => { const viewer = viewerRef.current; @@ -54,25 +106,104 @@ export const SongPlayerProvider = ({ viewer.resize(RENDER_WIDTH, RENDER_HEIGHT); }, []); - const registerMount = useCallback( - (node: HTMLDivElement | null) => { - mountRef.current = node; + const getPreferredMount = useCallback( + (songPublicId?: string | null) => { + const id = songPublicId ?? currentSongPublicIdRef.current; + const onPlayingSongPage = !!id && pathname === `/song/${id}`; - if (node && viewerRef.current) { - attachViewerToMount(node); + if (onPlayingSongPage) { + return pageMountRef.current ?? dockMountRef.current; } + + return dockMountRef.current ?? pageMountRef.current; + }, + [pathname], + ); + + const syncViewerMount = useCallback(() => { + const viewer = viewerRef.current; + const targetMount = getPreferredMount(); + + if (!viewer || !targetMount) return; + if (viewer.container === targetMount) return; + + attachViewerToMount(targetMount); + }, [attachViewerToMount, getPreferredMount]); + + const registerPageMount = useCallback( + (node: HTMLDivElement | null) => { + pageMountRef.current = node; + syncViewerMount(); + }, + [syncViewerMount], + ); + + const registerDockMount = useCallback( + (node: HTMLDivElement | null) => { + dockMountRef.current = node; + syncViewerMount(); }, - [attachViewerToMount], + [syncViewerMount], ); + const setActiveSong = useCallback((song: PlayingSong) => { + currentSongPublicIdRef.current = song.publicId; + setCurrentSong(toPlayingSong(song)); + }, []); + + const setPlayingState = useCallback((playing: boolean) => { + isPlayingRef.current = playing; + setIsPlaying(playing); + }, []); + + const play = useCallback(() => { + playerRef.current?.play(); + setPlayingState(true); + }, [setPlayingState]); + + const pause = useCallback(() => { + playerRef.current?.pause(); + setPlayingState(false); + }, [setPlayingState]); + + const togglePlayback = useCallback(() => { + // nbsvis togglePlayback() reads a worklet flag that stays false until + // async audioEngine.play() finishes, so the first click after autoPlay + // would call play() again and invert React state. Toggle from our own flag. + if (isPlayingRef.current) { + pause(); + return false; + } + + play(); + return true; + }, [pause, play]); + const loadSong = useCallback( - async (publicId: string) => { + async (song: PlayingSong, options?: LoadSongOptions) => { + const publicId = song.publicId; + const autoPlay = options?.autoPlay ?? false; + + if ( + loadingSongPublicIdRef.current === publicId || + (loadedSongPublicIdRef.current === publicId && !!playerRef.current) + ) { + setActiveSong(song); + syncViewerMount(); + return; + } + + let didLoad = false; + try { + loadingSongPublicIdRef.current = publicId; setIsLoading(true); setError(null); + setPlayingState(false); + setActiveSong(song); setLoadingPhase('Preparing player...'); - const mountNode = mountRef.current; + const mountNode = getPreferredMount(publicId); if (!mountNode) { throw new Error('Player container not ready'); } @@ -95,40 +226,73 @@ export const SongPlayerProvider = ({ viewerRef.current = viewer; playerRef.current = player; - } else if (viewerRef.current.container !== mountNode) { - attachViewerToMount(mountNode); + player.on('ended', () => setPlayingState(false)); + player.on('stop', () => setPlayingState(false)); + } else { + syncViewerMount(); } if (!playerRef.current) { throw new Error('Player initialization failed'); } + // Stop current song (TODO: unload) + try { + playerRef.current.stop(); + } catch { + // Ignore stop errors (e.g. if no song loaded) + } + setLoadingPhase('Loading song data...'); const songUrl = await getSongOpenUrl({ publicId }); await Promise.resolve(playerRef.current.loadSong(songUrl)); + loadedSongPublicIdRef.current = publicId; + didLoad = true; } catch (err) { if (!isCancelledRef.current) { setError(err instanceof Error ? err.message : 'Failed to load song'); } } finally { + loadingSongPublicIdRef.current = null; if (!isCancelledRef.current) setIsLoading(false); + + try { + if (didLoad && autoPlay) { + playerRef.current?.play(); + if (!isCancelledRef.current) setPlayingState(true); + } else { + playerRef.current?.pause(); + if (!isCancelledRef.current) setPlayingState(false); + } + } catch { + if (!isCancelledRef.current) setPlayingState(false); + } } }, - [attachViewerToMount], + [getPreferredMount, setActiveSong, setPlayingState, syncViewerMount], ); - const play = useCallback(() => { - playerRef.current?.play(); - }, []); + const playSong = useCallback( + async (song: PlayingSong) => { + const publicId = song.publicId; - const pause = useCallback(() => { - playerRef.current?.pause(); - }, []); + if (loadingSongPublicIdRef.current === publicId) { + setActiveSong(song); + return; + } - const togglePlayback = useCallback(() => { - return playerRef.current?.togglePlayback() ?? false; - }, []); + if (loadedSongPublicIdRef.current === publicId && playerRef.current) { + setActiveSong(song); + syncViewerMount(); + togglePlayback(); + return; + } + + await loadSong(song, { autoPlay: true }); + }, + [loadSong, setActiveSong, syncViewerMount, togglePlayback], + ); useEffect(() => { isCancelledRef.current = false; @@ -138,25 +302,39 @@ export const SongPlayerProvider = ({ }; }, []); + useEffect(() => { + syncViewerMount(); + }, [syncViewerMount]); + const value = useMemo( () => ({ - registerMount, + registerPageMount, + registerDockMount, loadSong, + playSong, play, pause, togglePlayback, isLoading, loadingPhase, error, + currentSong, + isPlaying, + isSongRoute, }), [ + currentSong, error, isLoading, + isPlaying, + isSongRoute, loadSong, loadingPhase, pause, play, - registerMount, + playSong, + registerDockMount, + registerPageMount, togglePlayback, ], ); From 53f4f8498c2d52a1c634529c4b244eabef3c736c Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:02:25 -0300 Subject: [PATCH 07/23] feat(player): add seek, looping, volume, expand and stop playback controls --- .../modules/shared/components/ui/slider.tsx | 10 +- .../components/client/SongPlayerBottomBar.tsx | 264 ++++++++++++++---- .../client/context/SongPlayer.context.tsx | 103 ++++++- 3 files changed, 322 insertions(+), 55 deletions(-) diff --git a/apps/frontend/src/modules/shared/components/ui/slider.tsx b/apps/frontend/src/modules/shared/components/ui/slider.tsx index fd178577..bd065035 100644 --- a/apps/frontend/src/modules/shared/components/ui/slider.tsx +++ b/apps/frontend/src/modules/shared/components/ui/slider.tsx @@ -12,15 +12,17 @@ const Slider = React.forwardRef< - - + + - + )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 0dd4a92a..6cafb82b 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -1,24 +1,56 @@ 'use client'; -import { faExpand, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; +import { + faChevronUp, + faCirclePause, + faCirclePlay, + faClose, + faRepeat, + faVolumeHigh, + faVolumeXmark, +} from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useRef } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { cn } from '@web/lib/utils'; +import { Slider } from '@web/modules/shared/components/ui/slider'; import { formatDuration } from '@web/modules/shared/util/format'; import { useSongPlayer } from './context/SongPlayer.context'; const RENDER_WIDTH = 1280; const RENDER_HEIGHT = 768; +const PLAYER_BAR_HEIGHT_VAR = '--song-player-bar-height'; + +const clearPlayerBarHeight = () => { + document.documentElement.style.removeProperty(PLAYER_BAR_HEIGHT_VAR); +}; export function SongPlayerBottomBar() { const router = useRouter(); + const barRef = useRef(null); const viewportRef = useRef(null); const containerRef = useRef(null); + const isScrubbingRef = useRef(false); + const [tick, setTick] = useState(0); + const [lengthTicks, setLengthTicks] = useState(0); const { registerDockMount, togglePlayback, + seek, + subscribeToSeek, + isLooping, + toggleLoop, + volume, + setVolume, + stopPlayback, isLoading, loadingPhase, error, @@ -28,6 +60,10 @@ export function SongPlayerBottomBar() { } = useSongPlayer(); const isVisible = !!currentSong && !isSongRoute; + const duration = currentSong?.stats.duration ?? 0; + const progress = + lengthTicks > 0 ? Math.min(1, Math.max(0, tick / lengthTicks)) : 0; + const currentTime = duration * progress; const applyScale = useCallback(() => { if (!viewportRef.current || !containerRef.current) return; @@ -56,8 +92,34 @@ export function SongPlayerBottomBar() { [applyScale, registerDockMount], ); + useLayoutEffect(() => { + if (!isVisible) { + clearPlayerBarHeight(); + return; + } + + const node = barRef.current; + if (!node) return; + + const syncHeight = () => { + document.documentElement.style.setProperty( + PLAYER_BAR_HEIGHT_VAR, + `${node.getBoundingClientRect().height}px`, + ); + }; + + syncHeight(); + const observer = new ResizeObserver(syncHeight); + observer.observe(node); + + return () => { + observer.disconnect(); + clearPlayerBarHeight(); + }; + }, [isVisible]); + useEffect(() => { - if (!isVisible || !viewportRef.current || !containerRef.current) return; + if (!viewportRef.current || !containerRef.current) return; applyScale(); @@ -73,22 +135,94 @@ export function SongPlayerBottomBar() { }, [applyScale, isVisible]); useEffect(() => { - if (!isVisible) return; - requestAnimationFrame(() => { applyScale(); }); }, [applyScale, currentSong?.publicId, isVisible]); + useEffect(() => { + setTick(0); + setLengthTicks(0); + + return subscribeToSeek((event) => { + if (isScrubbingRef.current) return; + setTick(event.tick); + setLengthTicks(event.totalLength); + }); + }, [currentSong?.publicId, subscribeToSeek]); + + const expandSong = () => { + if (!currentSong) return; + router.push(`/song/${currentSong.publicId}`); + }; + return (
-
-
+
+ + + + + + {formatDuration(currentTime)} + + + 0 ? lengthTicks : 1} + step={1} + value={[lengthTicks > 0 ? tick : 0]} + disabled={!currentSong || lengthTicks <= 0} + onValueChange={(value) => { + isScrubbingRef.current = true; + setTick(value[0] ?? 0); + }} + onValueCommit={(value) => { + seek(value[0] ?? 0); + isScrubbingRef.current = false; + }} + className='min-w-16 flex-1' + aria-label='Playback progress' + /> + + + {formatDuration(duration)} + + + -
-
+
-
+ + {currentSong - ? `${currentSong.uploader.username} · ${formatDuration( - currentSong.stats.duration, - )}` + ? currentSong.originalAuthor || currentSong.uploader.username : ''} + + + + {isLoading ? ( + + {loadingPhase} + + ) : null} + {error ? ( + + {error} + + ) : null} + +
+
+ +
+
+ setVolume(value[0] ?? 0)} + aria-label='Volume' + /> +
+
- {isLoading ? ( -
{loadingPhase}
- ) : null} - {error ? ( -
{error}
- ) : null} -
- + - + +
); diff --git a/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx b/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx index 020612f8..103b248c 100644 --- a/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx +++ b/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx @@ -16,6 +16,10 @@ import type { SongPreviewDtoType, SongViewDtoType } from '@nbw/database'; import { getSongOpenUrl } from '../../../util/downloadSong'; +type NbsPlayer = Player & { + setVolume?: (volume: number) => void; +}; + const RENDER_WIDTH = 1280; const RENDER_HEIGHT = 768; @@ -49,6 +53,11 @@ export const previewToPlayingSong = ( stats: { duration: song.duration }, }); +export type SeekEvent = { + tick: number; + totalLength: number; +}; + type SongPlayerContextType = { registerPageMount: (node: HTMLDivElement | null) => void; registerDockMount: (node: HTMLDivElement | null) => void; @@ -57,6 +66,13 @@ type SongPlayerContextType = { play: () => void; pause: () => void; togglePlayback: () => boolean; + seek: (tick: number) => void; + subscribeToSeek: (handler: (event: SeekEvent) => void) => () => void; + isLooping: boolean; + toggleLoop: () => void; + volume: number; + setVolume: (volume: number) => void; + stopPlayback: () => void; isLoading: boolean; loadingPhase: string; error: string | null; @@ -87,6 +103,11 @@ export const SongPlayerProvider = ({ const [currentSong, setCurrentSong] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const isPlayingRef = useRef(false); + const [isLooping, setIsLooping] = useState(false); + const isLoopingRef = useRef(false); + const [volume, setVolumeState] = useState(1); + const volumeRef = useRef(1); + const seekListenersRef = useRef(new Set<(event: SeekEvent) => void>()); const isSongRoute = useMemo(() => { if (!pathname || !currentSong) return false; @@ -179,6 +200,62 @@ export const SongPlayerProvider = ({ return true; }, [pause, play]); + const applyPlayerSettings = useCallback((player: Player) => { + try { + player.loop = isLoopingRef.current; + } catch { + // Audio engine may not be initialized yet + } + try { + (player as NbsPlayer).setVolume?.(volumeRef.current); + } catch { + // Volume API is not available until nbsvis exposes it + } + }, []); + + const seek = useCallback((tick: number) => { + playerRef.current?.seek(tick); + }, []); + + const subscribeToSeek = useCallback((handler: (event: SeekEvent) => void) => { + seekListenersRef.current.add(handler); + return () => { + seekListenersRef.current.delete(handler); + }; + }, []); + + const toggleLoop = useCallback(() => { + const next = !isLoopingRef.current; + isLoopingRef.current = next; + setIsLooping(next); + try { + if (playerRef.current) { + playerRef.current.loop = next; + } + } catch { + // Audio engine may not be initialized yet + } + }, []); + + const setVolume = useCallback((nextVolume: number) => { + const next = Math.min(1, Math.max(0, nextVolume)); + volumeRef.current = next; + setVolumeState(next); + (playerRef.current as NbsPlayer | null)?.setVolume?.(next); + }, []); + + const stopPlayback = useCallback(() => { + try { + playerRef.current?.stop(); + } catch { + // Ignore stop errors (e.g. if no song loaded) + } + setPlayingState(false); + currentSongPublicIdRef.current = null; + setCurrentSong(null); + setError(null); + }, [setPlayingState]); + const loadSong = useCallback( async (song: PlayingSong, options?: LoadSongOptions) => { const publicId = song.publicId; @@ -228,6 +305,9 @@ export const SongPlayerProvider = ({ playerRef.current = player; player.on('ended', () => setPlayingState(false)); player.on('stop', () => setPlayingState(false)); + player.on('seek', (event) => { + seekListenersRef.current.forEach((handler) => handler(event)); + }); } else { syncViewerMount(); } @@ -248,6 +328,7 @@ export const SongPlayerProvider = ({ const songUrl = await getSongOpenUrl({ publicId }); await Promise.resolve(playerRef.current.loadSong(songUrl)); loadedSongPublicIdRef.current = publicId; + applyPlayerSettings(playerRef.current); didLoad = true; } catch (err) { if (!isCancelledRef.current) { @@ -270,7 +351,13 @@ export const SongPlayerProvider = ({ } } }, - [getPreferredMount, setActiveSong, setPlayingState, syncViewerMount], + [ + applyPlayerSettings, + getPreferredMount, + setActiveSong, + setPlayingState, + syncViewerMount, + ], ); const playSong = useCallback( @@ -315,6 +402,13 @@ export const SongPlayerProvider = ({ play, pause, togglePlayback, + seek, + subscribeToSeek, + isLooping, + toggleLoop, + volume, + setVolume, + stopPlayback, isLoading, loadingPhase, error, @@ -326,6 +420,7 @@ export const SongPlayerProvider = ({ currentSong, error, isLoading, + isLooping, isPlaying, isSongRoute, loadSong, @@ -335,7 +430,13 @@ export const SongPlayerProvider = ({ playSong, registerDockMount, registerPageMount, + seek, + setVolume, + stopPlayback, + subscribeToSeek, + toggleLoop, togglePlayback, + volume, ], ); From 0b32aabcde83617f33976d190a908d6879f7c5e2 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:21:05 -0300 Subject: [PATCH 08/23] fix(player): adjust a lot of styling in the bottom bar --- .../components/client/SongPlayerBottomBar.tsx | 153 ++++++++++-------- 1 file changed, 82 insertions(+), 71 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 6cafb82b..33247c3a 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -165,64 +165,9 @@ export function SongPlayerBottomBar() { isVisible ? '' : 'invisible pointer-events-none', )} > -
- - - - - - {formatDuration(currentTime)} - - - 0 ? lengthTicks : 1} - step={1} - value={[lengthTicks > 0 ? tick : 0]} - disabled={!currentSong || lengthTicks <= 0} - onValueChange={(value) => { - isScrubbingRef.current = true; - setTick(value[0] ?? 0); - }} - onValueCommit={(value) => { - seek(value[0] ?? 0); - isScrubbingRef.current = false; - }} - className='min-w-16 flex-1' - aria-label='Playback progress' - /> - - - {formatDuration(duration)} - - - +
+ {/* Song title and artist */} + {/* Loading/error message display */} {isLoading ? ( {loadingPhase} @@ -266,7 +218,63 @@ export function SongPlayerBottomBar() { ) : null} -
+ {/* Play/Pause button */} + + + {/* Loop button */} + + +
+ {/* Playback progress */} + 0 ? lengthTicks : 1} + step={1} + value={[lengthTicks > 0 ? tick : 0]} + disabled={!currentSong || lengthTicks <= 0} + onValueChange={(value) => { + isScrubbingRef.current = true; + setTick(value[0] ?? 0); + }} + onValueCommit={(value) => { + seek(value[0] ?? 0); + isScrubbingRef.current = false; + }} + className='min-w-16 w-full row-2' + aria-label='Playback progress' + /> + + {/* Current time */} +
+ {`${formatDuration( + currentTime, + )}`} + {` / ${formatDuration(duration)}`} +
+
+ +
+ {/* Volume button */}
-
+
setVolume(value[0] ?? 0)} aria-label='Volume' + className='' />
+ {/* Expand button (open song page) */} + {/* Stop button (close player) */}
From 1d94f4ba85d4d4606f15096b07ea6f7c4aca1a74 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:04:34 -0300 Subject: [PATCH 09/23] refactor(player): remove leftover player bar height tracking on styles Had been used to pad the body + cookie banner to adjust to the bar. It's sticky now, so that's no longer needed. --- .../components/client/SongPlayerBottomBar.tsx | 39 +------------------ 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 33247c3a..523da372 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -11,13 +11,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useRouter } from 'next/navigation'; -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { cn } from '@web/lib/utils'; import { Slider } from '@web/modules/shared/components/ui/slider'; @@ -27,11 +21,6 @@ import { useSongPlayer } from './context/SongPlayer.context'; const RENDER_WIDTH = 1280; const RENDER_HEIGHT = 768; -const PLAYER_BAR_HEIGHT_VAR = '--song-player-bar-height'; - -const clearPlayerBarHeight = () => { - document.documentElement.style.removeProperty(PLAYER_BAR_HEIGHT_VAR); -}; export function SongPlayerBottomBar() { const router = useRouter(); @@ -92,32 +81,6 @@ export function SongPlayerBottomBar() { [applyScale, registerDockMount], ); - useLayoutEffect(() => { - if (!isVisible) { - clearPlayerBarHeight(); - return; - } - - const node = barRef.current; - if (!node) return; - - const syncHeight = () => { - document.documentElement.style.setProperty( - PLAYER_BAR_HEIGHT_VAR, - `${node.getBoundingClientRect().height}px`, - ); - }; - - syncHeight(); - const observer = new ResizeObserver(syncHeight); - observer.observe(node); - - return () => { - observer.disconnect(); - clearPlayerBarHeight(); - }; - }, [isVisible]); - useEffect(() => { if (!viewportRef.current || !containerRef.current) return; From 205be71afbfa1b790be8382b6474b8e86b524824 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:22:03 -0300 Subject: [PATCH 10/23] fix(player): subtly adjust size of buttons and add hover scale change --- .../song/components/client/SongPlayerBottomBar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 523da372..7b3ae277 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -185,7 +185,7 @@ export function SongPlayerBottomBar() { From 72d43fe85d6f42a0cfe8a1172df408890f53b831 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:22:56 -0300 Subject: [PATCH 12/23] feat(player): implement marquee text on song title display when too long --- apps/frontend/src/app/globals.css | 25 ++++ .../shared/components/client/MarqueeText.tsx | 131 ++++++++++++++++++ .../components/client/SongPlayerBottomBar.tsx | 7 +- 3 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/modules/shared/components/client/MarqueeText.tsx diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index b4a005fa..cf127541 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -72,6 +72,31 @@ body { /************** Animations **************/ +@keyframes marquee { + 0%, + 18% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +@utility animate-marquee { + animation: marquee var(--marquee-duration, 8s) linear infinite; +} + +@utility marquee-fade { + --marquee-mask-left-alpha: 1; + mask-image: linear-gradient( + to right, + rgb(0 0 0 / var(--marquee-mask-left-alpha)) 0, + black 8px, + black calc(100% - 16px), + transparent + ); +} + @keyframes bounce2 { 25% { transform-origin: center bottom; diff --git a/apps/frontend/src/modules/shared/components/client/MarqueeText.tsx b/apps/frontend/src/modules/shared/components/client/MarqueeText.tsx new file mode 100644 index 00000000..5efb56b7 --- /dev/null +++ b/apps/frontend/src/modules/shared/components/client/MarqueeText.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { cn } from '@web/lib/utils'; + +const GAP_PX = 32; +const PX_PER_SECOND = 32; +const MIN_DURATION_S = 8; +const LEFT_FADE_PX = 8; + +export function MarqueeText({ + children, + className, +}: { + children: string; + className?: string; +}) { + const containerRef = useRef(null); + const trackRef = useRef(null); + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [reduceMotion, setReduceMotion] = useState(false); + const [duration, setDuration] = useState(MIN_DURATION_S); + + useLayoutEffect(() => { + const container = containerRef.current; + const text = textRef.current; + if (!container || !text) return; + + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + + const measure = () => { + const overflowing = text.scrollWidth - container.clientWidth > 1; + const prefersReducedMotion = mediaQuery.matches; + + setIsOverflowing(overflowing); + setReduceMotion(prefersReducedMotion); + + if (overflowing && !prefersReducedMotion) { + setDuration( + Math.max(MIN_DURATION_S, (text.scrollWidth + GAP_PX) / PX_PER_SECOND), + ); + } + }; + + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(container); + observer.observe(text); + mediaQuery.addEventListener('change', measure); + + return () => { + observer.disconnect(); + mediaQuery.removeEventListener('change', measure); + }; + }, [children]); + + const shouldMarquee = isOverflowing && !reduceMotion; + + useEffect(() => { + const container = containerRef.current; + const track = trackRef.current; + + if (!shouldMarquee || !container || !track) { + container?.style.setProperty('--marquee-mask-left-alpha', '1'); + return; + } + + let frame = 0; + + const update = () => { + const tx = new DOMMatrix(getComputedStyle(track).transform).m41; + const loopWidth = track.scrollWidth / 2; + const distFromStart = + loopWidth > 0 ? Math.min(Math.abs(tx), Math.abs(tx + loopWidth)) : 0; + + container.style.setProperty( + '--marquee-mask-left-alpha', + distFromStart < LEFT_FADE_PX ? '1' : '0', + ); + frame = requestAnimationFrame(update); + }; + + frame = requestAnimationFrame(update); + return () => cancelAnimationFrame(frame); + }, [shouldMarquee]); + + return ( +
+
+ + {children} + + {shouldMarquee ? ( + <> + + + {children} + + + + ) : null} +
+
+ ); +} diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 2906c47f..3eb1205c 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -15,6 +15,7 @@ import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { cn } from '@web/lib/utils'; +import { MarqueeText } from '@web/modules/shared/components/client/MarqueeText'; import { Slider } from '@web/modules/shared/components/ui/slider'; import { formatDuration } from '@web/modules/shared/util/format'; @@ -166,11 +167,11 @@ export function SongPlayerBottomBar() { - {/* Loading/error message display */} - {isLoading ? ( - - {loadingPhase} - - ) : null} - {error ? ( - - {error} - - ) : null} - {/* Play/Pause button */}
From 5b3ad7b262d2c67b781c5e086e61d0bdf1254ea6 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:35:19 -0300 Subject: [PATCH 17/23] fix(player): implement popup slider for volume control --- apps/frontend/package.json | 4 +- .../components/client/SongPlayerBottomBar.tsx | 49 +++++++--------- bun.lock | 58 ++++++++++++++----- 3 files changed, 68 insertions(+), 43 deletions(-) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index a5438a86..2ecc69fa 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -10,8 +10,8 @@ "test": "jest" }, "dependencies": { - "@fortawesome/free-brands-svg-icons": "^6.7.2", - "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/free-brands-svg-icons": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/react-fontawesome": "^0.2.6", "@headlessui/react": "^1.7.19", "@hookform/resolvers": "^5.2.2", diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 2ae2cea1..67cfafa3 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -229,34 +229,29 @@ export function SongPlayerBottomBar() {
-
- {/* Volume button */} -
- +
+
+ setVolume(value[0] ?? 0)} + aria-label='Volume' + className='' /> - -
-
- setVolume(value[0] ?? 0)} - aria-label='Volume' - className='' - /> -
diff --git a/bun.lock b/bun.lock index 1766a07b..3e37800f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@nbw/root", @@ -108,8 +107,8 @@ "name": "@nbw/frontend", "version": "0.1.0", "dependencies": { - "@fortawesome/free-brands-svg-icons": "^6.7.2", - "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/free-brands-svg-icons": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/react-fontawesome": "^0.2.6", "@headlessui/react": "^1.7.19", "@hookform/resolvers": "^5.2.2", @@ -121,6 +120,7 @@ "@nbw/thumbnail": "workspace:*", "@next/mdx": "^16.0.8", "@next/third-parties": "^16.0.8", + "@opennbs/nbsvis": "^0.4.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", @@ -482,13 +482,13 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.7.2", "", {}, "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg=="], + "@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.3.1", "", {}, "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA=="], "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="], - "@fortawesome/free-brands-svg-icons": ["@fortawesome/free-brands-svg-icons@6.7.2", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" } }, "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q=="], + "@fortawesome/free-brands-svg-icons": ["@fortawesome/free-brands-svg-icons@7.3.1", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.3.1" } }, "sha512-8eiNzycuFhy3mZsi5EVO8JpO3H8l7/kSCl4Gk4iUew9hJuzYFqAAD6kqgeEup3+YZ9hL+5cjjorGzrRLkYuURg=="], - "@fortawesome/free-solid-svg-icons": ["@fortawesome/free-solid-svg-icons@6.7.2", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" } }, "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA=="], + "@fortawesome/free-solid-svg-icons": ["@fortawesome/free-solid-svg-icons@7.3.1", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.3.1" } }, "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg=="], "@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@0.2.6", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7", "react": "^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg=="], @@ -680,6 +680,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@nbsjs/core": ["@nbsjs/core@6.1.0", "", {}, "sha512-mc9Y810VOa2+ioKD/jNPsBsbWj0ZfGCWNlrbpFddgbjviXj2SIzfpuaWAFghe/rELHDYZZfWGkfx5TguTLk1Iw=="], + "@nbw/backend": ["@nbw/backend@workspace:apps/backend"], "@nbw/config": ["@nbw/config@workspace:packages/configs"], @@ -772,8 +774,12 @@ "@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="], + "@opennbs/nbsvis": ["@opennbs/nbsvis@0.4.2", "", { "dependencies": { "@encode42/nbs.js": "^5.0.2", "@nbsjs/core": "^6.0.0", "avl": "^1.6.0", "jszip": "^3.10.1", "mitt": "^3.0.1" }, "peerDependencies": { "pixi.js": "^8.8.1" } }, "sha512-qhjtlb8CxmVJ0eDU46s6js54TyAOvXr5Rgn4JHOoviJ1mVrOy4rJSpfvtj97lVqHXf9BivVdpgumOt0wOmmpjA=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "@pixi/colord": ["@pixi/colord@2.9.6", "", {}, "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], @@ -1032,6 +1038,8 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/earcut": ["@types/earcut@3.0.0", "", {}, "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ=="], + "@types/ejs": ["@types/ejs@3.1.5", "", {}, "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg=="], "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], @@ -1236,6 +1244,10 @@ "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + "@webgpu/types": ["@webgpu/types@0.1.72", "", {}, "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], @@ -1322,6 +1334,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "avl": ["avl@1.6.0", "", {}, "sha512-sXZcG/wbnIWbJJnH0wQrZl0iyzorHUQaL1BX9+8byrhIp2iPaXnloQ3bFkXTBEHKrshK6wcWe6iGblgMNXarUA=="], + "axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="], "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], @@ -1594,6 +1608,8 @@ "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], + "earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -1846,6 +1862,8 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "gifuct-js": ["gifuct-js@2.1.2", "", { "dependencies": { "js-binary-schema-parser": "^2.0.3" } }, "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg=="], + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -2048,6 +2066,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "ismobilejs": ["ismobilejs@1.1.1", "", {}, "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], @@ -2122,6 +2142,8 @@ "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], + "js-binary-schema-parser": ["js-binary-schema-parser@2.0.3", "", {}, "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg=="], + "js-confetti": ["js-confetti@0.13.1", "", {}, "sha512-Kau3apum0zYIvELfZv0lL5VY7dvzpbKi4Oq0XdfjDKdNZc/O+IeGRyLEej7GJ/1APbASvb+ZTWDxmTxxSFkTOg=="], "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], @@ -2420,6 +2442,8 @@ "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mjml": ["mjml@4.17.2", "", { "dependencies": { "@babel/runtime": "^7.28.4", "mjml-cli": "4.17.2", "mjml-core": "4.17.2", "mjml-migrate": "4.17.2", "mjml-preset-core": "4.17.2", "mjml-validator": "4.17.2" }, "bin": { "mjml": "bin/mjml" } }, "sha512-hAx9azG9lk/fQg6HuvMlc7IcgYLkXqT730psppHf8vGZ4L7j0Wr5NRq0B9Dm+aon6b+1WZrelPO9mIxnjgYiKA=="], "mjml-accordion": ["mjml-accordion@4.17.2", "", { "dependencies": { "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", "mjml-core": "4.17.2" } }, "sha512-faepE2yYKrpJxSHluQZV13D8ibqEar5SsPmT//MTnE0s0kW1KvEOaY+oXdgSGBbWWnvdvNQm4APxRz5yDCueUQ=="], @@ -2624,6 +2648,8 @@ "parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="], + "parse-svg-path": ["parse-svg-path@0.2.0", "", {}, "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -2676,6 +2702,8 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pixi.js": ["pixi.js@8.20.1", "", { "dependencies": { "@pixi/colord": "^2.9.6", "@types/earcut": "^3.0.0", "@webgpu/types": "^0.1.69", "@xmldom/xmldom": "^0.8.15", "earcut": "^3.0.2", "eventemitter3": "^5.0.1", "gifuct-js": "^2.1.2", "ismobilejs": "^1.1.1", "parse-svg-path": "^0.2.0", "tiny-lru": "^11.4.7" } }, "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ=="], + "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], @@ -3042,6 +3070,8 @@ "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + "tiny-lru": ["tiny-lru@11.4.7", "", {}, "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], @@ -3368,17 +3398,17 @@ "@nbw/backend/@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], - "@nbw/config/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@nbw/config/@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], - "@nbw/database/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@nbw/database/@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@nbw/frontend/@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], - "@nbw/song/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@nbw/song/@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@nbw/sounds/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], - "@nbw/thumbnail/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@nbw/thumbnail/@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@nbw/thumbnail/jest": ["jest@29.7.0", "", { "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", "import-local": "^3.0.2", "jest-cli": "^29.7.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw=="], @@ -4134,17 +4164,17 @@ "@nbw/backend/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@nbw/config/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "@nbw/config/@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], - "@nbw/database/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "@nbw/database/@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "@nbw/frontend/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@nbw/song/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "@nbw/song/@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "@nbw/sounds/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], - "@nbw/thumbnail/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "@nbw/thumbnail/@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "@nbw/thumbnail/jest/@jest/core": ["@jest/core@29.7.0", "", { "dependencies": { "@jest/console": "^29.7.0", "@jest/reporters": "^29.7.0", "@jest/test-result": "^29.7.0", "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", "ci-info": "^3.2.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-changed-files": "^29.7.0", "jest-config": "^29.7.0", "jest-haste-map": "^29.7.0", "jest-message-util": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-resolve": "^29.7.0", "jest-resolve-dependencies": "^29.7.0", "jest-runner": "^29.7.0", "jest-runtime": "^29.7.0", "jest-snapshot": "^29.7.0", "jest-util": "^29.7.0", "jest-validate": "^29.7.0", "jest-watcher": "^29.7.0", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-ansi": "^6.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg=="], From 7a0e539567ac1d9a127119653cfbf3bd89732900 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:43:59 -0300 Subject: [PATCH 18/23] fix(player): add more volume icon gradations --- .../song/components/client/SongPlayerBottomBar.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 67cfafa3..3b036127 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -7,8 +7,10 @@ import { faClose, faRepeat, faVolumeHigh, + faVolume, faVolumeLow, faVolumeXmark, + faVolumeOff, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useRouter } from 'next/navigation'; @@ -118,9 +120,11 @@ export function SongPlayerBottomBar() { router.push(`/song/${currentSong.publicId}`); }; - const getVolumeIcon = (volume: number) => { - if (volume === 0) return faVolumeXmark; - if (volume <= 0.33) return faVolumeLow; + const getVolumeIcon = (volume: number | 'mute') => { + if (volume === 'mute' || volume === 0) return faVolumeXmark; + if (volume <= 0.33) return faVolumeOff; + if (volume <= 0.67) return faVolumeLow; + if (volume <= 0.95) return faVolume; return faVolumeHigh; }; From de56dfdc77550d25c539b2706bf747b9ec65f379 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:44:58 -0300 Subject: [PATCH 19/23] fix(player): adjust size of control icons in bottom bar --- .../modules/song/components/client/SongPlayerBottomBar.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 3b036127..e081bca0 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -201,7 +201,7 @@ export function SongPlayerBottomBar() { aria-pressed={isLooping} title={isLooping ? 'Loop on' : 'Loop off'} > - +
@@ -267,7 +267,7 @@ export function SongPlayerBottomBar() { aria-label='Open song page' title='Open song' > - + {/* Stop button (close player) */} @@ -278,7 +278,7 @@ export function SongPlayerBottomBar() { aria-label='Stop playback' title='Close player' > - +
From 220a7d536ca88535bf3479a2d5fffd9f55ebe180 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:45:22 -0300 Subject: [PATCH 20/23] fix(player): make bottom bar responsive with title layout change --- .../components/client/SongPlayerBottomBar.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index e081bca0..7a934f3a 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -139,7 +139,7 @@ export function SongPlayerBottomBar() { >
{/* Miniplayer */} -
+
{currentSong?.title ?? ''} @@ -204,7 +204,16 @@ export function SongPlayerBottomBar() { -
+
+
+ + {currentSong?.title ?? ''} + + + {currentSong ? currentSong.uploader.username : ''} + +
+ {/* Playback progress */} Date: Wed, 16 Sep 2026 21:21:04 -0300 Subject: [PATCH 21/23] fix(player): incorrect nesting of player bar buttons --- .../components/client/SongPlayerBottomBar.tsx | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 7a934f3a..23a24b63 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -267,29 +267,29 @@ export function SongPlayerBottomBar() { />
+
- {/* Expand button (open song page) */} - + {/* Expand button (open song page) */} + - {/* Stop button (close player) */} - -
+ {/* Stop button (close player) */} +
); From 42469ae4e7c393981c02b64e66c3a715dcd249c8 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:23:45 -0300 Subject: [PATCH 22/23] refactor(player): extract `VolumeButton` component from player bar --- .../components/client/SongPlayerBottomBar.tsx | 42 +------------------ 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx index 23a24b63..df07da53 100644 --- a/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -6,11 +6,6 @@ import { faCirclePlay, faClose, faRepeat, - faVolumeHigh, - faVolume, - faVolumeLow, - faVolumeXmark, - faVolumeOff, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useRouter } from 'next/navigation'; @@ -22,6 +17,7 @@ import { Slider } from '@web/modules/shared/components/ui/slider'; import { formatDuration } from '@web/modules/shared/util/format'; import { useSongPlayer } from './context/SongPlayer.context'; +import { VolumeButton } from './VolumeButton'; const RENDER_WIDTH = 1280; const RENDER_HEIGHT = 768; @@ -41,8 +37,6 @@ export function SongPlayerBottomBar() { subscribeToSeek, isLooping, toggleLoop, - volume, - setVolume, stopPlayback, currentSong, isPlaying, @@ -120,14 +114,6 @@ export function SongPlayerBottomBar() { router.push(`/song/${currentSong.publicId}`); }; - const getVolumeIcon = (volume: number | 'mute') => { - if (volume === 'mute' || volume === 0) return faVolumeXmark; - if (volume <= 0.33) return faVolumeOff; - if (volume <= 0.67) return faVolumeLow; - if (volume <= 0.95) return faVolume; - return faVolumeHigh; - }; - return (
{/* Volume button */} -
- -
-
- setVolume(value[0] ?? 0)} - aria-label='Volume' - className='' - /> -
-
-
+ {/* Expand button (open song page) */} +
+
+ { + // Freeze unmute target at the level before this drag/press. + if (volume > 0) { + setVolume(volume); + } + }} + onValueChange={(value) => + setVolume(value[0] ?? 0, { rememberAudible: false }) + } + onValueCommit={(value) => { + const next = value[0] ?? 0; + // Remember the settled level only when still audible; if muted, + // keep the pre-press level from onPointerDown. + setVolume(next, { rememberAudible: next > 0 }); + }} + aria-label='Volume' + className='' + /> +
+
+
+ ); +};