diff --git a/apps/frontend/next.config.mjs b/apps/frontend/next.config.mjs index b3992d56..10c69307 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 @@ -24,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/package.json b/apps/frontend/package.json index 3b6a39e3..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", @@ -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", 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 }); + } +} 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/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/browse/components/SongCard.tsx b/apps/frontend/src/modules/browse/components/SongCard.tsx index a47c41c0..460dbc2b 100644 --- a/apps/frontend/src/modules/browse/components/SongCard.tsx +++ b/apps/frontend/src/modules/browse/components/SongCard.tsx @@ -1,12 +1,22 @@ 'use client'; -import { faPlay } from '@fortawesome/free-solid-svg-icons'; +import { + faCircleNotch, + 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 +33,7 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { ) : ( <> -
+
{formatDuration(song.duration)} @@ -67,17 +77,65 @@ 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 showLoading = isCurrent && isLoading; + + const handlePlay = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void playSong(previewToPlayingSong(song)); + }; + + return ( +
+ + +
- +
- +
); }; 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/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/shared/components/ui/slider.tsx b/apps/frontend/src/modules/shared/components/ui/slider.tsx index fd178577..f9bdf674 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/Player.tsx b/apps/frontend/src/modules/song/components/Player.tsx new file mode 100644 index 00000000..de807b1c --- /dev/null +++ b/apps/frontend/src/modules/song/components/Player.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useRef, useEffect, useCallback } from 'react'; + +import { + type PlayingSong, + useSongPlayer, +} from './client/context/SongPlayer.context'; + +const RENDER_WIDTH = 1280; +const RENDER_HEIGHT = 768; + +export const PlayerComponent = ({ + song, + children, +}: { + song: PlayingSong; + children: React.ReactNode; +}) => { + const viewportRef = useRef(null); + const containerRef = useRef(null); + const { + 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; + registerPageMount(node); + + if (node) { + requestAnimationFrame(() => { + applyScale(); + }); + } + }, + [applyScale, registerPageMount], + ); + + useEffect(() => { + 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; + + applyScale(); + + const observer = new ResizeObserver(() => { + applyScale(); + }); + + observer.observe(viewportRef.current); + + return () => { + observer.disconnect(); + }; + }, [applyScale]); + + useEffect(() => { + requestAnimationFrame(() => { + applyScale(); + }); + }, [applyScale, currentSong?.publicId, isSongRoute]); + + 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/components/client/SongPlayerBottomBar.tsx b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx new file mode 100644 index 00000000..df07da53 --- /dev/null +++ b/apps/frontend/src/modules/song/components/client/SongPlayerBottomBar.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { + faChevronUp, + faCirclePause, + faCirclePlay, + faClose, + faRepeat, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +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'; + +import { useSongPlayer } from './context/SongPlayer.context'; +import { VolumeButton } from './VolumeButton'; + +const RENDER_WIDTH = 1280; +const RENDER_HEIGHT = 768; + +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, + stopPlayback, + currentSong, + isPlaying, + isSongRoute, + } = 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; + + 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 (!viewportRef.current || !containerRef.current) return; + + applyScale(); + + const observer = new ResizeObserver(() => { + applyScale(); + }); + + observer.observe(viewportRef.current); + + return () => { + observer.disconnect(); + }; + }, [applyScale, isVisible]); + + useEffect(() => { + 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 ( +
+
+ {/* Miniplayer */} +
+
+
+
+
+ + {/* Song title and artist */} + + + {/* Play/Pause button */} + + + {/* Loop button */} + + +
+
+ + {currentSong?.title ?? ''} + + + {currentSong ? currentSong.uploader.username : ''} + +
+ + {/* 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 */} + + + {/* Expand button (open song page) */} + + + {/* Stop button (close player) */} + +
+
+ ); +} diff --git a/apps/frontend/src/modules/song/components/client/VolumeButton.tsx b/apps/frontend/src/modules/song/components/client/VolumeButton.tsx new file mode 100644 index 00000000..fedc52d9 --- /dev/null +++ b/apps/frontend/src/modules/song/components/client/VolumeButton.tsx @@ -0,0 +1,74 @@ +import { + faVolume, + faVolumeOff, + faVolumeLow, + faVolumeXmark, + faVolumeHigh, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import { Slider } from '@web/modules/shared/components/ui/slider'; + +import { useSongPlayer } from './context/SongPlayer.context'; + +/** +A button that allows the user to adjust the volume of the currently playing song. +Behavior: +- Hovering over the button will show a popup slider to adjust the volume. +- Clicking the button while not muted will enable the mute state, also triggerable by dragging the slider to 0. +- Clicking the button while muted will restore the volume to the last non-muted volume (either +the slider's value when the button was clicked, or the value before it was dragged to 0). +*/ +export const VolumeButton = () => { + const { volume, setVolume, toggleMute } = useSongPlayer(); + + 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 ( +
+ +
+
+ { + // 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='' + /> +
+
+
+ ); +}; 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..daf4885d --- /dev/null +++ b/apps/frontend/src/modules/song/components/client/context/SongPlayer.context.tsx @@ -0,0 +1,463 @@ +'use client'; + +import { PianoRollView, Player, Viewer } from '@opennbs/nbsvis'; +import { usePathname } from 'next/navigation'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import toast from 'react-hot-toast'; + +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; + +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 }, +}); + +export type SeekEvent = { + tick: number; + totalLength: number; +}; + +type SongPlayerContextType = { + 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; + 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; + currentSong: PlayingSong | null; + isPlaying: boolean; + isSongRoute: boolean; +}; + +const SongPlayerContext = createContext(null); + +export const SongPlayerProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + 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 [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; + + return pathname === `/song/${currentSong.publicId}`; + }, [currentSong, pathname]); + + 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 getPreferredMount = useCallback( + (songPublicId?: string | null) => { + const id = songPublicId ?? currentSongPublicIdRef.current; + const onPlayingSongPage = !!id && pathname === `/song/${id}`; + + 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(); + }, + [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 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; + 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 = getPreferredMount(publicId); + 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; + player.on('ended', () => setPlayingState(false)); + player.on('stop', () => setPlayingState(false)); + player.on('seek', (event) => { + seekListenersRef.current.forEach((handler) => handler(event)); + }); + } 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; + applyPlayerSettings(playerRef.current); + didLoad = true; + } catch (err) { + if (!isCancelledRef.current) { + setError(err instanceof Error ? err.message : 'Failed to load song'); + toast.error( + "Hm, we couldn't load this song. Please try again later!", + ); + console.error(err); + } + } 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); + } + } + }, + [ + applyPlayerSettings, + getPreferredMount, + setActiveSong, + setPlayingState, + syncViewerMount, + ], + ); + + const playSong = useCallback( + async (song: PlayingSong) => { + const publicId = song.publicId; + + if (loadingSongPublicIdRef.current === publicId) { + setActiveSong(song); + return; + } + + if (loadedSongPublicIdRef.current === publicId && playerRef.current) { + setActiveSong(song); + syncViewerMount(); + togglePlayback(); + return; + } + + await loadSong(song, { autoPlay: true }); + }, + [loadSong, setActiveSong, syncViewerMount, togglePlayback], + ); + + useEffect(() => { + isCancelledRef.current = false; + + return () => { + isCancelledRef.current = true; + }; + }, []); + + useEffect(() => { + syncViewerMount(); + }, [syncViewerMount]); + + const value = useMemo( + () => ({ + registerPageMount, + registerDockMount, + loadSong, + playSong, + play, + pause, + togglePlayback, + seek, + subscribeToSeek, + isLooping, + toggleLoop, + volume, + setVolume, + stopPlayback, + isLoading, + loadingPhase, + error, + currentSong, + isPlaying, + isSongRoute, + }), + [ + currentSong, + error, + isLoading, + isLooping, + isPlaying, + isSongRoute, + loadSong, + loadingPhase, + pause, + play, + playSong, + registerDockMount, + registerPageMount, + seek, + setVolume, + stopPlayback, + subscribeToSeek, + toggleLoop, + togglePlayback, + volume, + ], + ); + + return ( + + {children} + + ); +}; + +export const useSongPlayer = () => { + const context = useContext(SongPlayerContext); + + if (!context) { + throw new Error('useSongPlayer must be used within SongPlayerProvider'); + } + + return context; +}; 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; + } +}; 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=="],