svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount }
diff --git a/packages/client/src/components/ui/badge.tsx b/packages/client/src/components/ui/badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c5b7229aca943829de9686a574ddcae853ac4622
--- /dev/null
+++ b/packages/client/src/components/ui/badge.tsx
@@ -0,0 +1,39 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { Slot } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+ destructive: 'bg-destructive/60 text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20',
+ outline: 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 [a&]:hover:underline',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant = 'default',
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> & VariantProps
& { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : 'span'
+
+ return (
+
+ )
+}
+
+export { Badge }
diff --git a/packages/client/src/components/ui/button.tsx b/packages/client/src/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b9d1ba6676a2407ed3f159f53e815b862b2cd212
--- /dev/null
+++ b/packages/client/src/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { Slot } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive: 'bg-destructive/60 text-white hover:bg-destructive/90 focus-visible:ring-destructive/20',
+ outline: 'border border-input bg-input/30 shadow-xs hover:bg-input/50 hover:text-accent-foreground',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent/50 hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant = 'default',
+ size = 'default',
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : 'button'
+
+ return (
+
+ )
+}
+
+export { Button }
diff --git a/packages/client/src/components/ui/card.tsx b/packages/client/src/components/ui/card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bde786c190cfdef5232bbf1a7e751565ec36765f
--- /dev/null
+++ b/packages/client/src/components/ui/card.tsx
@@ -0,0 +1,54 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function Card({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
diff --git a/packages/client/src/components/ui/dialog.tsx b/packages/client/src/components/ui/dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a5648ef51e3a5ab68e4679e221266892e539860d
--- /dev/null
+++ b/packages/client/src/components/ui/dialog.tsx
@@ -0,0 +1,137 @@
+import * as React from 'react'
+import { XIcon } from 'lucide-react'
+import { Dialog as DialogPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+
+function Dialog({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DialogClose({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<'div'> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/packages/client/src/components/ui/drawer.tsx b/packages/client/src/components/ui/drawer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a18224b6cc7dc6ee15efe3945ae89f70a92c4144
--- /dev/null
+++ b/packages/client/src/components/ui/drawer.tsx
@@ -0,0 +1,107 @@
+import * as React from 'react'
+import { Drawer as DrawerPrimitive } from 'vaul'
+
+import { cn } from '@/lib/utils'
+
+function Drawer({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DrawerTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DrawerPortal({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DrawerClose({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DrawerOverlay({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DrawerContent({ className, children, ...props }: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+ )
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function DrawerTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DrawerDescription({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+}
diff --git a/packages/client/src/components/ui/dropdown-menu.tsx b/packages/client/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..896fcf36ca2bdd5f93a497f215d59ed97bcd6ab2
--- /dev/null
+++ b/packages/client/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,219 @@
+'use client'
+
+import * as React from 'react'
+import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
+import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function DropdownMenu({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = 'default',
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: 'default' | 'destructive'
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/packages/client/src/components/ui/input.tsx b/packages/client/src/components/ui/input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3128ff62675415c95ddf00f76a44fc7fcb0a85ca
--- /dev/null
+++ b/packages/client/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/packages/client/src/components/ui/label.tsx b/packages/client/src/components/ui/label.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8cf9868f852aad5c64a1b76c31b291bc3da4acaa
--- /dev/null
+++ b/packages/client/src/components/ui/label.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react'
+import { Label as LabelPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Label({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/packages/client/src/components/ui/marquee-text.tsx b/packages/client/src/components/ui/marquee-text.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b6af2f2c72f2fe88c10b38822133e6d4e286257a
--- /dev/null
+++ b/packages/client/src/components/ui/marquee-text.tsx
@@ -0,0 +1,58 @@
+import { cn } from '@/lib/utils'
+import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
+
+interface MarqueeTextProps {
+ children: ReactNode
+ className?: string
+}
+
+/**
+ * Text component that automatically scrolls horizontally when content overflows.
+ * Uses CSS `translateX` animation (GPU-accelerated) with pause-scroll-pause pattern.
+ * Falls back to static display when text fits within the container.
+ */
+export function MarqueeText({ children, className }: MarqueeTextProps) {
+ const containerRef = useRef(null)
+ const textRef = useRef(null)
+ const [overflow, setOverflow] = useState(0)
+
+ useLayoutEffect(() => {
+ const container = containerRef.current
+ const text = textRef.current
+ if (!container || !text) return
+
+ const check = () => {
+ const diff = text.scrollWidth - container.clientWidth
+ setOverflow(diff > 1 ? diff : 0)
+ }
+
+ check()
+
+ const ro = new ResizeObserver(check)
+ ro.observe(container)
+ ro.observe(text)
+
+ return () => ro.disconnect()
+ }, [children])
+
+ const duration = overflow > 0 ? Math.max(5, 4 + overflow / 30) : 0
+
+ return (
+
+ 0 && 'animate-marquee will-change-transform')}
+ style={
+ overflow > 0
+ ? ({
+ '--marquee-distance': `-${overflow + 8}px`,
+ animationDuration: `${duration}s`,
+ } as React.CSSProperties)
+ : undefined
+ }
+ >
+ {children}
+
+
+ )
+}
diff --git a/packages/client/src/components/ui/numeric-input.tsx b/packages/client/src/components/ui/numeric-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e9032067f8afbc1ac5ff29412b85003243af4400
--- /dev/null
+++ b/packages/client/src/components/ui/numeric-input.tsx
@@ -0,0 +1,49 @@
+import { Input } from '@/components/ui/input'
+import { useEffect, useState } from 'react'
+
+/**
+ * 数值输入组件:使用本地 state 让用户自由输入,
+ * 仅在失焦或按回车时校验范围并提交。
+ */
+export function NumericInput({
+ value,
+ onChange,
+ min = 10,
+ max = 200,
+}: {
+ value: number
+ onChange: (v: number) => void
+ min?: number
+ max?: number
+}) {
+ const [local, setLocal] = useState(String(value))
+
+ // 外部值变化时同步(例如重置设置)
+ useEffect(() => {
+ setLocal(String(value))
+ }, [value])
+
+ const commit = () => {
+ const v = parseInt(local, 10)
+ if (Number.isFinite(v)) {
+ const clamped = Math.max(min, Math.min(max, v))
+ onChange(clamped)
+ setLocal(String(clamped))
+ } else {
+ setLocal(String(value)) // 无效输入回退
+ }
+ }
+
+ return (
+ setLocal(e.target.value)}
+ onBlur={commit}
+ onKeyDown={(e) => { if (e.key === 'Enter') commit() }}
+ className="w-20 text-center"
+ />
+ )
+}
diff --git a/packages/client/src/components/ui/popover.tsx b/packages/client/src/components/ui/popover.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fb26dcdd734ed91ad2d37cf4b57fe52f7dc11d9c
--- /dev/null
+++ b/packages/client/src/components/ui/popover.tsx
@@ -0,0 +1,52 @@
+import * as React from 'react'
+import { Popover as PopoverPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Popover({ ...props }: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = 'center',
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function PopoverAnchor({ ...props }: React.ComponentProps) {
+ return
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
+ return
+}
+
+function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
+ return
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverHeader, PopoverTitle, PopoverDescription }
diff --git a/packages/client/src/components/ui/resize-handle.tsx b/packages/client/src/components/ui/resize-handle.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..90008bff91c621e15f0a289344496bcf493a8f5d
--- /dev/null
+++ b/packages/client/src/components/ui/resize-handle.tsx
@@ -0,0 +1,99 @@
+import { useCallback, useEffect, useRef } from 'react'
+import { GripVertical } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+interface ResizeHandleProps {
+ /** Called continuously during drag with the horizontal pixel delta (negative = moving left) */
+ onResize: (delta: number) => void
+ /** Called once when drag ends, for snap logic */
+ onResizeEnd?: () => void
+ /** Whether the adjacent panel is collapsed (width === 0) */
+ collapsed?: boolean
+ /** Double-click restores default width */
+ onDoubleClick?: () => void
+}
+
+export function ResizeHandle({ onResize, onResizeEnd, collapsed = false, onDoubleClick }: ResizeHandleProps) {
+ const isDragging = useRef(false)
+ const lastX = useRef(0)
+ const moveHandlerRef = useRef<((e: PointerEvent) => void) | null>(null)
+ const upHandlerRef = useRef<(() => void) | null>(null)
+
+ // Cleanup document listeners on unmount (safety net for mid-drag unmount)
+ useEffect(() => {
+ return () => {
+ if (moveHandlerRef.current) document.removeEventListener('pointermove', moveHandlerRef.current)
+ if (upHandlerRef.current) document.removeEventListener('pointerup', upHandlerRef.current)
+ if (isDragging.current) {
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ isDragging.current = false
+ }
+ }
+ }, [])
+
+ const handlePointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ e.preventDefault()
+ isDragging.current = true
+ lastX.current = e.clientX
+
+ // Lock cursor and disable text selection during drag
+ document.body.style.cursor = 'col-resize'
+ document.body.style.userSelect = 'none'
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ if (!isDragging.current) return
+ const delta = moveEvent.clientX - lastX.current
+ lastX.current = moveEvent.clientX
+ onResize(delta)
+ }
+
+ const handlePointerUp = () => {
+ isDragging.current = false
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ document.removeEventListener('pointermove', handlePointerMove)
+ document.removeEventListener('pointerup', handlePointerUp)
+ moveHandlerRef.current = null
+ upHandlerRef.current = null
+ onResizeEnd?.()
+ }
+
+ moveHandlerRef.current = handlePointerMove
+ upHandlerRef.current = handlePointerUp
+ document.addEventListener('pointermove', handlePointerMove)
+ document.addEventListener('pointerup', handlePointerUp)
+ },
+ [onResize, onResizeEnd],
+ )
+
+ const handleDoubleClick = useCallback(() => {
+ onDoubleClick?.()
+ }, [onDoubleClick])
+
+ return (
+
+ {/* Visual grip indicator on hover */}
+
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/packages/client/src/components/ui/responsive-dialog.tsx b/packages/client/src/components/ui/responsive-dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..57069b460ef65cbc82eacc3f27d0f2e80b5a9cc4
--- /dev/null
+++ b/packages/client/src/components/ui/responsive-dialog.tsx
@@ -0,0 +1,174 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+import { useIsMobile } from '@/hooks/useIsMobile'
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from '@/components/ui/drawer'
+
+// ---------------------------------------------------------------------------
+// Context — shares isMobile state with sub-components
+// ---------------------------------------------------------------------------
+
+const ResponsiveDialogContext = React.createContext(false)
+
+function useIsResponsiveMobile() {
+ return React.useContext(ResponsiveDialogContext)
+}
+
+// ---------------------------------------------------------------------------
+// Root
+// ---------------------------------------------------------------------------
+
+function ResponsiveDialog({
+ open,
+ onOpenChange,
+ children,
+ mobileRepositionInputs,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ children: React.ReactNode
+ mobileRepositionInputs?: boolean
+}) {
+ const isMobile = useIsMobile()
+
+ return (
+
+ {isMobile ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Content
+// ---------------------------------------------------------------------------
+
+function ResponsiveDialogContent({
+ className,
+ children,
+ showCloseButton,
+ ...props
+}: React.ComponentProps) {
+ const isMobile = useIsResponsiveMobile()
+
+ if (isMobile) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Header
+// ---------------------------------------------------------------------------
+
+function ResponsiveDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ const isMobile = useIsResponsiveMobile()
+ return isMobile ? (
+
+ ) : (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Title
+// ---------------------------------------------------------------------------
+
+function ResponsiveDialogTitle({ className, ...props }: React.ComponentProps) {
+ const isMobile = useIsResponsiveMobile()
+ return isMobile ? :
+}
+
+// ---------------------------------------------------------------------------
+// Description
+// ---------------------------------------------------------------------------
+
+function ResponsiveDialogDescription({ className, ...props }: React.ComponentProps) {
+ const isMobile = useIsResponsiveMobile()
+ return isMobile ? (
+
+ ) : (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Footer
+// ---------------------------------------------------------------------------
+
+function ResponsiveDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ const isMobile = useIsResponsiveMobile()
+ return isMobile ? (
+
+ ) : (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Close
+// ---------------------------------------------------------------------------
+
+function ResponsiveDialogClose(props: React.ComponentProps) {
+ const isMobile = useIsResponsiveMobile()
+ return isMobile ? :
+}
+
+// ---------------------------------------------------------------------------
+// Body — provides horizontal padding in Drawer mode so body content aligns
+// with DrawerHeader (p-4). In Dialog mode it's a transparent wrapper since
+// DialogContent already supplies p-6.
+// ---------------------------------------------------------------------------
+
+function ResponsiveDialogBody({ className, ...props }: React.ComponentProps<'div'>) {
+ const isMobile = useIsResponsiveMobile()
+ return
+}
+
+// ---------------------------------------------------------------------------
+// Exports
+// ---------------------------------------------------------------------------
+
+export {
+ ResponsiveDialog,
+ ResponsiveDialogBody,
+ ResponsiveDialogClose,
+ ResponsiveDialogContent,
+ ResponsiveDialogDescription,
+ ResponsiveDialogFooter,
+ ResponsiveDialogHeader,
+ ResponsiveDialogTitle,
+}
diff --git a/packages/client/src/components/ui/scroll-area.tsx b/packages/client/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bcb47659f683a58228dc7d14012ea2ea12da7b58
--- /dev/null
+++ b/packages/client/src/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+import * as React from 'react'
+import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function ScrollArea({ className, children, ...props }: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/packages/client/src/components/ui/select.tsx b/packages/client/src/components/ui/select.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..468f48ec7f3dba44baa809dc50001e0f8c783e6c
--- /dev/null
+++ b/packages/client/src/components/ui/select.tsx
@@ -0,0 +1,162 @@
+'use client'
+
+import * as React from 'react'
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
+import { Select as SelectPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Select({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SelectValue({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = 'default',
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: 'sm' | 'default'
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = 'item-aligned',
+ align = 'center',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({ className, children, ...props }: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/packages/client/src/components/ui/separator.tsx b/packages/client/src/components/ui/separator.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..da235186e294784ed7047d65c797161e00c1653f
--- /dev/null
+++ b/packages/client/src/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+'use client'
+
+import * as React from 'react'
+import { Separator as SeparatorPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Separator({
+ className,
+ orientation = 'horizontal',
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/packages/client/src/components/ui/sheet.tsx b/packages/client/src/components/ui/sheet.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bbf02e7f9da2ef8c92a12c8044421e275df63100
--- /dev/null
+++ b/packages/client/src/components/ui/sheet.tsx
@@ -0,0 +1,107 @@
+'use client'
+
+import * as React from 'react'
+import { XIcon } from 'lucide-react'
+import { Dialog as SheetPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetClose({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetPortal({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = 'right',
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ side?: 'top' | 'right' | 'bottom' | 'left'
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function SheetTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetDescription({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }
diff --git a/packages/client/src/components/ui/skeleton.tsx b/packages/client/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7ae30e8c00482ed4535d131896014bca8ecad497
--- /dev/null
+++ b/packages/client/src/components/ui/skeleton.tsx
@@ -0,0 +1,7 @@
+import { cn } from '@/lib/utils'
+
+function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+export { Skeleton }
diff --git a/packages/client/src/components/ui/slider.tsx b/packages/client/src/components/ui/slider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e265b5126527c6de43ba010097bd4f976ebfab8c
--- /dev/null
+++ b/packages/client/src/components/ui/slider.tsx
@@ -0,0 +1,54 @@
+import * as React from 'react'
+import { Slider as SliderPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Slider({
+ className,
+ defaultValue,
+ value,
+ min = 0,
+ max = 100,
+ ...props
+}: React.ComponentProps) {
+ const _values = React.useMemo(
+ () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
+ [value, defaultValue, min, max],
+ )
+
+ return (
+
+
+
+
+ {Array.from({ length: _values.length }, (_, index) => (
+
+ ))}
+
+ )
+}
+
+export { Slider }
diff --git a/packages/client/src/components/ui/switch.tsx b/packages/client/src/components/ui/switch.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1df880ceecc7e3f52578ba96a0f87969ce9724ec
--- /dev/null
+++ b/packages/client/src/components/ui/switch.tsx
@@ -0,0 +1,33 @@
+import * as React from 'react'
+import { Switch as SwitchPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Switch({
+ className,
+ size = 'default',
+ ...props
+}: React.ComponentProps & {
+ size?: 'sm' | 'default'
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/packages/client/src/components/ui/tabs.tsx b/packages/client/src/components/ui/tabs.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b0c9c13dd96e56f31fa6e578bb83b584af5eedc5
--- /dev/null
+++ b/packages/client/src/components/ui/tabs.tsx
@@ -0,0 +1,69 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { Tabs as TabsPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+
+function Tabs({ className, orientation = 'horizontal', ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ 'rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col',
+ {
+ variants: {
+ variant: {
+ default: 'bg-muted',
+ line: 'gap-1 bg-transparent',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function TabsList({
+ className,
+ variant = 'default',
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: React.ComponentProps) {
+ return
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/packages/client/src/components/ui/toggle-group.tsx b/packages/client/src/components/ui/toggle-group.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9f7ef69428d32ecb4436fb1cf4848ca165abe14b
--- /dev/null
+++ b/packages/client/src/components/ui/toggle-group.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import * as React from 'react'
+import { type VariantProps } from 'class-variance-authority'
+import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+import { toggleVariants } from '@/components/ui/toggle-variants'
+
+const ToggleGroupContext = React.createContext<
+ VariantProps & {
+ spacing?: number
+ }
+>({
+ size: 'default',
+ variant: 'default',
+ spacing: 0,
+})
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ spacing = 0,
+ children,
+ ...props
+}: React.ComponentProps &
+ VariantProps & {
+ spacing?: number
+ }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps & VariantProps) {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/packages/client/src/components/ui/toggle-variants.ts b/packages/client/src/components/ui/toggle-variants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..38b87ce6bae596c8025d01821475a45899dfdae3
--- /dev/null
+++ b/packages/client/src/components/ui/toggle-variants.ts
@@ -0,0 +1,22 @@
+import { cva } from 'class-variance-authority'
+
+export const toggleVariants = cva(
+ 'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4',
+ {
+ variants: {
+ variant: {
+ default: 'bg-transparent',
+ outline: 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
+ },
+ size: {
+ default: 'h-9 min-w-9 px-2',
+ sm: 'h-8 min-w-8 px-1.5',
+ lg: 'h-10 min-w-10 px-2.5',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
diff --git a/packages/client/src/components/ui/toggle.tsx b/packages/client/src/components/ui/toggle.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7d2b81b81741c72ec2c4c7fef35f04f2721b4f4c
--- /dev/null
+++ b/packages/client/src/components/ui/toggle.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react'
+import { type VariantProps } from 'class-variance-authority'
+import { Toggle as TogglePrimitive } from 'radix-ui'
+
+import { cn } from '@/lib/utils'
+import { toggleVariants } from '@/components/ui/toggle-variants'
+
+function Toggle({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps & VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle }
diff --git a/packages/client/src/components/ui/tooltip.tsx b/packages/client/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b1bfd4854a5c2176ad63215312a03b706eeaad08
--- /dev/null
+++ b/packages/client/src/components/ui/tooltip.tsx
@@ -0,0 +1,44 @@
+'use client'
+
+import { Tooltip as TooltipPrimitive } from 'radix-ui'
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) {
+ return
+}
+
+function Tooltip({ ...props }: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({ ...props }: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
diff --git a/packages/client/src/env.d.ts b/packages/client/src/env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e042dcc51e6f8addc13ce9a4038d0b3107cdb405
--- /dev/null
+++ b/packages/client/src/env.d.ts
@@ -0,0 +1 @@
+declare const __APP_VERSION__: string
diff --git a/packages/client/src/hooks/room/useAuthSync.ts b/packages/client/src/hooks/room/useAuthSync.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1809e37fc08a0b6a0dfe783056e7832bfbb81284
--- /dev/null
+++ b/packages/client/src/hooks/room/useAuthSync.ts
@@ -0,0 +1,99 @@
+import { useSocketContext } from '@/providers/socket-context'
+import { useRoomStore } from '@/stores/roomStore'
+import { storage } from '@/lib/storage'
+import { EVENTS } from '@music-together/shared'
+import type { MusicSource } from '@music-together/shared'
+import { useEffect, useRef } from 'react'
+import { toast } from 'sonner'
+
+const PLATFORM_NAMES: Record = {
+ netease: '网易云音乐',
+ tencent: 'QQ 音乐',
+ kugou: '酷狗音乐',
+ kugou_concept: '酷狗概念版',
+ bilibili: 'B站',
+}
+
+/**
+ * Hook:认证结果处理(always-mounted 级别)
+ *
+ * 统一处理 AUTH_SET_COOKIE_RESULT 事件:
+ * 1. localStorage 持久化(成功时保存 cookie)
+ * 2. Toast 通知(区分用户操作 vs 自动恢复)
+ *
+ * 设计决策:
+ * - Cookie 只通过 useAuth.logout() 删除,本 hook 永不删除
+ * - 自动恢复(auto-resend)成功时静默(避免进房间时 N 条 toast)
+ * - 自动恢复失败时提示一次(用 toast id 去重)
+ */
+export function useAuthSync() {
+ const { socket } = useSocketContext()
+
+ // 跟踪待处理的自动恢复请求数,用于静默成功 toast
+ const pendingAutoResendRef = useRef(0)
+
+ useEffect(() => {
+ const onAuthCookieResult = (data: {
+ success: boolean
+ message: string
+ platform?: MusicSource
+ cookie?: string
+ reason?: 'expired' | 'error' | 'reauth_required'
+ }) => {
+ if (data.success) {
+ // 持久化 cookie
+ if (data.platform && data.cookie) {
+ storage.upsertAuthCookie(data.platform, data.cookie)
+ }
+
+ // 自动恢复成功时静默,手动操作时弹 toast
+ if (pendingAutoResendRef.current > 0) {
+ pendingAutoResendRef.current--
+ } else {
+ toast.success(data.message)
+ }
+ } else if (data.platform) {
+ const name = PLATFORM_NAMES[data.platform] ?? data.platform
+
+ // 自动恢复失败时消费计数
+ if (pendingAutoResendRef.current > 0) {
+ pendingAutoResendRef.current--
+ }
+
+ if (data.reason === 'reauth_required') {
+ storage.removeAuthCookie(data.platform)
+ toast.warning(data.message, { id: `auth-reauth-${data.platform}` })
+ } else if (data.reason === 'expired') {
+ toast.warning(`${name} 登录验证失败,将在下次进入房间时重试`, { id: `auth-expired-${data.platform}` })
+ } else if (data.reason === 'error') {
+ toast.info(`${name} 登录验证失败,将在下次进入房间时重试`, { id: `auth-error-${data.platform}` })
+ } else {
+ // 手动操作失败
+ toast.error(data.message)
+ }
+ // Cookie 永不删除 — 只有 useAuth.logout() 有权删除
+ }
+ }
+
+ // 当收到 ROOM_STATE 时,useRoomState 会自动恢复 cookie
+ // 此处记录即将到来的恢复结果数量,以便静默成功 toast
+ const onRoomState = () => {
+ const stored = storage.getAuthCookies()
+ pendingAutoResendRef.current = stored.length
+ }
+
+ socket.on(EVENTS.AUTH_SET_COOKIE_RESULT, onAuthCookieResult)
+ socket.on(EVENTS.ROOM_STATE, onRoomState)
+
+ // 如果组件挂载时已在房间中(如路由切换),也要初始化计数
+ if (useRoomStore.getState().room) {
+ const stored = storage.getAuthCookies()
+ pendingAutoResendRef.current = stored.length
+ }
+
+ return () => {
+ socket.off(EVENTS.AUTH_SET_COOKIE_RESULT, onAuthCookieResult)
+ socket.off(EVENTS.ROOM_STATE, onRoomState)
+ }
+ }, [socket])
+}
diff --git a/packages/client/src/hooks/room/useChatSync.ts b/packages/client/src/hooks/room/useChatSync.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2062fbb45c34969d72de546b49422aee7877b5f9
--- /dev/null
+++ b/packages/client/src/hooks/room/useChatSync.ts
@@ -0,0 +1,28 @@
+import { useSocketContext } from '@/providers/socket-context'
+import { useChatStore } from '@/stores/chatStore'
+import { EVENTS } from '@music-together/shared'
+import type { ChatMessage } from '@music-together/shared'
+import { useEffect } from 'react'
+
+/** Syncs chat history and incoming messages from the server to the chat store. */
+export function useChatSync() {
+ const { socket } = useSocketContext()
+
+ useEffect(() => {
+ const onChatHistory = (messages: ChatMessage[]) => {
+ useChatStore.getState().setMessages(messages)
+ }
+
+ const onChatMessage = (message: ChatMessage) => {
+ useChatStore.getState().addMessage(message)
+ }
+
+ socket.on(EVENTS.CHAT_HISTORY, onChatHistory)
+ socket.on(EVENTS.CHAT_MESSAGE, onChatMessage)
+
+ return () => {
+ socket.off(EVENTS.CHAT_HISTORY, onChatHistory)
+ socket.off(EVENTS.CHAT_MESSAGE, onChatMessage)
+ }
+ }, [socket])
+}
diff --git a/packages/client/src/hooks/room/useConnectionGuard.ts b/packages/client/src/hooks/room/useConnectionGuard.ts
new file mode 100644
index 0000000000000000000000000000000000000000..09685f2d6ea2dc040fa01c5f78e708a15650b0d3
--- /dev/null
+++ b/packages/client/src/hooks/room/useConnectionGuard.ts
@@ -0,0 +1,23 @@
+import { useSocketContext } from '@/providers/socket-context'
+import { resetAllRoomState } from '@/lib/resetStores'
+import { useEffect } from 'react'
+
+/**
+ * Resets all room-related stores on socket disconnect.
+ * Re-join on reconnect is handled by RoomPage's auto-join effect.
+ */
+export function useConnectionGuard() {
+ const { socket } = useSocketContext()
+
+ useEffect(() => {
+ const onDisconnect = () => {
+ resetAllRoomState()
+ }
+
+ socket.on('disconnect', onDisconnect)
+
+ return () => {
+ socket.off('disconnect', onDisconnect)
+ }
+ }, [socket])
+}
diff --git a/packages/client/src/hooks/room/useQueueSync.ts b/packages/client/src/hooks/room/useQueueSync.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cf01a1e04e5b423ebffc562db83205c1603760e0
--- /dev/null
+++ b/packages/client/src/hooks/room/useQueueSync.ts
@@ -0,0 +1,25 @@
+import { useSocketContext } from '@/providers/socket-context'
+import { useRoomStore } from '@/stores/roomStore'
+import { EVENTS } from '@music-together/shared'
+import type { Track } from '@music-together/shared'
+import { useEffect } from 'react'
+
+/** Keeps the local queue in sync with server-side QUEUE_UPDATED events. */
+export function useQueueSync() {
+ const { socket } = useSocketContext()
+
+ useEffect(() => {
+ const onQueueUpdated = (data: { queue: Track[] }) => {
+ const room = useRoomStore.getState().room
+ if (room) {
+ useRoomStore.getState().updateRoom({ queue: data.queue })
+ }
+ }
+
+ socket.on(EVENTS.QUEUE_UPDATED, onQueueUpdated)
+
+ return () => {
+ socket.off(EVENTS.QUEUE_UPDATED, onQueueUpdated)
+ }
+ }, [socket])
+}
diff --git a/packages/client/src/hooks/room/useRoomState.ts b/packages/client/src/hooks/room/useRoomState.ts
new file mode 100644
index 0000000000000000000000000000000000000000..31b9a5cb3332d92027bd5eb89b2afbfcbc5ef9b5
--- /dev/null
+++ b/packages/client/src/hooks/room/useRoomState.ts
@@ -0,0 +1,192 @@
+import { useSocketContext } from '@/providers/socket-context'
+import { useRoomStore } from '@/stores/roomStore'
+import { storage } from '@/lib/storage'
+import { ERROR_CODE, EVENTS } from '@music-together/shared'
+import type {
+ AudioQuality,
+ MusicSource,
+ RoomAutoFallbackEvent,
+ RoomState,
+ User,
+ UserRole,
+} from '@music-together/shared'
+import { useEffect, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { toast } from 'sonner'
+import { fetchCurrentProfile } from '@/lib/profileApi'
+
+/**
+ * Handles core room lifecycle events:
+ * ROOM_STATE, ROOM_USER_JOINED/LEFT, ROOM_SETTINGS, ROOM_ROLE_CHANGED, ROOM_ERROR.
+ *
+ * Also auto-resends persisted auth cookies on ROOM_STATE (join/reconnect).
+ *
+ * NOTE: `currentUser` is auto-derived inside `roomStore` whenever `room`
+ * changes (setRoom / addUser / removeUser / updateRoom).
+ */
+export function useRoomState() {
+ const navigate = useNavigate()
+ const { socket } = useSocketContext()
+
+ // Guard against React Strict Mode double-mount sending cookies twice.
+ // Persists across cleanup/re-setup so the second mount is a no-op.
+ const mountResendDone = useRef(false)
+
+ useEffect(() => {
+ const resendCookies = () => {
+ const storedCookies = storage.getAuthCookies()
+ for (const { platform, cookie } of storedCookies) {
+ socket.emit(EVENTS.AUTH_SET_COOKIE, { platform, cookie })
+ }
+ }
+
+ const onRoomState = (roomState: RoomState) => {
+ // setRoom automatically derives currentUser from room.users
+ useRoomStore.getState().setRoom(roomState)
+ // 仅 owner 专用 ROOM_STATE 携带密码明文;公开 ROOM_STATE 不应清空本地密码缓存。
+ if ('password' in roomState) {
+ useRoomStore.getState().setRoomPassword(roomState.password ?? null)
+ }
+ void fetchCurrentProfile().catch(() => null)
+
+ // Auto-resend persisted auth cookies so the room's cookie pool is populated
+ resendCookies()
+ }
+
+ const onUserJoined = (user: User) => {
+ useRoomStore.getState().addUser(user)
+ }
+
+ const onRejoinToken = (data: { roomId: string; token: string; expiresAt: number }) => {
+ storage.setRejoinToken(data.roomId, data.token, data.expiresAt)
+ }
+
+ const onUserLeft = (user: User) => {
+ useRoomStore.getState().removeUser(user.id)
+ }
+
+ const onSettings = (settings: {
+ name: string
+ hasPassword: boolean
+ hidden: boolean
+ permanent: boolean
+ allowTemporaryAdminTrackRemoval: boolean
+ allowTemporaryAdminQueueClear: boolean
+ password?: string | null
+ audioQuality: AudioQuality
+ }) => {
+ useRoomStore.getState().updateRoom(settings)
+ // 存储密码明文(服务端广播)
+ if ('password' in settings) {
+ useRoomStore.getState().setRoomPassword(settings.password ?? null)
+ }
+ }
+
+ const onRoleChanged = (data: { userId: string; role: UserRole }) => {
+ const store = useRoomStore.getState()
+ const room = store.room
+ if (!room) return
+ const updatedUsers = room.users.map((u) => (u.id === data.userId ? { ...u, role: data.role } : u))
+ const updatedMembers = room.members.map((member) =>
+ member.id === data.userId ? { ...member, role: data.role } : member,
+ )
+ // updateRoom with users automatically re-derives currentUser
+ store.updateRoom({ users: updatedUsers, members: updatedMembers })
+ }
+
+ const sourceLabel = (source: MusicSource) => {
+ switch (source) {
+ case 'netease':
+ return '网易云'
+ case 'tencent':
+ return 'QQ音乐'
+ case 'kugou':
+ return '酷狗音乐'
+ case 'kugou_concept':
+ return '酷狗概念版'
+ case 'bilibili':
+ return 'B站'
+ }
+ }
+
+ const onAutoFallback = (data: RoomAutoFallbackEvent) => {
+ const id = `auto-fallback:${data.attemptId}`
+ const from = sourceLabel(data.fromSource)
+ const to = sourceLabel(data.toSource)
+
+ if (data.status === 'trying') {
+ const reasonLabel =
+ data.reasonType === 'VIP_REQUIRED'
+ ? '无权限'
+ : data.reasonType === 'COPYRIGHT_RESTRICTED'
+ ? '版权限制'
+ : data.reasonType === 'NO_RESOURCE'
+ ? '无资源'
+ : data.reasonType === 'TIMEOUT'
+ ? '超时'
+ : '不可用'
+
+ toast.loading(`${from} ${reasonLabel},正在尝试使用 ${to} 点播…`, { id })
+ return
+ }
+
+ if (data.status === 'success') {
+ toast.success(`已切换到 ${to},点播成功:${data.trackTitle}`, { id })
+ return
+ }
+
+ // failed
+ type ReasonType = NonNullable
+ const reasonMap: Partial> = {
+ VIP_REQUIRED: 'VIP/权限',
+ COPYRIGHT_RESTRICTED: '版权限制',
+ NO_RESOURCE: '无资源',
+ TIMEOUT: '超时',
+ }
+ const reasonText = data.reasonType ? (reasonMap[data.reasonType] ?? null) : null
+ toast.error(
+ reasonText ? `自动换源失败:${data.trackTitle}(${reasonText})` : `自动换源失败:${data.trackTitle}`,
+ { id },
+ )
+ }
+
+ const onError = (error: { code: string; message: string }) => {
+ // WRONG_PASSWORD is handled by RoomPage's own UI (gate password field),
+ // so skip the generic toast to avoid duplicate feedback.
+ if (error.code === ERROR_CODE.WRONG_PASSWORD) return
+
+ toast.error(error.message)
+ if (error.code === ERROR_CODE.ROOM_NOT_FOUND) {
+ navigate('/', { replace: true })
+ }
+ }
+
+ socket.on(EVENTS.ROOM_STATE, onRoomState)
+ socket.on(EVENTS.ROOM_REJOIN_TOKEN, onRejoinToken)
+ socket.on(EVENTS.ROOM_USER_JOINED, onUserJoined)
+ socket.on(EVENTS.ROOM_USER_LEFT, onUserLeft)
+ socket.on(EVENTS.ROOM_SETTINGS, onSettings)
+ socket.on(EVENTS.ROOM_ROLE_CHANGED, onRoleChanged)
+ socket.on(EVENTS.ROOM_AUTO_FALLBACK, onAutoFallback)
+ socket.on(EVENTS.ROOM_ERROR, onError)
+
+ // If room was already set before this hook mounted (e.g. HomePage consumed
+ // ROOM_STATE and navigated here), resend cookies now. The ref guard prevents
+ // React Strict Mode's double-mount from sending twice.
+ if (!mountResendDone.current && useRoomStore.getState().room) {
+ mountResendDone.current = true
+ resendCookies()
+ }
+
+ return () => {
+ socket.off(EVENTS.ROOM_STATE, onRoomState)
+ socket.off(EVENTS.ROOM_REJOIN_TOKEN, onRejoinToken)
+ socket.off(EVENTS.ROOM_USER_JOINED, onUserJoined)
+ socket.off(EVENTS.ROOM_USER_LEFT, onUserLeft)
+ socket.off(EVENTS.ROOM_SETTINGS, onSettings)
+ socket.off(EVENTS.ROOM_ROLE_CHANGED, onRoleChanged)
+ socket.off(EVENTS.ROOM_AUTO_FALLBACK, onAutoFallback)
+ socket.off(EVENTS.ROOM_ERROR, onError)
+ }
+ }, [socket, navigate])
+}
diff --git a/packages/client/src/hooks/useAuth.ts b/packages/client/src/hooks/useAuth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..77b03c7efde5f29e833f09731783e4059207f68d
--- /dev/null
+++ b/packages/client/src/hooks/useAuth.ts
@@ -0,0 +1,158 @@
+import { storage } from '@/lib/storage'
+import { useSocketContext } from '@/providers/socket-context'
+import type { MusicSource, MyPlatformAuth, PlatformAuthStatus } from '@music-together/shared'
+import { EVENTS, QR_STATUS } from '@music-together/shared'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+
+/**
+ * Hook:管理平台认证 UI 状态
+ *
+ * 职责:
+ * - QR 扫码登录状态(二维码数据、扫码状态、加载状态)
+ * - 平台认证状态(聚合状态、个人状态)
+ * - 用户操作方法(requestQrCode、setCookie、logout)
+ *
+ * 不负责:
+ * - Cookie 持久化(由 useAuthSync 处理)
+ * - Cookie 验证结果 toast(由 useAuthSync 处理)
+ */
+export function useAuth() {
+ const { socket } = useSocketContext()
+ const [platformStatus, setPlatformStatus] = useState([])
+ const [myStatus, setMyStatus] = useState([])
+ const [statusLoaded, setStatusLoaded] = useState(false)
+ const [qrData, setQrData] = useState<{ key: string; qrimg: string } | null>(null)
+ const [qrStatus, setQrStatus] = useState<{ status: number; message: string } | null>(null)
+ const [isQrLoading, setIsQrLoading] = useState(false)
+ const [isClaimingKugouConceptVip, setIsClaimingKugouConceptVip] = useState(false)
+ const [qrPlatform, setQrPlatform] = useState('netease')
+
+ // Ref 跟踪最新的 qrPlatform,避免重建回调导致重启轮询
+ const qrPlatformRef = useRef(qrPlatform)
+ const qrKeyRef = useRef(null)
+ const qrCompletedRef = useRef(false)
+
+ useEffect(() => {
+ const onStatusUpdate = (data: PlatformAuthStatus[]) => {
+ setPlatformStatus(data)
+ }
+
+ const onMyStatus = (data: MyPlatformAuth[]) => {
+ setMyStatus(data)
+ setStatusLoaded(true)
+ }
+
+ const onQrGenerated = (data: { key: string; qrimg: string }) => {
+ qrCompletedRef.current = false
+ qrKeyRef.current = data.key
+ setQrData(data)
+ setQrStatus({ status: 801, message: '等待扫码' })
+ setIsQrLoading(false)
+ }
+
+ const onQrStatus = (data: { status: number; message: string; key?: string }) => {
+ if (qrCompletedRef.current) return
+ if (data.key && data.key !== qrKeyRef.current) return
+ if (data.status === QR_STATUS.SUCCESS || data.status === QR_STATUS.EXPIRED) {
+ qrCompletedRef.current = true
+ qrKeyRef.current = null
+ }
+ setQrStatus(data)
+ if (data.status === QR_STATUS.SUCCESS || data.status === QR_STATUS.EXPIRED) {
+ setIsQrLoading(false)
+ }
+ }
+
+ const onKugouConceptVipClaim = (data: { success: boolean; message: string }) => {
+ setIsClaimingKugouConceptVip(false)
+ if (data.success) toast.success(data.message)
+ else toast.error(data.message)
+ }
+
+ socket.on(EVENTS.AUTH_STATUS_UPDATE, onStatusUpdate)
+ socket.on(EVENTS.AUTH_MY_STATUS, onMyStatus)
+ socket.on(EVENTS.AUTH_QR_GENERATED, onQrGenerated)
+ socket.on(EVENTS.AUTH_QR_STATUS, onQrStatus)
+ socket.on(EVENTS.AUTH_CLAIM_KUGOU_CONCEPT_VIP_RESULT, onKugouConceptVipClaim)
+
+ // 挂载时拉取当前状态(覆盖延迟挂载场景)
+ socket.emit(EVENTS.AUTH_GET_STATUS)
+
+ return () => {
+ socket.off(EVENTS.AUTH_STATUS_UPDATE, onStatusUpdate)
+ socket.off(EVENTS.AUTH_MY_STATUS, onMyStatus)
+ socket.off(EVENTS.AUTH_QR_GENERATED, onQrGenerated)
+ socket.off(EVENTS.AUTH_QR_STATUS, onQrStatus)
+ socket.off(EVENTS.AUTH_CLAIM_KUGOU_CONCEPT_VIP_RESULT, onKugouConceptVipClaim)
+ }
+ }, [socket])
+
+ const requestQrCode = useCallback(
+ (platform: MusicSource) => {
+ setQrData(null)
+ qrCompletedRef.current = false
+ qrKeyRef.current = null
+ setQrStatus(null)
+ setIsQrLoading(true)
+ setQrPlatform(platform)
+ qrPlatformRef.current = platform
+ socket.emit(EVENTS.AUTH_REQUEST_QR, { platform })
+ },
+ [socket],
+ )
+
+ const checkQrStatus = useCallback(
+ (key: string) => {
+ if (qrCompletedRef.current || qrKeyRef.current !== key) return
+ socket.emit(EVENTS.AUTH_CHECK_QR, { key, platform: qrPlatformRef.current })
+ },
+ [socket],
+ )
+
+ const setCookie = useCallback(
+ (platform: MusicSource, cookie: string) => {
+ socket.emit(EVENTS.AUTH_SET_COOKIE, { platform, cookie })
+ },
+ [socket],
+ )
+
+ const logout = useCallback(
+ (platform: MusicSource) => {
+ storage.removeAuthCookie(platform)
+ socket.emit(EVENTS.AUTH_LOGOUT, { platform })
+ },
+ [socket],
+ )
+
+ const resetQr = useCallback(() => {
+ qrCompletedRef.current = true
+ qrKeyRef.current = null
+ setQrData(null)
+ setQrStatus(null)
+ setIsQrLoading(false)
+ }, [])
+
+ const claimKugouConceptVip = useCallback(() => {
+ if (isClaimingKugouConceptVip) return
+ setIsClaimingKugouConceptVip(true)
+ socket.emit(EVENTS.AUTH_CLAIM_KUGOU_CONCEPT_VIP)
+ }, [isClaimingKugouConceptVip, socket])
+
+ return {
+ platformStatus,
+ myStatus,
+ statusLoaded,
+ qrData,
+ qrStatus,
+ qrPlatform,
+ isQrLoading,
+ requestQrCode,
+ checkQrStatus,
+ setCookie,
+ logout,
+ resetQr,
+ claimKugouConceptVip,
+ isClaimingKugouConceptVip,
+ }
+}
diff --git a/packages/client/src/hooks/useChat.ts b/packages/client/src/hooks/useChat.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b75a95338642a8ba2655984c8379bbf22ba2d51a
--- /dev/null
+++ b/packages/client/src/hooks/useChat.ts
@@ -0,0 +1,23 @@
+import { useCallback } from 'react'
+import { EVENTS, LIMITS } from '@music-together/shared'
+import { useSocketContext } from '@/providers/socket-context'
+import { toast } from 'sonner'
+
+export function useChat() {
+ const { socket } = useSocketContext()
+
+ const sendMessage = useCallback(
+ (content: string) => {
+ const trimmed = content.trim()
+ if (!trimmed) return
+ if (trimmed.length > LIMITS.CHAT_CONTENT_MAX_LENGTH) {
+ toast.error(`消息不能超过 ${LIMITS.CHAT_CONTENT_MAX_LENGTH} 个字符`)
+ return
+ }
+ socket.emit(EVENTS.CHAT_MESSAGE, { content: trimmed })
+ },
+ [socket],
+ )
+
+ return { sendMessage }
+}
diff --git a/packages/client/src/hooks/useClockSync.ts b/packages/client/src/hooks/useClockSync.ts
new file mode 100644
index 0000000000000000000000000000000000000000..da6bd7925daf35614a23cbc81c82ec23d8fafdca
--- /dev/null
+++ b/packages/client/src/hooks/useClockSync.ts
@@ -0,0 +1,92 @@
+import { useEffect, useRef } from 'react'
+import { EVENTS, NTP } from '@music-together/shared'
+import { useSocketContext } from '@/providers/socket-context'
+import { recordPing, processPong, resetClockSync, isCalibrated, getMedianRTT } from '@/lib/clockSync'
+import { useSettingsStore } from '@/stores/settingsStore'
+
+/**
+ * Runs the NTP clock-sync loop for the lifetime of the socket connection.
+ *
+ * Phase 1 (calibration): rapid pings every `NTP.INITIAL_INTERVAL_MS` until
+ * `MAX_INITIAL_SAMPLES` are collected.
+ * Phase 2 (steady state): pings at the user-configured sync packet interval
+ * to track drift.
+ */
+export function useClockSync(): void {
+ const { socket } = useSocketContext()
+ const intervalRef = useRef | null>(null)
+ const switchedRef = useRef(false)
+
+ useEffect(() => {
+ const burstTimers: ReturnType[] = []
+
+ const sendPing = () => {
+ const id = recordPing()
+ const rtt = getMedianRTT()
+ socket.emit(EVENTS.NTP_PING, { clientPingId: id, lastRttMs: rtt > 0 ? rtt : undefined })
+ }
+
+ const startSteadyHeartbeat = () => {
+ if (intervalRef.current !== null) clearInterval(intervalRef.current)
+ const intervalMs = useSettingsStore.getState().syncPacketIntervalSeconds * 1000
+ intervalRef.current = setInterval(sendPing, intervalMs)
+ }
+
+ const startFastCalibration = () => {
+ if (intervalRef.current !== null) clearInterval(intervalRef.current)
+ resetClockSync()
+ switchedRef.current = false
+ sendPing()
+ intervalRef.current = setInterval(sendPing, NTP.INITIAL_INTERVAL_MS)
+ }
+
+ const onPong = (data: { clientPingId: number; serverTime: number }) => {
+ processPong(data.clientPingId, data.serverTime)
+ if (!switchedRef.current && isCalibrated() && intervalRef.current !== null) {
+ switchedRef.current = true
+ startSteadyHeartbeat()
+ }
+ }
+
+ const unsubscribeSettings = useSettingsStore.subscribe((state, previousState) => {
+ if (state.syncPacketIntervalSeconds === previousState.syncPacketIntervalSeconds) return
+ if (switchedRef.current && intervalRef.current !== null) startSteadyHeartbeat()
+ })
+
+ const onDisconnect = () => {
+ if (intervalRef.current !== null) clearInterval(intervalRef.current)
+ intervalRef.current = null
+ resetClockSync()
+ }
+
+ const onVisibilityChange = () => {
+ if (document.visibilityState !== 'visible' || !socket.connected) return
+ // Timers and performance clocks can behave differently across sleep and
+ // mobile backgrounding. Refresh several samples without discarding the
+ // last usable anchor, so an arriving playback action can still schedule.
+ for (let i = 0; i < 5; i++) {
+ burstTimers.push(setTimeout(sendPing, i * NTP.INITIAL_INTERVAL_MS))
+ }
+ }
+
+ socket.on(EVENTS.NTP_PONG, onPong)
+ socket.on('connect', startFastCalibration)
+ socket.on('disconnect', onDisconnect)
+ document.addEventListener('visibilitychange', onVisibilityChange)
+ if (socket.connected) startFastCalibration()
+
+ return () => {
+ socket.off(EVENTS.NTP_PONG, onPong)
+ socket.off('connect', startFastCalibration)
+ socket.off('disconnect', onDisconnect)
+ document.removeEventListener('visibilitychange', onVisibilityChange)
+ unsubscribeSettings()
+ for (const timer of burstTimers) clearTimeout(timer)
+ if (intervalRef.current !== null) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ resetClockSync()
+ }
+ }, [socket])
+}
diff --git a/packages/client/src/hooks/useContainerPortrait.ts b/packages/client/src/hooks/useContainerPortrait.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f9286532f757b1d6699fed5d355ae0fa1ecb9489
--- /dev/null
+++ b/packages/client/src/hooks/useContainerPortrait.ts
@@ -0,0 +1,23 @@
+import { useCallback, useRef, useState } from 'react'
+
+export function useContainerPortrait() {
+ const [isPortrait, setIsPortrait] = useState(false)
+ const observerRef = useRef(null)
+
+ const ref = useCallback((node: HTMLDivElement | null) => {
+ observerRef.current?.disconnect()
+ observerRef.current = null
+ if (node) {
+ const rect = node.getBoundingClientRect()
+ setIsPortrait(rect.height > rect.width)
+ const ro = new ResizeObserver(([entry]) => {
+ const { width, height } = entry.contentRect
+ setIsPortrait(height > width)
+ })
+ ro.observe(node)
+ observerRef.current = ro
+ }
+ }, [])
+
+ return { ref, isPortrait }
+}
diff --git a/packages/client/src/hooks/useCoverWidth.ts b/packages/client/src/hooks/useCoverWidth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bdd1b4e5c7e2c46a661581e586f221aaaf905e67
--- /dev/null
+++ b/packages/client/src/hooks/useCoverWidth.ts
@@ -0,0 +1,45 @@
+import { useCallback, useRef, useState } from 'react'
+
+/**
+ * Measures the minimum dimension (min of width/height) of a container element.
+ * Used to constrain sibling elements (info bar, controls) to match cover width.
+ *
+ * @param paused - When true, stops updating the width (e.g. during lyric mode
+ * where the container shrinks to compact height and would produce a misleading value).
+ */
+export function useCoverWidth(paused: boolean) {
+ const [width, setWidth] = useState(0)
+ const observerRef = useRef(null)
+
+ const ref = useCallback(
+ (node: HTMLDivElement | null) => {
+ observerRef.current?.disconnect()
+ observerRef.current = null
+ if (!node) return
+
+ const update = (w: number, h: number) => {
+ if (paused) return
+ const newDimension = Math.floor(Math.min(w, h))
+
+ setWidth((prev) => {
+ // Hysteresis dead-zone: Ignore sub 3px changes to prevent ResizeObserver infinite loops
+ // when the zoom scaling below changes the actual pixel height of the controls.
+ if (Math.abs(prev - newDimension) <= 3) return prev
+ return newDimension
+ })
+ }
+
+ const rect = node.getBoundingClientRect()
+ update(rect.width, rect.height)
+
+ const ro = new ResizeObserver(([entry]) => {
+ update(entry.contentRect.width, entry.contentRect.height)
+ })
+ ro.observe(node)
+ observerRef.current = ro
+ },
+ [paused],
+ )
+
+ return { ref, coverWidth: width }
+}
diff --git a/packages/client/src/hooks/useHasHover.ts b/packages/client/src/hooks/useHasHover.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8858425f7669508ed334d28c38bb51beb4c207ad
--- /dev/null
+++ b/packages/client/src/hooks/useHasHover.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react'
+
+/**
+ * Detects whether the device has hover capability (mouse/trackpad).
+ * Touch-only devices (phones, tablets) return false regardless of orientation.
+ * Uses the W3C standard `(hover: hover)` media query (Media Queries Level 4).
+ *
+ * Supports hot-plug: connecting/disconnecting a mouse triggers the `change` event.
+ */
+export function useHasHover() {
+ const [hasHover, setHasHover] = useState(
+ () =>
+ typeof window !== 'undefined' &&
+ (window.matchMedia('(any-hover: hover)').matches || window.matchMedia('(hover: hover)').matches),
+ )
+
+ useEffect(() => {
+ const mqlAnyHover = window.matchMedia('(any-hover: hover)')
+ const mqlHover = window.matchMedia('(hover: hover)')
+ const onChange = () => setHasHover(mqlAnyHover.matches || mqlHover.matches)
+ mqlAnyHover.addEventListener('change', onChange)
+ mqlHover.addEventListener('change', onChange)
+ return () => {
+ mqlAnyHover.removeEventListener('change', onChange)
+ mqlHover.removeEventListener('change', onChange)
+ }
+ }, [])
+
+ return hasHover
+}
diff --git a/packages/client/src/hooks/useHowl.ts b/packages/client/src/hooks/useHowl.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6be0e7c0cc1fc19b9277fa8e3e28489a09b297b8
--- /dev/null
+++ b/packages/client/src/hooks/useHowl.ts
@@ -0,0 +1,444 @@
+import { useCallback, useEffect, useRef } from 'react'
+import { Howl, Howler } from 'howler'
+import type { Track } from '@music-together/shared'
+import { usePlayerStore } from '@/stores/playerStore'
+import { useRoomStore } from '@/stores/roomStore'
+import { useSettingsStore } from '@/stores/settingsStore'
+import { CURRENT_TIME_THROTTLE_MS } from '@/lib/constants'
+import { toast } from 'sonner'
+import { getServerTime } from '@/lib/clockSync'
+import { SERVER_URL } from '@/lib/config'
+import { attachTimeStretch, prepareDirectStreamForTimeStretch, type TimeStretchController } from '@/lib/timeStretch'
+
+/** Max wait (ms) for Howler `unlock` event before giving up and skipping */
+const PLAY_ERROR_TIMEOUT_MS = 3000
+
+/** HTMLMediaElement may wait forever when an upstream audio proxy cannot connect. */
+const LOAD_TIMEOUT_MS = 18_000
+
+/**
+ * Howler uses `canplaythrough` by default for HTML5 Audio. With a proxied
+ * Kugou stream that means waiting until the browser estimates the entire
+ * track can play uninterrupted, which can be tens of seconds when the server
+ * → CDN link is slow. `canplay` starts as soon as the media element has a
+ * usable buffer; the existing stalled-stream recovery still handles a later
+ * network interruption.
+ */
+;(Howler as typeof Howler & { _canPlayEvent: 'canplay' | 'canplaythrough' })._canPlayEvent = 'canplay'
+
+/** If playback reports playing() but currentTime doesn't advance for this
+ * many milliseconds, treat it as stalled (network drop mid-stream). */
+const STALLED_TIMEOUT_MS = 8000
+
+/** Short fade after a scheduled start masks the decoder's final seek. */
+const SCHEDULED_START_FADE_MS = 120
+const TRACK_CROSSFADE_MS = 80
+
+interface ScheduledStart {
+ howl: Howl
+ targetTime: number
+ executeAt: number
+ onCommit?: () => void
+}
+
+/**
+ * Manages a Howl audio instance with two-phase loading strategy:
+ * Phase 1: Create Howl with volume=0 (silent)
+ * Phase 2: onload → seek to target → delay → fade-in unmute
+ */
+export function useHowl(onTrackEnd: () => void) {
+ const howlRef = useRef(null)
+ const soundIdRef = useRef(undefined)
+ const animFrameRef = useRef(0)
+ const syncReadyRef = useRef(false)
+ const unmuteTimerRef = useRef | null>(null)
+ const playErrorTimerRef = useRef | null>(null)
+ const loadTimerRef = useRef | null>(null)
+ const lastTimeUpdateRef = useRef(0)
+ const stalledRef = useRef<{ lastSeek: number; since: number }>({ lastSeek: -1, since: 0 })
+ const trackTitleRef = useRef('')
+ const retryRef = useRef(false)
+ const scheduledStartRef = useRef(null)
+ const scheduledStartTimerRef = useRef | null>(null)
+ const outgoingHowlRef = useRef(null)
+ const outgoingCleanupTimerRef = useRef | null>(null)
+ const preparedTrackRef = useRef(null)
+ const stretchRef = useRef(null)
+ const stretchReadyRef = useRef | null>(null)
+
+ // Use selectors for the one reactive value we need (volume sync effect)
+ const volume = usePlayerStore((s) => s.volume)
+ const playbackTempoSyncEnabled = useSettingsStore((s) => s.playbackTempoSyncEnabled)
+
+ // Throttled time update loop with stalled detection
+ const startTimeUpdate = useCallback(() => {
+ cancelAnimationFrame(animFrameRef.current)
+ stalledRef.current = { lastSeek: -1, since: 0 }
+ const update = () => {
+ if (howlRef.current && howlRef.current.playing()) {
+ const now = performance.now()
+ if (now - lastTimeUpdateRef.current >= CURRENT_TIME_THROTTLE_MS) {
+ lastTimeUpdateRef.current = now
+ const seekVal = howlRef.current.seek() as number
+ usePlayerStore.getState().setCurrentTime(seekVal)
+
+ // Stalled detection: if currentTime hasn't moved for STALLED_TIMEOUT_MS
+ // while playing() is true, the stream likely broke mid-playback.
+ const st = stalledRef.current
+ if (Math.abs(seekVal - st.lastSeek) < 0.05) {
+ if (st.since > 0 && now - st.since > STALLED_TIMEOUT_MS) {
+ console.warn('Playback stalled, skipping track')
+ toast.error('播放中断,已跳到下一首')
+ stalledRef.current = { lastSeek: -1, since: 0 }
+ onTrackEnd()
+ return
+ }
+ // still stalled but not timed out yet — keep since
+ } else {
+ // time moved — reset stalled tracker
+ stalledRef.current = { lastSeek: seekVal, since: now }
+ }
+ }
+ }
+ animFrameRef.current = requestAnimationFrame(update)
+ }
+ animFrameRef.current = requestAnimationFrame(update)
+ }, [onTrackEnd])
+
+ const stopTimeUpdate = useCallback(() => {
+ cancelAnimationFrame(animFrameRef.current)
+ }, [])
+
+ const cancelScheduledPlayback = useCallback(() => {
+ if (scheduledStartTimerRef.current) {
+ clearTimeout(scheduledStartTimerRef.current)
+ scheduledStartTimerRef.current = null
+ }
+ scheduledStartRef.current = null
+ }, [])
+
+ const disposeHowl = useCallback((howl: Howl | null) => {
+ if (!howl) return
+ try {
+ howl.unload()
+ } catch {
+ /* ignore */
+ }
+ }, [])
+
+ const pausePlayback = useCallback(
+ (position: number) => {
+ const pendingStart = scheduledStartRef.current
+ cancelScheduledPlayback()
+ const howl = howlRef.current
+ if (howl) {
+ if (howl.playing()) howl.pause(soundIdRef.current)
+ howl.seek(position, soundIdRef.current)
+ }
+ if (pendingStart && preparedTrackRef.current) {
+ usePlayerStore.getState().setCurrentTrack(preparedTrackRef.current)
+ usePlayerStore.getState().setCurrentTime(position)
+ const duration = howl?.duration()
+ if (duration && Number.isFinite(duration)) usePlayerStore.getState().setDuration(duration)
+ pendingStart.onCommit?.()
+ }
+
+ // A pause received during a pending track transition also stops the
+ // outgoing track at the shared pause instant.
+ if (outgoingCleanupTimerRef.current) {
+ clearTimeout(outgoingCleanupTimerRef.current)
+ outgoingCleanupTimerRef.current = null
+ }
+ disposeHowl(outgoingHowlRef.current)
+ outgoingHowlRef.current = null
+ },
+ [cancelScheduledPlayback, disposeHowl],
+ )
+
+ const stopPlayback = useCallback(() => {
+ cancelScheduledPlayback()
+ if (loadTimerRef.current) {
+ clearTimeout(loadTimerRef.current)
+ loadTimerRef.current = null
+ }
+ if (outgoingCleanupTimerRef.current) {
+ clearTimeout(outgoingCleanupTimerRef.current)
+ outgoingCleanupTimerRef.current = null
+ }
+ const current = howlRef.current
+ const outgoing = outgoingHowlRef.current
+ disposeHowl(current)
+ if (outgoing !== current) disposeHowl(outgoing)
+ howlRef.current = null
+ outgoingHowlRef.current = null
+ preparedTrackRef.current = null
+ soundIdRef.current = undefined
+ stopTimeUpdate()
+ }, [cancelScheduledPlayback, disposeHowl, stopTimeUpdate])
+
+ const schedulePlayback = useCallback(
+ (targetTime: number, executeAt: number, onCommit?: () => void) => {
+ cancelScheduledPlayback()
+ const howl = howlRef.current
+ if (!howl) return
+
+ // Keep the decoder silent until its position has been aligned. Slow
+ // clients join the live timeline late instead of playing stale audio.
+ howl.volume(0)
+ scheduledStartRef.current = { howl, targetTime, executeAt, onCommit }
+
+ const start = () => {
+ scheduledStartTimerRef.current = null
+ void (async () => {
+ await stretchReadyRef.current
+ if (scheduledStartRef.current?.howl !== howl || howlRef.current !== howl) return
+ if (soundIdRef.current !== undefined) howl.play(soundIdRef.current)
+ else soundIdRef.current = howl.play()
+ })()
+ }
+
+ const delay = Math.max(0, executeAt - getServerTime())
+ scheduledStartTimerRef.current = setTimeout(start, delay)
+ },
+ [cancelScheduledPlayback],
+ )
+
+ // Load and play a track
+ const loadTrack = useCallback(
+ (track: Track, seekTo?: number, deferCommit = false) => {
+ cancelScheduledPlayback()
+ if (unmuteTimerRef.current) {
+ clearTimeout(unmuteTimerRef.current)
+ unmuteTimerRef.current = null
+ }
+ // Clear any pending play-error timeout from the previous track so it
+ // doesn't fire onTrackEnd() and skip the new track being loaded.
+ if (playErrorTimerRef.current) {
+ clearTimeout(playErrorTimerRef.current)
+ playErrorTimerRef.current = null
+ }
+ if (loadTimerRef.current) {
+ clearTimeout(loadTimerRef.current)
+ loadTimerRef.current = null
+ }
+
+ if (howlRef.current) {
+ // Keep one audible outgoing track alive while the replacement loads.
+ // If another replacement arrives before commit, discard only the
+ // superseded silent candidate.
+ const outgoing = outgoingHowlRef.current
+ if (outgoing && howlRef.current !== outgoing) {
+ disposeHowl(howlRef.current)
+ } else {
+ outgoingHowlRef.current = howlRef.current
+ }
+ stopTimeUpdate()
+ }
+
+ syncReadyRef.current = false
+ soundIdRef.current = undefined
+ trackTitleRef.current = track.title
+ preparedTrackRef.current = track
+ stretchRef.current = null
+ stretchReadyRef.current = null
+ retryRef.current = false
+
+ if (!track.streamUrl) return
+
+ const currentVolume = usePlayerStore.getState().volume
+ const roomId = useRoomStore.getState().room?.id ?? ''
+ const playbackUrl =
+ track.source === 'bilibili'
+ ? `${SERVER_URL}/api/music/bilibili-audio-proxy?url=${encodeURIComponent(track.streamUrl)}&bvid=${encodeURIComponent(track.urlId)}&roomId=${encodeURIComponent(roomId)}`
+ : track.source === 'kugou' || track.source === 'kugou_concept'
+ ? `${SERVER_URL}/api/music/kugou-audio-proxy?url=${encodeURIComponent(track.streamUrl)}`
+ : track.streamUrl
+ const streamFormat = track.source === 'bilibili' ? [track.streamFormat ?? 'm4a'] : ['flac', 'm4a', 'ogg', 'mp3']
+
+ // Howler creates its HTMLMediaElement synchronously inside the
+ // constructor. Configure CORS before it assigns the direct CDN URL so
+ // Web Audio is allowed to consume the stream.
+ const canTimeStretch = prepareDirectStreamForTimeStretch(playbackUrl)
+
+ const howl = new Howl({
+ src: [playbackUrl],
+ html5: true,
+ format: streamFormat,
+ volume: 0,
+ onload: () => {
+ if (howlRef.current !== howl) return // Stale instance guard
+ if (loadTimerRef.current) {
+ clearTimeout(loadTimerRef.current)
+ loadTimerRef.current = null
+ }
+ const d = howl.duration()
+ if (!deferCommit && Number.isFinite(d) && d > 0) {
+ usePlayerStore.getState().setDuration(d)
+ }
+ if (seekTo && seekTo > 0) howl.seek(seekTo)
+ if (scheduledStartRef.current?.howl === howl) howl.volume(0)
+ else howl.volume(currentVolume)
+ if (!deferCommit) usePlayerStore.getState().setCurrentTime(seekTo ?? 0)
+ syncReadyRef.current = true
+ },
+ onplay: (soundId) => {
+ if (howlRef.current !== howl) return
+
+ const scheduledStart = scheduledStartRef.current
+ if (scheduledStart?.howl === howl) {
+ const lateBy = Math.max(0, (getServerTime() - scheduledStart.executeAt) / 1000)
+ const alignedTime = scheduledStart.targetTime + lateBy
+ howl.volume(0, soundId)
+ howl.seek(alignedTime, soundId)
+ usePlayerStore.getState().setCurrentTrack(track)
+ usePlayerStore.getState().setCurrentTime(alignedTime)
+ const duration = howl.duration()
+ if (Number.isFinite(duration) && duration > 0) {
+ usePlayerStore.getState().setDuration(duration)
+ }
+ scheduledStart.onCommit?.()
+ scheduledStartRef.current = null
+ const outgoing = outgoingHowlRef.current
+ if (outgoing && outgoing !== howl) {
+ outgoingHowlRef.current = null
+ outgoing.fade(outgoing.volume(), 0, TRACK_CROSSFADE_MS)
+ if (outgoingCleanupTimerRef.current) clearTimeout(outgoingCleanupTimerRef.current)
+ outgoingCleanupTimerRef.current = setTimeout(() => {
+ outgoingCleanupTimerRef.current = null
+ disposeHowl(outgoing)
+ }, TRACK_CROSSFADE_MS)
+ }
+ if (unmuteTimerRef.current) clearTimeout(unmuteTimerRef.current)
+ unmuteTimerRef.current = setTimeout(() => {
+ if (howlRef.current !== howl) return
+ const latestVolume = usePlayerStore.getState().volume
+ howl.fade(0, latestVolume, SCHEDULED_START_FADE_MS, soundId)
+ syncReadyRef.current = true
+ }, 20)
+ }
+ usePlayerStore.getState().setIsPlaying(true)
+ const dur = howl.duration()
+ if (Number.isFinite(dur) && dur > 0) {
+ usePlayerStore.getState().setDuration(dur)
+ }
+ startTimeUpdate()
+ },
+ onpause: () => {
+ if (howlRef.current !== howl) return
+ usePlayerStore.getState().setIsPlaying(false)
+ stopTimeUpdate()
+ },
+ onend: () => {
+ if (howlRef.current !== howl) return
+ usePlayerStore.getState().setIsPlaying(false)
+ stopTimeUpdate()
+ onTrackEnd()
+ },
+ onloaderror: (_id, msg) => {
+ // If a newer track has been loaded, this Howl is stale — ignore.
+ if (howlRef.current !== howl) return
+ if (!retryRef.current) {
+ retryRef.current = true
+ console.warn('Howl load error, retrying:', msg)
+ howl.load()
+ return
+ }
+ retryRef.current = false
+ if (loadTimerRef.current) {
+ clearTimeout(loadTimerRef.current)
+ loadTimerRef.current = null
+ }
+ console.error('Howl load error (after retry):', msg)
+ toast.error(`「${trackTitleRef.current}」加载失败,已跳到下一首`)
+ onTrackEnd()
+ },
+ onplayerror: function (soundId: number) {
+ // Try to recover via Howler unlock; give up after timeout
+ if (playErrorTimerRef.current) clearTimeout(playErrorTimerRef.current)
+ playErrorTimerRef.current = setTimeout(() => {
+ playErrorTimerRef.current = null
+ console.warn('Howl unlock timeout, skipping track')
+ toast.error('播放失败,已跳到下一首')
+ onTrackEnd()
+ }, PLAY_ERROR_TIMEOUT_MS)
+ howl.once('unlock', () => {
+ if (howlRef.current !== howl) return // Already switched or unmounted
+ if (playErrorTimerRef.current) {
+ clearTimeout(playErrorTimerRef.current)
+ playErrorTimerRef.current = null
+ }
+ howl.play(soundId)
+ })
+ },
+ })
+
+ howlRef.current = howl
+ loadTimerRef.current = setTimeout(() => {
+ if (howlRef.current !== howl || syncReadyRef.current) return
+ loadTimerRef.current = null
+ console.warn('Audio load timed out, skipping track')
+ disposeHowl(howl)
+ if (howlRef.current === howl) howlRef.current = null
+ toast.error(`「${trackTitleRef.current}」加载超时,已跳到下一首`)
+ onTrackEnd()
+ }, LOAD_TIMEOUT_MS)
+ stretchReadyRef.current = (canTimeStretch ? attachTimeStretch(howl) : Promise.resolve(null)).then(
+ (controller) => {
+ if (howlRef.current === howl) stretchRef.current = controller
+ controller?.setEnabled(useSettingsStore.getState().playbackTempoSyncEnabled)
+ return controller
+ },
+ )
+ if (!deferCommit) usePlayerStore.getState().setCurrentTrack(track)
+ },
+ [cancelScheduledPlayback, disposeHowl, onTrackEnd, startTimeUpdate, stopTimeUpdate],
+ )
+
+ // Volume sync
+ useEffect(() => {
+ if (howlRef.current && syncReadyRef.current) {
+ howlRef.current.volume(volume)
+ }
+ }, [volume])
+
+ useEffect(() => {
+ const controller = stretchRef.current
+ if (!controller) return
+ if (!playbackTempoSyncEnabled) controller.reset()
+ controller.setEnabled(playbackTempoSyncEnabled)
+ }, [playbackTempoSyncEnabled])
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (unmuteTimerRef.current) {
+ clearTimeout(unmuteTimerRef.current)
+ unmuteTimerRef.current = null
+ }
+ stopPlayback()
+ if (playErrorTimerRef.current) {
+ clearTimeout(playErrorTimerRef.current)
+ playErrorTimerRef.current = null
+ }
+ if (outgoingCleanupTimerRef.current) {
+ clearTimeout(outgoingCleanupTimerRef.current)
+ outgoingCleanupTimerRef.current = null
+ }
+ }
+ }, [stopPlayback])
+
+ const setPlaybackTempo = useCallback((tempo: number) => {
+ stretchRef.current?.setTempo(tempo)
+ }, [])
+
+ return {
+ howlRef,
+ soundIdRef,
+ loadTrack,
+ schedulePlayback,
+ cancelScheduledPlayback,
+ pausePlayback,
+ stopPlayback,
+ setPlaybackTempo,
+ }
+}
diff --git a/packages/client/src/hooks/useIsMobile.ts b/packages/client/src/hooks/useIsMobile.ts
new file mode 100644
index 0000000000000000000000000000000000000000..98dc4ff2e3907e8bcd813a2e69634f9c1f27acee
--- /dev/null
+++ b/packages/client/src/hooks/useIsMobile.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from 'react'
+
+/**
+ * Detects portrait (mobile) vs landscape (desktop) orientation.
+ * Portrait = mobile layout (single column), Landscape = desktop layout (two columns).
+ */
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = useState(
+ () => typeof window !== 'undefined' && window.matchMedia('(orientation: portrait)').matches,
+ )
+
+ useEffect(() => {
+ const mql = window.matchMedia('(orientation: portrait)')
+ const onChange = () => setIsMobile(mql.matches)
+ mql.addEventListener('change', onChange)
+ return () => mql.removeEventListener('change', onChange)
+ }, [])
+
+ return isMobile
+}
diff --git a/packages/client/src/hooks/useLobby.ts b/packages/client/src/hooks/useLobby.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9f6aa7901f2685a5b92045fc1197825fa4191a6c
--- /dev/null
+++ b/packages/client/src/hooks/useLobby.ts
@@ -0,0 +1,55 @@
+import { storage } from '@/lib/storage'
+import { useSocketContext } from '@/providers/socket-context'
+import { useLobbyStore } from '@/stores/lobbyStore'
+import { EVENTS, type RoomListItem } from '@music-together/shared'
+import { useCallback, useEffect } from 'react'
+import { useSocketEvent } from './useSocketEvent'
+
+export function useLobby() {
+ const { socket } = useSocketContext()
+ const rooms = useLobbyStore((s) => s.rooms)
+ const isLoading = useLobbyStore((s) => s.isLoading)
+
+ // Request room list on mount and on reconnect
+ useEffect(() => {
+ const requestList = () => {
+ useLobbyStore.getState().setLoading(true)
+ socket.emit(EVENTS.ROOM_LIST)
+ }
+
+ requestList()
+ socket.on('connect', requestList)
+ return () => {
+ socket.off('connect', requestList)
+ }
+ }, [socket])
+
+ // Listen for real-time room list updates
+ useSocketEvent(
+ EVENTS.ROOM_LIST_UPDATE,
+ useCallback((rooms: RoomListItem[]) => {
+ useLobbyStore.getState().setRooms(rooms)
+ }, []),
+ )
+
+ const createRoom = useCallback(
+ (nickname: string, roomName?: string, password?: string) => {
+ socket.emit(EVENTS.ROOM_CREATE, { nickname, roomName, password })
+ },
+ [socket],
+ )
+
+ const joinRoom = useCallback(
+ (roomId: string, nickname: string, password?: string) => {
+ socket.emit(EVENTS.ROOM_JOIN, {
+ roomId,
+ nickname,
+ password,
+ rejoinToken: storage.getRejoinToken(roomId) ?? undefined,
+ })
+ },
+ [socket],
+ )
+
+ return { rooms, isLoading, createRoom, joinRoom }
+}
diff --git a/packages/client/src/hooks/useLyric.ts b/packages/client/src/hooks/useLyric.ts
new file mode 100644
index 0000000000000000000000000000000000000000..88e3ac064301ea1e5a238be9d45ecb7d86dfa69c
--- /dev/null
+++ b/packages/client/src/hooks/useLyric.ts
@@ -0,0 +1,253 @@
+import { SERVER_URL } from '@/lib/config'
+import { usePlayerStore } from '@/stores/playerStore'
+import { useSettingsStore } from '@/stores/settingsStore'
+import { parseTTML, parseYrc } from '@applemusic-like-lyrics/lyric'
+import type { LyricLine as AMLLLyricLine } from '@applemusic-like-lyrics/core'
+import type { Track } from '@music-together/shared'
+import { useCallback, useEffect, useRef } from 'react'
+
+/** 支持 TTML 的平台 → TTML DB 文件夹映射 */
+const TTML_FOLDER_MAP: Record = {
+ netease: 'ncm-lyrics',
+ tencent: 'qq-lyrics',
+}
+
+/** TTML 请求超时(ms) */
+const TTML_TIMEOUT_MS = 8_000
+
+/**
+ * 将 @applemusic-like-lyrics/lyric 的 LyricLine 转为 @applemusic-like-lyrics/core 的 LyricLine
+ * 两者接口略有差异(core 的 LyricWord 多 obscene 字段)
+ */
+function toCoreLyricLines(lyricLines: ReturnType['lines']): AMLLLyricLine[] {
+ return lyricLines.map((line) => ({
+ words: line.words.map((w) => ({
+ word: w.word,
+ startTime: w.startTime,
+ endTime: w.endTime,
+ romanWord: w.romanWord ?? '',
+ obscene: false,
+ })),
+ translatedLyric: line.translatedLyric ?? '',
+ romanLyric: line.romanLyric ?? '',
+ startTime: line.startTime,
+ endTime: line.endTime,
+ isBG: line.isBG ?? false,
+ isDuet: line.isDuet ?? false,
+ }))
+}
+
+/**
+ * parseYrc 返回的是裸 LyricLine[](无 .lines 包装)
+ * 直接转为 core 格式
+ */
+function yrcToCoreLyricLines(lines: ReturnType): AMLLLyricLine[] {
+ return lines.map((line) => ({
+ words: line.words.map((w) => ({
+ word: w.word,
+ startTime: w.startTime,
+ endTime: w.endTime,
+ romanWord: w.romanWord ?? '',
+ obscene: false,
+ })),
+ translatedLyric: line.translatedLyric ?? '',
+ romanLyric: line.romanLyric ?? '',
+ startTime: line.startTime,
+ endTime: line.endTime,
+ isBG: line.isBG ?? false,
+ isDuet: line.isDuet ?? false,
+ }))
+}
+
+/** 解析 LRC 格式歌词为 {timeMs, text} 数组 */
+function parseLRC(lrc: string): { timeMs: number; text: string }[] {
+ const lines: { timeMs: number; text: string }[] = []
+ const regex = /\[(\d{2}):(\d{2})(?:\.(\d{1,3}))?\](.*)/g
+ let match
+ while ((match = regex.exec(lrc)) !== null) {
+ const minutes = parseInt(match[1], 10)
+ const seconds = parseInt(match[2], 10)
+ const ms = match[3] ? parseInt(match[3].padEnd(3, '0'), 10) : 0
+ const timeMs = (minutes * 60 + seconds) * 1000 + ms
+ const text = match[4].trim()
+ if (text) lines.push({ timeMs, text })
+ }
+ return lines.sort((a, b) => a.timeMs - b.timeMs)
+}
+
+/**
+ * 将 LRC 格式的辅助歌词(翻译/罗马音)按时间戳合并到 AMLL LyricLine 的指定字段。
+ * 匹配策略:精确匹配 → ±500ms 容差(适配不同平台的时间轴偏差)。
+ * 直接修改传入的 lines 数组(避免不必要的拷贝)。
+ */
+function mergeLRCIntoLines(lines: AMLLLyricLine[], lrc: string, field: 'translatedLyric' | 'romanLyric'): void {
+ if (!lrc) return
+ const parsed = parseLRC(lrc)
+ if (parsed.length === 0) return
+
+ // 构建时间→文本映射(key = 毫秒取整到 100ms,加速查找)
+ const map = new Map()
+ for (const item of parsed) {
+ map.set(Math.round(item.timeMs / 100), item.text)
+ }
+
+ const TOLERANCE_STEPS = 5 // ±500ms,每步 100ms
+ for (const line of lines) {
+ // 跳过已有内容的行(TTML 自带时不覆盖)
+ if (line[field]) continue
+
+ const key = Math.round(line.startTime / 100)
+ const exact = map.get(key)
+ if (exact) {
+ line[field] = exact
+ continue
+ }
+ // 容差匹配
+ for (let offset = 1; offset <= TOLERANCE_STEPS; offset++) {
+ const near = map.get(key + offset) ?? map.get(key - offset)
+ if (near) {
+ line[field] = near
+ break
+ }
+ }
+ }
+}
+
+export function useLyric() {
+ const setLyric = usePlayerStore((s) => s.setLyric)
+ const setTtmlLines = usePlayerStore((s) => s.setTtmlLines)
+ const setLyricLoading = usePlayerStore((s) => s.setLyricLoading)
+ const abortRef = useRef(null)
+
+ // Abort any in-flight lyric request on unmount
+ useEffect(
+ () => () => {
+ abortRef.current?.abort()
+ },
+ [],
+ )
+
+ const fetchLyric = useCallback(
+ async (track: Track) => {
+ // Cancel any in-flight lyric request (e.g. rapid track switching)
+ abortRef.current?.abort()
+ abortRef.current = null
+
+ // 重置歌词状态(立即清空,避免显示上一首歌的歌词)
+ setTtmlLines(null)
+ setLyric('', '')
+ setLyricLoading(true)
+
+ const controller = new AbortController()
+ abortRef.current = controller
+
+ let wordByWordSuccess = false
+
+ // ========================================
+ // 1. 优先:TTML 在线逐词歌词(如果开启)
+ // ========================================
+ const { ttmlEnabled, ttmlDbUrl } = useSettingsStore.getState()
+ const lyricSource = track.metadataSource ?? track.source
+ const folder = TTML_FOLDER_MAP[lyricSource]
+ if (ttmlEnabled && folder) {
+ try {
+ // URL 模板:%s 替换为歌曲 ID,ncm-lyrics 适配平台
+ const lyricTrackId = track.metadataSource ? track.lyricId : track.sourceId
+ const ttmlUrl = ttmlDbUrl.replace('ncm-lyrics', folder).replace('%s', lyricTrackId ?? '')
+ // 绑定主 controller:切歌/卸载时取消 TTML,避免过时响应写回 store;同时 8s 超时
+ const timeoutSignal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(TTML_TIMEOUT_MS) : null
+ const SignalFactory = AbortSignal as typeof AbortSignal & {
+ any?: (signals: AbortSignal[]) => AbortSignal
+ }
+ const ttmlSignal =
+ timeoutSignal && typeof SignalFactory.any === 'function'
+ ? SignalFactory.any([controller.signal, timeoutSignal])
+ : controller.signal
+
+ const ttmlRes = await fetch(ttmlUrl, { signal: ttmlSignal })
+
+ if (ttmlRes.ok) {
+ const ttmlText = await ttmlRes.text()
+ // 确保返回的是 XML/TTML 而非错误页面
+ if (ttmlText.includes(' 0) {
+ setTtmlLines(toCoreLyricLines(parsed.lines))
+ wordByWordSuccess = true
+ }
+ }
+ }
+ } catch {
+ // TTML 获取失败(超时/网络错误),静默回退
+ if (controller.signal.aborted) return
+ }
+ }
+
+ // ========================================
+ // 2. 获取服务端歌词(包含 LRC + 可能的 YRC/KRC)
+ // ========================================
+ let lyricData: {
+ lyric: string
+ tlyric: string
+ romalrc: string
+ yrc: string
+ wordByWord?: AMLLLyricLine[]
+ } | null = null
+
+ if (track.lyricId) {
+ try {
+ const res = await fetch(
+ `${SERVER_URL}/api/music/lyric?source=${lyricSource}&lyricId=${encodeURIComponent(track.lyricId)}`,
+ { signal: controller.signal, credentials: 'include' },
+ )
+ if (res.ok) {
+ lyricData = await res.json()
+ }
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ }
+ }
+
+ // ========================================
+ // 3. 其次:平台原生逐词歌词(KRC 酷狗 / YRC 网易云)
+ // YRC/KRC 格式本身不携带翻译,需要将服务端返回的
+ // tlyric(LRC 格式)按时间戳合并到 translatedLyric 字段
+ // ========================================
+ if (!wordByWordSuccess && lyricData?.wordByWord?.length) {
+ // KRC:服务端已转为 AMLL 格式,合并翻译和罗马音后写入 store
+ mergeLRCIntoLines(lyricData.wordByWord, lyricData.tlyric, 'translatedLyric')
+ mergeLRCIntoLines(lyricData.wordByWord, lyricData.romalrc, 'romanLyric')
+ setTtmlLines(lyricData.wordByWord)
+ wordByWordSuccess = true
+ } else if (!wordByWordSuccess && lyricData?.yrc) {
+ try {
+ const parsed = parseYrc(lyricData.yrc)
+ if (parsed.length > 0) {
+ const amllLines = yrcToCoreLyricLines(parsed)
+ // YRC 不携带翻译和罗马音,从服务端数据合并
+ mergeLRCIntoLines(amllLines, lyricData.tlyric, 'translatedLyric')
+ mergeLRCIntoLines(amllLines, lyricData.romalrc, 'romanLyric')
+ setTtmlLines(amllLines)
+ wordByWordSuccess = true
+ }
+ } catch {
+ // YRC 解析失败,走 LRC 兜底
+ }
+ }
+
+ // ========================================
+ // 4. 兜底:设置 LRC 歌词
+ // ========================================
+ if (lyricData) {
+ setLyric(lyricData.lyric || '', lyricData.tlyric || '')
+ } else if (!wordByWordSuccess) {
+ setLyric('', '')
+ }
+
+ setLyricLoading(false)
+ },
+ [setLyric, setTtmlLines, setLyricLoading],
+ )
+
+ return { fetchLyric }
+}
diff --git a/packages/client/src/hooks/useMediaSession.ts b/packages/client/src/hooks/useMediaSession.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cc35986a7038398a1e4d35dee9654029275442f7
--- /dev/null
+++ b/packages/client/src/hooks/useMediaSession.ts
@@ -0,0 +1,246 @@
+import { useSocketContext } from '@/providers/socket-context'
+import { usePlayerStore } from '@/stores/playerStore'
+import { useRoomStore } from '@/stores/roomStore'
+import { useAccountStore } from '@/stores/accountStore'
+import { isBilibiliCoverUrl } from '@/lib/cover'
+import type { VoteAction } from '@music-together/shared'
+import { defineAbilityFor, EVENTS, TIMING } from '@music-together/shared'
+import { useCallback, useEffect, useMemo, useRef } from 'react'
+import { toast } from 'sonner'
+import { getVoteActionLabel } from './useVote'
+
+interface MediaSessionControls {
+ play: () => void
+ pause: () => void
+ next: () => void
+ prev: () => void
+ seek: (time: number) => void
+}
+
+/**
+ * Integrates with the MediaSession API so hardware media keys
+ * (play / pause / next / prev) and the OS media notification bar
+ * control the app's synced playback.
+ *
+ * Mirrors `PlayerControls` permission routing to keep every control
+ * entry point (on-screen buttons, media keys, lock-screen widget)
+ * behaviourally identical:
+ *
+ * - If the user can directly perform the action, the media-session
+ * handler invokes the action (which emits the usual socket event).
+ * - Else if the user can vote, the handler initiates a vote (available
+ * only for pause/resume/next/prev — no seek-vote exists).
+ * - Else the handler is unregistered so the OS hides or greys it out.
+ *
+ * `seekto` is only registered when the user has direct `seek` permission.
+ * Otherwise the optimistic `setCurrentTime` inside `usePlayer.seek` would
+ * leave the UI stuck at a fake position after the server rejected the
+ * request (no rollback exists — the in-page slider avoids this because
+ * it's disabled in the first place).
+ *
+ * Requirements:
+ * - A real `` / `` element must be playing. Howler runs
+ * with `html5: true`, which satisfies this.
+ * - First playback must come from a user gesture (autoplay policy);
+ * media keys work freely after that.
+ */
+export function useMediaSession({ play, pause, next, prev, seek }: MediaSessionControls) {
+ const { socket } = useSocketContext()
+ // Build ability directly from roomStore instead of AbilityContext, because
+ // usePlayer() (our caller) runs in RoomPage's function body which is
+ // *outside* 's subtree — useContext would always return
+ // the default member ability regardless of the user's actual role.
+ const role = useRoomStore((s) => s.currentUser?.role ?? 'member')
+ const isServerAdmin = useAccountStore((state) => state.profile?.role === 'admin')
+ const ability = useMemo(() => defineAbilityFor(isServerAdmin ? 'owner' : role), [isServerAdmin, role])
+ const canPlay = ability.can('play', 'Player')
+ const canSeek = ability.can('seek', 'Player')
+ const canNext = ability.can('next', 'Player')
+ const canPrev = ability.can('prev', 'Player')
+ const canVote = ability.can('vote', 'Player')
+
+ // Keep latest callbacks in refs so action handlers never go stale
+ // without having to rebind on every render.
+ const callbacksRef = useRef({ play, pause, next, prev, seek })
+ useEffect(() => {
+ callbacksRef.current = { play, pause, next, prev, seek }
+ }, [play, pause, next, prev, seek])
+
+ // Vote starter for member fall-back. Mirrors `useVote.startVote` but
+ // kept local to avoid depending on a hook that only exists once per
+ // tree (it lives inside AudioPlayer).
+ // Debounce matches PlayerControls' cooldown to prevent toast spam from
+ // rapid media-key presses.
+ const lastVoteRef = useRef(0)
+ const startVote = useCallback(
+ (action: VoteAction) => {
+ const now = Date.now()
+ if (now - lastVoteRef.current < TIMING.PLAYER_NEXT_DEBOUNCE_MS) return
+ lastVoteRef.current = now
+ socket.emit(EVENTS.VOTE_START, { action })
+ toast.info(`已发起投票:${getVoteActionLabel(action)}`)
+ },
+ [socket],
+ )
+
+ // Register action handlers based on the current ability.
+ // Re-runs on permission change so role up/down takes effect immediately.
+ useEffect(() => {
+ if (!('mediaSession' in navigator)) return
+ const ms = navigator.mediaSession
+
+ // play / pause — direct control or vote fallback (resume/pause).
+ if (canPlay) {
+ ms.setActionHandler('play', () => callbacksRef.current.play())
+ ms.setActionHandler('pause', () => callbacksRef.current.pause())
+ } else if (canVote) {
+ ms.setActionHandler('play', () => startVote('resume'))
+ ms.setActionHandler('pause', () => startVote('pause'))
+ } else {
+ ms.setActionHandler('play', null)
+ ms.setActionHandler('pause', null)
+ }
+
+ // next
+ if (canNext) {
+ ms.setActionHandler('nexttrack', () => callbacksRef.current.next())
+ } else if (canVote) {
+ ms.setActionHandler('nexttrack', () => startVote('next'))
+ } else {
+ ms.setActionHandler('nexttrack', null)
+ }
+
+ // prev
+ if (canPrev) {
+ ms.setActionHandler('previoustrack', () => callbacksRef.current.prev())
+ } else if (canVote) {
+ ms.setActionHandler('previoustrack', () => startVote('prev'))
+ } else {
+ ms.setActionHandler('previoustrack', null)
+ }
+
+ // seekto — direct only; no voting equivalent.
+ if (canSeek) {
+ ms.setActionHandler('seekto', (details) => {
+ if (details.seekTime != null) {
+ callbacksRef.current.seek(details.seekTime)
+ }
+ })
+ } else {
+ ms.setActionHandler('seekto', null)
+ }
+
+ return () => {
+ ms.setActionHandler('play', null)
+ ms.setActionHandler('pause', null)
+ ms.setActionHandler('nexttrack', null)
+ ms.setActionHandler('previoustrack', null)
+ ms.setActionHandler('seekto', null)
+ }
+ }, [canPlay, canSeek, canNext, canPrev, canVote, startVote])
+
+ // Metadata — current track info for the OS notification bar.
+ const currentTrack = usePlayerStore((s) => s.currentTrack)
+ useEffect(() => {
+ if (!('mediaSession' in navigator)) return
+ const ms = navigator.mediaSession
+
+ if (currentTrack) {
+ const metadata = {
+ title: currentTrack.title,
+ artist: currentTrack.artist.join(' / '),
+ album: currentTrack.album || '',
+ }
+ const directArtwork = currentTrack.cover
+ ? [{ src: currentTrack.cover, sizes: '512x512', type: 'image/jpeg' }]
+ : []
+
+ if (!currentTrack.cover || !isBilibiliCoverUrl(currentTrack.cover)) {
+ ms.metadata = new MediaMetadata({ ...metadata, artwork: directArtwork })
+ } else {
+ const controller = new AbortController()
+ let objectUrl: string | null = null
+
+ ms.metadata = new MediaMetadata(metadata)
+ void fetch(currentTrack.cover, {
+ signal: controller.signal,
+ referrerPolicy: 'no-referrer',
+ })
+ .then((response) => {
+ if (!response.ok) throw new Error(`Artwork request failed: ${response.status}`)
+ return response.blob()
+ })
+ .then((blob) => {
+ if (controller.signal.aborted) return
+ objectUrl = URL.createObjectURL(blob)
+ ms.metadata = new MediaMetadata({
+ ...metadata,
+ artwork: [{ src: objectUrl, sizes: '512x512', type: blob.type || 'image/jpeg' }],
+ })
+ })
+ .catch(() => {
+ // Keep text-only metadata when artwork is unavailable or the track changes.
+ })
+
+ return () => {
+ controller.abort()
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
+ ms.metadata = null
+ }
+ }
+ } else {
+ ms.metadata = null
+ }
+
+ return () => {
+ ms.metadata = null
+ }
+ }, [currentTrack])
+
+ // Playback state.
+ const isPlaying = usePlayerStore((s) => s.isPlaying)
+ useEffect(() => {
+ if (!('mediaSession' in navigator)) return
+ navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused'
+
+ return () => {
+ navigator.mediaSession.playbackState = 'none'
+ }
+ }, [isPlaying])
+
+ // Position state. Browsers interpolate using `playbackRate`, so we only
+ // need to sync on meaningful state changes: play/pause toggles, duration
+ // updates, or seeks. Syncing on every `currentTime` tick is wasteful.
+ const currentTime = usePlayerStore((s) => s.currentTime)
+ const duration = usePlayerStore((s) => s.duration)
+ const prevIsPlayingRef = useRef(isPlaying)
+ const prevDurationRef = useRef(duration)
+ const prevPositionRef = useRef(currentTime)
+
+ useEffect(() => {
+ if (!('mediaSession' in navigator)) return
+ if (!duration || !isFinite(duration)) return
+
+ const playStateChanged = isPlaying !== prevIsPlayingRef.current
+ const durationChanged = duration !== prevDurationRef.current
+ // Detect a seek: position jumped more than ~2s away from linear progression.
+ const expectedDelta = prevIsPlayingRef.current ? 1 : 0
+ const isSeek = Math.abs(currentTime - prevPositionRef.current - expectedDelta) > 2
+
+ if (!playStateChanged && !durationChanged && !isSeek) return
+
+ prevIsPlayingRef.current = isPlaying
+ prevDurationRef.current = duration
+ prevPositionRef.current = currentTime
+
+ try {
+ navigator.mediaSession.setPositionState({
+ duration,
+ playbackRate: 1,
+ position: Math.min(Math.max(0, currentTime), duration),
+ })
+ } catch {
+ // setPositionState throws if position > duration; ignore gracefully.
+ }
+ }, [isPlaying, duration, currentTime])
+}
diff --git a/packages/client/src/hooks/usePlayer.ts b/packages/client/src/hooks/usePlayer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b79822980902d3f0741506d973df4ec050f9e8c1
--- /dev/null
+++ b/packages/client/src/hooks/usePlayer.ts
@@ -0,0 +1,244 @@
+import { getServerTime } from '@/lib/clockSync'
+import { PLAYER_PLAY_DEDUP_MS } from '@/lib/constants'
+import { storage } from '@/lib/storage'
+import { useSocketContext } from '@/providers/socket-context'
+import { usePlayerStore } from '@/stores/playerStore'
+import { useRoomStore } from '@/stores/roomStore'
+import type { ScheduledPlayState, Track } from '@music-together/shared'
+import { EVENTS } from '@music-together/shared'
+import { useCallback, useEffect, useRef } from 'react'
+import { useHowl } from './useHowl'
+import { useLyric } from './useLyric'
+import { useMediaSession } from './useMediaSession'
+import { usePlayerSync } from './usePlayerSync'
+
+/**
+ * Composing hook: useHowl + useLyric + usePlayerSync.
+ * Provides unified playback controls.
+ *
+ * Architecture: **Scheduled Execution**.
+ * All player actions (play, pause, seek, resume) are emitted to the server
+ * which broadcasts a `ScheduledPlayState` to ALL clients (including the
+ * initiator). Clients then execute the action at the scheduled server-time
+ * so that every device acts in unison.
+ */
+export function usePlayer() {
+ const { socket } = useSocketContext()
+ const loadingRef = useRef<{ trackId: string; ts: number; serverTimestamp: number } | null>(null)
+ // Set by recovery effect to signal onPlayerPlay that this track was already
+ // loaded by reconnect recovery — the subsequent PLAYER_PLAY from
+ // syncPlaybackToSocket should be skipped to avoid a double-load.
+ const recoveredTrackIdRef = useRef(null)
+
+ const next = useCallback(() => socket.emit(EVENTS.PLAYER_NEXT), [socket])
+
+ // Auto-next on song end: only the current conductor (hostId) emits PLAYER_NEXT.
+ // The conductor is auto-elected by the server (owner > admin > member).
+ // Other clients silently wait to prevent duplicate PLAYER_NEXT events.
+ const autoNext = useCallback(() => {
+ const { room } = useRoomStore.getState()
+ const myId = storage.getUserId()
+ if (room?.hostId === myId) {
+ socket.emit(EVENTS.PLAYER_NEXT)
+ }
+ }, [socket])
+
+ const {
+ howlRef,
+ soundIdRef,
+ loadTrack,
+ schedulePlayback,
+ cancelScheduledPlayback,
+ pausePlayback,
+ stopPlayback,
+ setPlaybackTempo,
+ } = useHowl(autoNext)
+ const { fetchLyric } = useLyric()
+
+ // Connect sync (handles SEEK, PAUSE, RESUME + conductor reporting)
+ usePlayerSync(howlRef, soundIdRef, schedulePlayback, cancelScheduledPlayback, pausePlayback, setPlaybackTempo)
+
+ // Reset dedup ref on disconnect so reconnect PLAYER_PLAY is never blocked
+ useEffect(() => {
+ const onDisconnect = () => {
+ loadingRef.current = null
+ recoveredTrackIdRef.current = null
+ }
+ socket.on('disconnect', onDisconnect)
+ return () => {
+ socket.off('disconnect', onDisconnect)
+ }
+ }, [socket])
+
+ // Listen for PLAYER_PLAY events (new track load)
+ useEffect(() => {
+ const onPlayerPlay = (data: { track: Track; playState: ScheduledPlayState }) => {
+ // Deduplicate: ignore if the same track with the same serverTimestamp
+ // was requested within the dedup window. Comparing serverTimestamp
+ // ensures that a legitimate replay of the same track (e.g. loop mode)
+ // with a different serverTimestamp is not discarded.
+ const now = Date.now()
+ if (
+ loadingRef.current?.trackId === data.track.id &&
+ loadingRef.current.serverTimestamp === data.playState.serverTimestamp &&
+ now - loadingRef.current.ts < PLAYER_PLAY_DEDUP_MS
+ ) {
+ return
+ }
+ // Recovery already loaded this track (reconnect: ROOM_STATE → recovery
+ // loadTrack → PLAYER_PLAY from syncPlaybackToSocket). The serverTimestamp
+ // differs (syncPlaybackToSocket computes a new scheduleTime) so the normal
+ // dedup above doesn't catch it. Skip the redundant load but update
+ // roomStore with the authoritative scheduled playState.
+ if (recoveredTrackIdRef.current === data.track.id) {
+ recoveredTrackIdRef.current = null
+ loadingRef.current = { trackId: data.track.id, ts: now, serverTimestamp: data.playState.serverTimestamp }
+ useRoomStore.getState().updateRoom({
+ currentTrack: data.track,
+ playState: {
+ isPlaying: data.playState.isPlaying,
+ currentTime: data.playState.currentTime,
+ serverTimestamp: data.playState.serverTimestamp,
+ },
+ })
+ if (data.playState.isPlaying) {
+ schedulePlayback(data.playState.currentTime, data.playState.serverTimeToExecute, () => {
+ fetchLyric(data.track)
+ })
+ }
+ return
+ }
+ loadingRef.current = { trackId: data.track.id, ts: now, serverTimestamp: data.playState.serverTimestamp }
+
+ // Keep roomStore in sync so recovery effect sees the correct currentTrack
+ useRoomStore.getState().updateRoom({
+ currentTrack: data.track,
+ playState: {
+ isPlaying: data.playState.isPlaying,
+ currentTime: data.playState.currentTime,
+ serverTimestamp: data.playState.serverTimestamp,
+ },
+ })
+
+ // Start downloading immediately, while remaining silent. Playback is
+ // committed at the shared server time. If this device buffers past the
+ // deadline, schedulePlayback aligns it before the fade-in.
+ loadTrack(data.track, data.playState.currentTime, data.playState.isPlaying)
+ if (data.playState.isPlaying) {
+ schedulePlayback(data.playState.currentTime, data.playState.serverTimeToExecute, () => {
+ fetchLyric(data.track)
+ })
+ } else {
+ fetchLyric(data.track)
+ }
+ }
+
+ socket.on(EVENTS.PLAYER_PLAY, onPlayerPlay)
+
+ return () => {
+ socket.off(EVENTS.PLAYER_PLAY, onPlayerPlay)
+ }
+ }, [socket, loadTrack, fetchLyric, schedulePlayback])
+
+ useEffect(() => {
+ const onTrackMetadataUpdated = (data: { track: Track }) => {
+ const currentTrack = usePlayerStore.getState().currentTrack
+ if (currentTrack?.id !== data.track.id) return
+ usePlayerStore.getState().setCurrentTrack(data.track)
+ useRoomStore.getState().updateRoom({ currentTrack: data.track })
+ fetchLyric(data.track)
+ }
+
+ socket.on(EVENTS.PLAYER_TRACK_METADATA_UPDATED, onTrackMetadataUpdated)
+ return () => socket.off(EVENTS.PLAYER_TRACK_METADATA_UPDATED, onTrackMetadataUpdated)
+ }, [socket, fetchLyric])
+
+ // Recovery: auto-sync player state from room state when desync is detected
+ // (e.g. after HMR resets stores, or reconnection where PLAYER_PLAY was missed)
+ useEffect(() => {
+ let hasRecovered = false
+
+ const recover = () => {
+ const { room } = useRoomStore.getState()
+
+ // When room becomes null (disconnect), reset flag so next reconnect can recover
+ if (!room) {
+ hasRecovered = false
+ return
+ }
+
+ if (hasRecovered) return
+ const playerTrack = usePlayerStore.getState().currentTrack
+ const roomTrack = room.currentTrack
+
+ // Server has cleared the track (queue empty / cleared) — reset client
+ if (!roomTrack && playerTrack) {
+ hasRecovered = true
+ stopPlayback()
+ usePlayerStore.getState().reset()
+ return
+ }
+
+ // Server has track but client doesn't (HMR reset / missed PLAYER_PLAY)
+ if (roomTrack?.streamUrl && (!playerTrack || !howlRef.current)) {
+ // Skip if onPlayerPlay is already handling this track — its updateRoom()
+ // call triggers this subscription synchronously before loadTrack runs,
+ // so playerTrack/howlRef are still stale. Checking loadingRef avoids
+ // a redundant double-load.
+ // However, if howlRef is null despite loadingRef pointing to this track,
+ // the previous loadTrack failed (e.g. !streamUrl) and we should retry.
+ if (loadingRef.current?.trackId === roomTrack.id && howlRef.current) return
+
+ hasRecovered = true
+ const ps = room.playState
+ const elapsed = ps.isPlaying ? (getServerTime() - ps.serverTimestamp) / 1000 : 0
+ const recoveredTime = ps.currentTime + Math.max(0, elapsed)
+ recoveredTrackIdRef.current = roomTrack.id
+ loadTrack(roomTrack, recoveredTime, ps.isPlaying)
+ if (ps.isPlaying) {
+ schedulePlayback(recoveredTime, getServerTime(), () => fetchLyric(roomTrack))
+ } else {
+ fetchLyric(roomTrack)
+ }
+ }
+ }
+
+ // Check immediately (covers HMR where roomStore already has data)
+ recover()
+
+ // Subscribe for future changes (covers reconnect where ROOM_STATE arrives later)
+ const unsubscribe = useRoomStore.subscribe(recover)
+ return unsubscribe
+ // `socket` intentionally excluded — effect subscribes to roomStore, not socket directly.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loadTrack, fetchLyric, schedulePlayback, stopPlayback])
+
+ // -----------------------------------------------------------------------
+ // Controls — emit to server only. Server broadcasts ScheduledPlayState
+ // to ALL clients (including us) via scheduled execution.
+ // -----------------------------------------------------------------------
+ const play = useCallback(() => {
+ socket.emit(EVENTS.PLAYER_PLAY)
+ }, [socket])
+
+ const pause = useCallback(() => {
+ socket.emit(EVENTS.PLAYER_PAUSE)
+ }, [socket])
+
+ const seek = useCallback(
+ (time: number) => {
+ // Optimistic local update for the progress bar UI
+ usePlayerStore.getState().setCurrentTime(time)
+ socket.emit(EVENTS.PLAYER_SEEK, { currentTime: time })
+ },
+ [socket],
+ )
+
+ const prev = useCallback(() => socket.emit(EVENTS.PLAYER_PREV), [socket])
+
+ // MediaSession: hardware media keys + OS media notification bar.
+ // Permission-aware: mirrors PlayerControls fallback-to-vote behaviour.
+ useMediaSession({ play, pause, next, prev, seek })
+
+ return { play, pause, seek, next, prev }
+}
diff --git a/packages/client/src/hooks/usePlayerSync.ts b/packages/client/src/hooks/usePlayerSync.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f4dbfe512d4ac9e3aa2a9092e316e45e5149b923
--- /dev/null
+++ b/packages/client/src/hooks/usePlayerSync.ts
@@ -0,0 +1,317 @@
+import { getServerTime, isCalibrated, getMedianRTT } from '@/lib/clockSync'
+import {
+ DRIFT_SEEK_THRESHOLD_MS,
+ DRIFT_DEAD_ZONE_MS,
+ DRIFT_TEMPO_KP,
+ MAX_TEMPO_ADJUSTMENT,
+ DRIFT_SMOOTH_ALPHA,
+ MAX_NETWORK_DELAY_S,
+ DRIFT_SEEK_RTT_MARGIN_MS,
+ HARD_SEEK_CONFIRM_COUNT,
+} from '@/lib/constants'
+import { useSocketContext } from '@/providers/socket-context'
+import { usePlayerStore } from '@/stores/playerStore'
+import { useRoomStore } from '@/stores/roomStore'
+import { useSettingsStore } from '@/stores/settingsStore'
+import type { ScheduledPlayState } from '@music-together/shared'
+import { EVENTS } from '@music-together/shared'
+import type { Howl } from 'howler'
+import { useEffect, useRef, type RefObject } from 'react'
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Compute the delay (ms) until `serverTimeToExecute`, using our
+ * NTP-calibrated clock. Returns 0 if the time has already passed.
+ * Falls back to 0 (immediate execution) when NTP is not yet calibrated
+ * to avoid wildly inaccurate scheduling from uncorrected local clocks.
+ */
+function scheduleDelay(serverTimeToExecute: number): number {
+ if (!isCalibrated()) return 0
+ return Math.max(0, serverTimeToExecute - getServerTime())
+}
+
+function clamp(value: number, limit: number): number {
+ return Math.max(-limit, Math.min(limit, value))
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+/**
+ * Manages playback sync via **event-driven Scheduled Execution**
+ * with periodic **pitch-preserving drift correction + EMA smoothing**:
+ *
+ * small drift -> SoundTouch tempo correction (max +/-1%)
+ * sustained large drift -> direct seek correction (rare)
+ *
+ * The EMA low-pass filter prevents a single noisy network sample from
+ * triggering a seek. Native uncorrected playback rate is never used.
+ */
+export function usePlayerSync(
+ howlRef: RefObject,
+ soundIdRef: RefObject,
+ schedulePlayback: (targetTime: number, executeAt: number) => void,
+ cancelScheduledPlayback: () => void,
+ pausePlayback: (position: number) => void,
+ setPlaybackTempo: (tempo: number) => void,
+) {
+ const { socket } = useSocketContext()
+ const setCurrentTime = usePlayerStore((s) => s.setCurrentTime)
+ const syncPacketIntervalSeconds = useSettingsStore((s) => s.syncPacketIntervalSeconds)
+
+ // Pending scheduled action timers (so we can cancel on unmount / new action)
+ const scheduledTimerRef = useRef | null>(null)
+ // Monotonic action ID — guards against stale setTimeout(fn, 0) callbacks
+ // when rapid events arrive in the same event loop tick.
+ const actionIdRef = useRef(0)
+ // EMA-smoothed drift value (seconds) — persists across sync responses
+ const smoothedDriftRef = useRef(0)
+ // When true, next sync response seeds EMA directly instead of blending,
+ // avoiding the cold-start lag after pause/resume/new-track.
+ const emaColdStartRef = useRef(true)
+ // Consecutive hard-seek triggers — require HARD_SEEK_CONFIRM_COUNT before actually seeking
+ const hardSeekCountRef = useRef(0)
+
+ const clearScheduled = () => {
+ if (scheduledTimerRef.current) {
+ clearTimeout(scheduledTimerRef.current)
+ scheduledTimerRef.current = null
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Scheduled action handlers
+ // -----------------------------------------------------------------------
+ useEffect(() => {
+ // -- SEEK ---------------------------------------------------------------
+ const onSeek = (data: { playState: ScheduledPlayState }) => {
+ clearScheduled()
+ const id = ++actionIdRef.current
+ const delay = scheduleDelay(data.playState.serverTimeToExecute)
+
+ scheduledTimerRef.current = setTimeout(() => {
+ if (actionIdRef.current !== id) return // stale callback
+ if (howlRef.current) {
+ howlRef.current.seek(data.playState.currentTime)
+ }
+ setCurrentTime(data.playState.currentTime)
+ setPlaybackTempo(1)
+ smoothedDriftRef.current = 0
+ emaColdStartRef.current = true
+ // Keep roomStore.playState in sync for recovery effect
+ useRoomStore.getState().updateRoom({
+ playState: {
+ isPlaying: data.playState.isPlaying,
+ currentTime: data.playState.currentTime,
+ serverTimestamp: data.playState.serverTimestamp,
+ },
+ })
+ }, delay)
+ }
+
+ // -- PAUSE --------------------------------------------------------------
+ const onPause = (data: { playState: ScheduledPlayState }) => {
+ clearScheduled()
+ cancelScheduledPlayback()
+ const id = ++actionIdRef.current
+ const delay = scheduleDelay(data.playState.serverTimeToExecute)
+
+ scheduledTimerRef.current = setTimeout(() => {
+ if (actionIdRef.current !== id) return // stale callback
+ if (howlRef.current) {
+ pausePlayback(data.playState.currentTime)
+ setCurrentTime(data.playState.currentTime)
+ }
+ // Reset drift state — paused means no drift
+ smoothedDriftRef.current = 0
+ emaColdStartRef.current = true
+ usePlayerStore.getState().setSyncDrift(0)
+ setPlaybackTempo(1)
+ // Keep roomStore.playState in sync for recovery effect
+ useRoomStore.getState().updateRoom({
+ playState: {
+ isPlaying: data.playState.isPlaying,
+ currentTime: data.playState.currentTime,
+ serverTimestamp: data.playState.serverTimestamp,
+ },
+ })
+ }, delay)
+ }
+
+ // -- RESUME -------------------------------------------------------------
+ const onResume = (data: { playState: ScheduledPlayState }) => {
+ clearScheduled()
+ const id = ++actionIdRef.current
+ const delay = scheduleDelay(data.playState.serverTimeToExecute)
+
+ if (howlRef.current) {
+ schedulePlayback(data.playState.currentTime, data.playState.serverTimeToExecute)
+ }
+
+ scheduledTimerRef.current = setTimeout(() => {
+ if (actionIdRef.current !== id) return // stale callback
+ if (!howlRef.current) return
+ smoothedDriftRef.current = 0
+ emaColdStartRef.current = true
+ setPlaybackTempo(1)
+ // Keep roomStore.playState in sync for recovery effect
+ useRoomStore.getState().updateRoom({
+ playState: {
+ isPlaying: data.playState.isPlaying,
+ currentTime: data.playState.currentTime,
+ serverTimestamp: data.playState.serverTimestamp,
+ },
+ })
+ }, delay)
+ }
+
+ // -- NEW TRACK (PLAYER_PLAY) ---------------------------------------------
+ // When a new track loads, cancel any pending action from the previous track
+ // so it doesn't accidentally seek/pause/resume the new Howl instance.
+ const onPlay = () => {
+ clearScheduled()
+ ++actionIdRef.current // invalidate any pending stale callbacks
+ hardSeekCountRef.current = 0
+ smoothedDriftRef.current = 0
+ emaColdStartRef.current = true
+ setPlaybackTempo(1)
+ }
+
+ // -- SYNC RESPONSE (proportional drift correction + EMA smoothing) ------
+ const onSyncResponse = (data: { currentTime: number; isPlaying: boolean; serverTimestamp: number }) => {
+ if (!howlRef.current) return
+ if (!howlRef.current.playing()) return
+ if (!isCalibrated()) return
+
+ // Use NTP-calibrated server time for accurate delay estimation
+ const networkDelaySec = Math.max(
+ 0,
+ Math.min(MAX_NETWORK_DELAY_S, (getServerTime() - data.serverTimestamp) / 1000),
+ )
+ const expectedTime = data.currentTime + (data.isPlaying ? networkDelaySec : 0)
+
+ const currentSeek = howlRef.current.seek() as number
+ const rawDrift = currentSeek - expectedTime
+
+ // EMA low-pass filter: smooths noisy measurements to prevent oscillation.
+ // On cold start (after pause/resume/new-track), seed with raw value
+ // directly so the first correction is immediate, not dampened by stale 0.
+ if (emaColdStartRef.current) {
+ smoothedDriftRef.current = rawDrift
+ emaColdStartRef.current = false
+ } else {
+ smoothedDriftRef.current = DRIFT_SMOOTH_ALPHA * rawDrift + (1 - DRIFT_SMOOTH_ALPHA) * smoothedDriftRef.current
+ }
+ const sd = smoothedDriftRef.current
+ const absDrift = Math.abs(sd)
+
+ // Update store with smoothed value so UI shows stable drift reading
+ usePlayerStore.getState().setSyncDrift(sd)
+
+ // Adaptive hard-seek threshold: on high-latency links the NTP offset
+ // error (asymmetric routing) and extrapolation jitter can trigger
+ // unnecessary backward jumps on a static threshold.
+ // Raising the threshold proportionally to the observed RTT avoids this.
+ const rttBasedThreshold = (getMedianRTT() + DRIFT_SEEK_RTT_MARGIN_MS) / 1000
+ const hardSeekThreshold = Math.max(DRIFT_SEEK_THRESHOLD_MS / 1000, rttBasedThreshold)
+ const { playbackTempoSyncEnabled, playbackHardSeekSyncEnabled } = useSettingsStore.getState()
+ const hardSeekEnabled = playbackTempoSyncEnabled || playbackHardSeekSyncEnabled
+
+ if (absDrift > hardSeekThreshold) {
+ if (!hardSeekEnabled) {
+ hardSeekCountRef.current = 0
+ setPlaybackTempo(1)
+ return
+ }
+ // Require HARD_SEEK_CONFIRM_COUNT consecutive triggers to avoid
+ // acting on a single noisy measurement.
+ hardSeekCountRef.current++
+ if (hardSeekCountRef.current < HARD_SEEK_CONFIRM_COUNT) return
+ // Confirmed: large sustained drift — hard seek
+ hardSeekCountRef.current = 0
+ const howl = howlRef.current
+ const soundId = soundIdRef.current
+ const restoreVolume = usePlayerStore.getState().volume
+ setPlaybackTempo(1)
+
+ // Howler's HTML5 seek briefly pauses and asynchronously restarts the
+ // media element. Pre-seek fading can therefore strand the sound at
+ // volume 0 when playback is temporarily reported as paused or the
+ // callback is cancelled. Seek directly and always restore the latest
+ // user volume so a correction can never leave the track muted.
+ if (soundId === undefined) howl.seek(expectedTime)
+ else howl.seek(expectedTime, soundId)
+ if (soundId === undefined) howl.volume(restoreVolume)
+ else howl.volume(restoreVolume, soundId)
+ setCurrentTime(expectedTime)
+ smoothedDriftRef.current = 0
+ emaColdStartRef.current = true
+ } else if (absDrift > DRIFT_DEAD_ZONE_MS / 1000) {
+ hardSeekCountRef.current = 0
+ if (playbackTempoSyncEnabled) {
+ const adjustment = clamp(sd * DRIFT_TEMPO_KP, MAX_TEMPO_ADJUSTMENT)
+ setPlaybackTempo(1 - adjustment)
+ } else {
+ setPlaybackTempo(1)
+ }
+ } else {
+ hardSeekCountRef.current = 0
+ setPlaybackTempo(1)
+ }
+ }
+
+ socket.on(EVENTS.PLAYER_SEEK, onSeek)
+ socket.on(EVENTS.PLAYER_PAUSE, onPause)
+ socket.on(EVENTS.PLAYER_RESUME, onResume)
+ socket.on(EVENTS.PLAYER_PLAY, onPlay)
+ socket.on(EVENTS.PLAYER_SYNC_RESPONSE, onSyncResponse)
+
+ return () => {
+ clearScheduled()
+ socket.off(EVENTS.PLAYER_SEEK, onSeek)
+ socket.off(EVENTS.PLAYER_PAUSE, onPause)
+ socket.off(EVENTS.PLAYER_RESUME, onResume)
+ socket.off(EVENTS.PLAYER_PLAY, onPlay)
+ socket.off(EVENTS.PLAYER_SYNC_RESPONSE, onSyncResponse)
+ }
+ }, [
+ socket,
+ howlRef,
+ soundIdRef,
+ setCurrentTime,
+ schedulePlayback,
+ cancelScheduledPlayback,
+ pausePlayback,
+ setPlaybackTempo,
+ ])
+
+ // -----------------------------------------------------------------------
+ // Every client, including the room host, follows the server-owned timeline.
+ // -----------------------------------------------------------------------
+ useEffect(() => {
+ const requestSyncIfPlaying = () => {
+ if (!socket.connected || !howlRef.current?.playing()) return
+ socket.emit(EVENTS.PLAYER_SYNC_REQUEST)
+ }
+
+ let visibilityFollowUp: ReturnType | null = null
+ const onVisibilityChange = () => {
+ if (document.visibilityState !== 'visible') return
+ requestSyncIfPlaying()
+ // A second close sample confirms a real post-background drift without
+ // returning to the old constant one-request-per-second traffic pattern.
+ visibilityFollowUp = setTimeout(requestSyncIfPlaying, 250)
+ }
+ const interval = setInterval(requestSyncIfPlaying, syncPacketIntervalSeconds * 1000)
+ document.addEventListener('visibilitychange', onVisibilityChange)
+ return () => {
+ clearInterval(interval)
+ if (visibilityFollowUp) clearTimeout(visibilityFollowUp)
+ document.removeEventListener('visibilitychange', onVisibilityChange)
+ }
+ }, [socket, howlRef, syncPacketIntervalSeconds])
+}
diff --git a/packages/client/src/hooks/usePlaylist.ts b/packages/client/src/hooks/usePlaylist.ts
new file mode 100644
index 0000000000000000000000000000000000000000..90eedb77db3df70e95b9bb4b80cf7211f73a33cd
--- /dev/null
+++ b/packages/client/src/hooks/usePlaylist.ts
@@ -0,0 +1,333 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { EVENTS, LIMITS, type MusicSource, type Playlist, type Track } from '@music-together/shared'
+import { useSocketContext } from '@/providers/socket-context'
+import { useRoomStore } from '@/stores/roomStore'
+import { SERVER_URL } from '@/lib/config'
+
+const PAGE_SIZE = LIMITS.PLAYLIST_PAGE_SIZE
+
+/** Build the playlist API URL with all query parameters */
+function buildPlaylistUrl(
+ source: MusicSource,
+ id: string,
+ limit: number,
+ offset: number,
+ options?: { total?: number; roomId?: string; type?: 'playlist' | 'album' },
+): string {
+ const params = new URLSearchParams({
+ source,
+ id,
+ limit: String(limit),
+ offset: String(offset),
+ })
+ if (options?.total) params.set('total', String(options.total))
+ if (options?.roomId) params.set('roomId', options.roomId)
+ if (options?.type) params.set('type', options.type)
+ return `${SERVER_URL}/api/music/playlist?${params.toString()}`
+}
+
+/**
+ * Extract a playlist ID from a URL or raw ID string.
+ * Supports common URL formats for netease, tencent, and kugou.
+ */
+export function parsePlaylistInput(input: string, source: MusicSource): string | null {
+ const trimmed = input.trim()
+ if (!trimmed) return null
+
+ // If it looks like a plain numeric/alphanumeric ID, return as-is
+ if (/^[\w-]+$/.test(trimmed)) return trimmed
+
+ try {
+ const url = new URL(trimmed)
+
+ switch (source) {
+ case 'netease': {
+ // https://music.163.com/playlist?id=12345
+ // https://music.163.com/#/playlist?id=12345
+ const idParam = url.searchParams.get('id')
+ if (idParam) return idParam
+ const hashMatch = url.hash.match(/[?&]id=(\d+)/)
+ if (hashMatch) return hashMatch[1]
+ const pathMatch = url.pathname.match(/\/playlist\/(\d+)/)
+ if (pathMatch) return pathMatch[1]
+ break
+ }
+ case 'tencent': {
+ // https://y.qq.com/n/ryqq/playlist/12345.html
+ const qqMatch = url.pathname.match(/\/playlist\/(\d+)/)
+ if (qqMatch) return qqMatch[1]
+ break
+ }
+ case 'kugou': {
+ // Various kugou URL formats
+ const kgMatch = url.pathname.match(/(\d+)/)
+ if (kgMatch) return kgMatch[1]
+ break
+ }
+ case 'bilibili':
+ break
+ }
+ } catch {
+ // Not a URL, try to extract numbers
+ const numMatch = trimmed.match(/(\d{4,})/)
+ if (numMatch) return numMatch[1]
+ }
+
+ return null
+}
+
+export function usePlaylist() {
+ const { socket } = useSocketContext()
+ const [myPlaylists, setMyPlaylists] = useState>({
+ netease: [],
+ tencent: [],
+ kugou: [],
+ kugou_concept: [],
+ bilibili: [],
+ })
+ const [playlistsLoading, setPlaylistsLoading] = useState>({
+ netease: false,
+ tencent: false,
+ kugou: false,
+ kugou_concept: false,
+ bilibili: false,
+ })
+
+ // Paginated playlist tracks state
+ const [playlistTracks, setPlaylistTracks] = useState([])
+ const [playlistTotal, setPlaylistTotal] = useState(0)
+ const [hasMoreTracks, setHasMoreTracks] = useState(false)
+ const [tracksLoading, setTracksLoading] = useState(false)
+ const [loadingMore, setLoadingMore] = useState(false)
+
+ // Track current playlist context to prevent stale responses
+ const currentPlaylistRef = useRef<{ source: MusicSource; id: string; type?: 'playlist' | 'album' } | null>(null)
+ const offsetRef = useRef(0)
+ const loadingMoreRef = useRef(false)
+
+ useEffect(() => {
+ const onMyList = (data: { platform: MusicSource; playlists: Playlist[] }) => {
+ setMyPlaylists((prev) => ({ ...prev, [data.platform]: data.playlists }))
+ setPlaylistsLoading((prev) => ({ ...prev, [data.platform]: false }))
+ }
+
+ socket.on(EVENTS.PLAYLIST_MY_LIST, onMyList)
+ return () => {
+ socket.off(EVENTS.PLAYLIST_MY_LIST, onMyList)
+ }
+ }, [socket])
+
+ const fetchMyPlaylists = useCallback(
+ (platform: MusicSource) => {
+ setPlaylistsLoading((prev) => ({ ...prev, [platform]: true }))
+ socket.emit(EVENTS.PLAYLIST_GET_MY, { platform })
+ },
+ [socket],
+ )
+
+ /**
+ * Fetch the first page of a playlist's tracks.
+ * Resets all track state immediately to prevent stale data from flashing.
+ */
+ const fetchPlaylistTracks = useCallback(
+ async (
+ source: MusicSource,
+ playlistId: string,
+ trackCount?: number,
+ type: 'playlist' | 'album' = 'playlist',
+ ): Promise => {
+ // Reset state immediately — prevents flashing old data when switching playlists
+ setPlaylistTracks([])
+ setPlaylistTotal(0)
+ setHasMoreTracks(false)
+ setTracksLoading(true)
+ setLoadingMore(false)
+ loadingMoreRef.current = false
+
+ // Track current context for stale response detection
+ currentPlaylistRef.current = { source, id: playlistId, type }
+ offsetRef.current = 0
+
+ try {
+ const url = buildPlaylistUrl(source, playlistId, PAGE_SIZE, 0, {
+ total: trackCount,
+ roomId: useRoomStore.getState().room?.id,
+ type,
+ })
+ const res = await fetch(url, { credentials: 'include' })
+ if (!res.ok) {
+ setTracksLoading(false)
+ return []
+ }
+
+ // Stale response guard
+ const ctx = currentPlaylistRef.current
+ if (!ctx || ctx.source !== source || ctx.id !== playlistId) return []
+
+ const data = await res.json()
+ const tracks: Track[] = data.tracks ?? []
+ const total: number = data.total ?? tracks.length
+
+ setPlaylistTracks(tracks)
+ setPlaylistTotal(total)
+ setHasMoreTracks(data.hasMore ?? false)
+ offsetRef.current = tracks.length
+ setTracksLoading(false)
+ return tracks
+ } catch {
+ // Only update state if this is still the active playlist
+ const ctx = currentPlaylistRef.current
+ if (ctx && ctx.source === source && ctx.id === playlistId) {
+ setTracksLoading(false)
+ }
+ return []
+ }
+ },
+ [],
+ )
+
+ /**
+ * Load the next page of tracks for the current playlist.
+ * Uses a ref for synchronous dedup — prevents duplicate requests from fast scrolling
+ * even before React batches the state update.
+ */
+ const loadMoreTracks = useCallback(async () => {
+ const ctx = currentPlaylistRef.current
+ if (!ctx || loadingMoreRef.current || !hasMoreTracks) return
+
+ loadingMoreRef.current = true
+ setLoadingMore(true)
+
+ try {
+ const offset = offsetRef.current
+ const url = buildPlaylistUrl(ctx.source, ctx.id, PAGE_SIZE, offset, {
+ total: playlistTotal,
+ roomId: useRoomStore.getState().room?.id,
+ type: ctx.type,
+ })
+ const res = await fetch(url, { credentials: 'include' })
+ if (!res.ok) return
+
+ // Stale response guard — context might have changed while we were fetching
+ const currentCtx = currentPlaylistRef.current
+ if (!currentCtx || currentCtx.source !== ctx.source || currentCtx.id !== ctx.id) return
+
+ const data = await res.json()
+ const newTracks: Track[] = data.tracks ?? []
+
+ setPlaylistTracks((prev) => [...prev, ...newTracks])
+ setHasMoreTracks(data.hasMore ?? false)
+ offsetRef.current = offset + newTracks.length
+ } catch {
+ // Silently fail — user can scroll again to retry
+ } finally {
+ loadingMoreRef.current = false
+ setLoadingMore(false)
+ }
+ }, [hasMoreTracks, playlistTotal])
+
+ /**
+ * Fetch every page of the active playlist and return the complete track list.
+ * The regular detail view stays paginated; this is only used by "add all".
+ */
+ const fetchAllPlaylistTracks = useCallback(async (): Promise => {
+ const ctx = currentPlaylistRef.current
+ if (!ctx) return []
+ if (loadingMoreRef.current) throw new Error('Playlist tracks are already loading')
+
+ loadingMoreRef.current = true
+ setLoadingMore(true)
+
+ try {
+ const allTracks: Track[] = []
+ let offset = 0
+ let total = playlistTotal
+ let hasMore = true
+
+ while (hasMore) {
+ const url = buildPlaylistUrl(ctx.source, ctx.id, PAGE_SIZE, offset, {
+ total,
+ roomId: useRoomStore.getState().room?.id,
+ type: ctx.type,
+ })
+ const res = await fetch(url, { credentials: 'include' })
+ if (!res.ok) throw new Error(`Failed to load playlist page: ${res.status}`)
+
+ const currentCtx = currentPlaylistRef.current
+ if (
+ !currentCtx ||
+ currentCtx.source !== ctx.source ||
+ currentCtx.id !== ctx.id ||
+ currentCtx.type !== ctx.type
+ ) {
+ throw new Error('Active playlist changed while loading')
+ }
+
+ const data = await res.json()
+ const pageTracks: Track[] = data.tracks ?? []
+ total = data.total ?? total
+
+ if (pageTracks.length === 0 && data.hasMore) {
+ throw new Error('Playlist pagination returned an empty page')
+ }
+
+ allTracks.push(...pageTracks)
+ offset += pageTracks.length
+ hasMore = data.hasMore ?? offset < total
+
+ setPlaylistTracks([...allTracks])
+ setPlaylistTotal(total)
+ setHasMoreTracks(hasMore)
+ offsetRef.current = offset
+ }
+
+ return allTracks
+ } finally {
+ loadingMoreRef.current = false
+ setLoadingMore(false)
+ }
+ }, [playlistTotal])
+
+ const addTrackToQueue = useCallback(
+ (track: Track) => {
+ socket.emit(EVENTS.QUEUE_ADD, { track })
+ },
+ [socket],
+ )
+
+ const insertTrackAfterCurrent = useCallback(
+ (track: Track) => {
+ socket.emit(EVENTS.QUEUE_INSERT_AFTER_CURRENT, { track })
+ },
+ [socket],
+ )
+
+ const addBatchToQueue = useCallback(
+ (tracks: Track[], playlistName?: string) => {
+ for (let offset = 0; offset < tracks.length; offset += LIMITS.QUEUE_BATCH_MAX_SIZE) {
+ socket.emit(EVENTS.QUEUE_ADD_BATCH, {
+ tracks: tracks.slice(offset, offset + LIMITS.QUEUE_BATCH_MAX_SIZE),
+ playlistName,
+ })
+ }
+ },
+ [socket],
+ )
+
+ return {
+ myPlaylists,
+ playlistsLoading,
+ playlistTracks,
+ playlistTotal,
+ hasMoreTracks,
+ tracksLoading,
+ loadingMore,
+ fetchMyPlaylists,
+ fetchPlaylistTracks,
+ loadMoreTracks,
+ fetchAllPlaylistTracks,
+ addTrackToQueue,
+ insertTrackAfterCurrent,
+ addBatchToQueue,
+ }
+}
diff --git a/packages/client/src/hooks/useQueue.ts b/packages/client/src/hooks/useQueue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1c7f8a3080fa401f318a4df0ec4927ec338d53a4
--- /dev/null
+++ b/packages/client/src/hooks/useQueue.ts
@@ -0,0 +1,52 @@
+import { useCallback } from 'react'
+import { EVENTS, LIMITS, type BilibiliMetadataSource, type Track } from '@music-together/shared'
+import { useSocketContext } from '@/providers/socket-context'
+
+export function useQueue() {
+ const { socket } = useSocketContext()
+
+ const addTrack = useCallback((track: Track) => socket.emit(EVENTS.QUEUE_ADD, { track }), [socket])
+
+ const insertAfterCurrent = useCallback(
+ (track: Track) => socket.emit(EVENTS.QUEUE_INSERT_AFTER_CURRENT, { track }),
+ [socket],
+ )
+
+ const addBatchTracks = useCallback(
+ (tracks: Track[], playlistName?: string) => {
+ for (let offset = 0; offset < tracks.length; offset += LIMITS.QUEUE_BATCH_MAX_SIZE) {
+ socket.emit(EVENTS.QUEUE_ADD_BATCH, {
+ tracks: tracks.slice(offset, offset + LIMITS.QUEUE_BATCH_MAX_SIZE),
+ playlistName,
+ })
+ }
+ },
+ [socket],
+ )
+
+ const removeTrack = useCallback((trackId: string) => socket.emit(EVENTS.QUEUE_REMOVE, { trackId }), [socket])
+
+ const reorderTracks = useCallback((trackIds: string[]) => socket.emit(EVENTS.QUEUE_REORDER, { trackIds }), [socket])
+
+ const updateBilibiliMetadata = useCallback(
+ (
+ trackId: string,
+ metadata:
+ | { metadataSource: BilibiliMetadataSource; lyricId?: string; picId?: string; cover: string }
+ | { clearMetadata: true },
+ ) => socket.emit(EVENTS.QUEUE_UPDATE_METADATA, { trackId, ...metadata }),
+ [socket],
+ )
+
+ const clearQueue = useCallback(() => socket.emit(EVENTS.QUEUE_CLEAR), [socket])
+
+ return {
+ addTrack,
+ insertAfterCurrent,
+ addBatchTracks,
+ removeTrack,
+ reorderTracks,
+ updateBilibiliMetadata,
+ clearQueue,
+ }
+}
diff --git a/packages/client/src/hooks/useRecommendations.ts b/packages/client/src/hooks/useRecommendations.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f0e4b193c49619c35fa4a2dffc272d7cacdf3841
--- /dev/null
+++ b/packages/client/src/hooks/useRecommendations.ts
@@ -0,0 +1,128 @@
+import { SERVER_URL } from '@/lib/config'
+import type { PlatformRecommendation } from '@music-together/shared'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+
+function appendTencentRecommendationPages(
+ current: PlatformRecommendation[],
+ incoming: PlatformRecommendation[],
+): PlatformRecommendation[] {
+ const incomingTencent = incoming.find((item) => item.platform === 'tencent')
+ if (!incomingTencent) return current
+
+ const currentTencent = current.find((item) => item.platform === 'tencent')
+ if (!currentTencent) return [...current, incomingTencent]
+
+ const trackKeys = new Set()
+ const tracks = [...currentTencent.tracks, ...incomingTencent.tracks].filter((track) => {
+ const key = `${track.source}:${track.sourceId}`
+ if (trackKeys.has(key)) return false
+ trackKeys.add(key)
+ return true
+ })
+ const playlistKeys = new Set()
+ const playlists = [...(currentTencent.playlists ?? []), ...(incomingTencent.playlists ?? [])].filter((playlist) => {
+ const key = `${playlist.source}:${playlist.id}`
+ if (playlistKeys.has(key)) return false
+ playlistKeys.add(key)
+ return true
+ })
+ const mergedTencent: PlatformRecommendation = {
+ ...incomingTencent,
+ tracks,
+ playlists,
+ unavailableReason: tracks.length > 0 || playlists.length > 0 ? undefined : incomingTencent.unavailableReason,
+ }
+
+ return current.map((item) => (item.platform === 'tencent' ? mergedTencent : item))
+}
+
+export function useRecommendations(roomId?: string) {
+ const [recommendations, setRecommendations] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [loadingMore, setLoadingMore] = useState(false)
+ const [loaded, setLoaded] = useState(false)
+ const abortRef = useRef(null)
+ const tencentPaginationRef = useRef(undefined)
+
+ useEffect(
+ () => () => {
+ abortRef.current?.abort()
+ abortRef.current = null
+ },
+ [],
+ )
+
+ const request = useCallback(
+ (append: boolean) => {
+ abortRef.current?.abort()
+ if (!roomId) {
+ abortRef.current = null
+ tencentPaginationRef.current = undefined
+ setRecommendations([])
+ setLoading(false)
+ setLoadingMore(false)
+ setLoaded(true)
+ return
+ }
+
+ const controller = new AbortController()
+ abortRef.current = controller
+ const pagination = tencentPaginationRef.current
+ const params = new URLSearchParams({
+ roomId,
+ limit: '50',
+ radarPage: String(pagination?.tracks?.nextPage ?? 1),
+ playlistOffset: String(pagination?.playlists?.nextOffset ?? 0),
+ refresh: String(Date.now()),
+ })
+ setLoading(!append)
+ setLoadingMore(append)
+
+ fetch(`${SERVER_URL}/api/music/recommendations?${params}`, {
+ signal: controller.signal,
+ credentials: 'include',
+ })
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`Recommendations failed: ${response.status}`)
+ return response.json() as Promise<{ recommendations?: PlatformRecommendation[] }>
+ })
+ .then((data) => {
+ const nextRecommendations = data.recommendations ?? []
+ const tencent = nextRecommendations.find((item) => item.platform === 'tencent')
+ tencentPaginationRef.current = tencent?.pagination
+ setRecommendations((current) =>
+ append ? appendTencentRecommendationPages(current, nextRecommendations) : nextRecommendations,
+ )
+ })
+ .catch((err) => {
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ toast.error('加载平台推荐失败,请稍后重试')
+ })
+ .finally(() => {
+ if (abortRef.current === controller) {
+ abortRef.current = null
+ setLoading(false)
+ setLoadingMore(false)
+ setLoaded(true)
+ }
+ })
+ },
+ [roomId],
+ )
+
+ const load = useCallback(() => request(false), [request])
+ const loadMore = useCallback(() => request(true), [request])
+
+ const reset = useCallback(() => {
+ abortRef.current?.abort()
+ abortRef.current = null
+ tencentPaginationRef.current = undefined
+ setRecommendations([])
+ setLoading(false)
+ setLoadingMore(false)
+ setLoaded(false)
+ }, [])
+
+ return { recommendations, loading, loadingMore, loaded, load, loadMore, reset }
+}
diff --git a/packages/client/src/hooks/useRoom.ts b/packages/client/src/hooks/useRoom.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8bb37f6344a9abca4387cfd6d36227b3ddc99aea
--- /dev/null
+++ b/packages/client/src/hooks/useRoom.ts
@@ -0,0 +1,59 @@
+import { useSocketContext } from '@/providers/socket-context'
+import { resetAllRoomState } from '@/lib/resetStores'
+import { storage } from '@/lib/storage'
+import { EVENTS } from '@music-together/shared'
+import { useCallback } from 'react'
+import { useRoomStore } from '@/stores/roomStore'
+
+import { useRoomState } from './room/useRoomState'
+import { useChatSync } from './room/useChatSync'
+import { useQueueSync } from './room/useQueueSync'
+import { useAuthSync } from './room/useAuthSync'
+import { useConnectionGuard } from './room/useConnectionGuard'
+
+/**
+ * Composition hook that wires up all room-related socket event listeners.
+ * Sub-hooks are split by responsibility for maintainability.
+ * The public API (leaveRoom, updateSettings, setUserRole) remains unchanged.
+ */
+export function useRoom() {
+ const { socket } = useSocketContext()
+
+ // Sub-hooks handle their own event listeners
+ useRoomState()
+ useChatSync()
+ useQueueSync()
+ useAuthSync()
+ useConnectionGuard()
+
+ const leaveRoom = useCallback(() => {
+ const roomId = useRoomStore.getState().room?.id
+ if (roomId) storage.clearRejoinToken(roomId)
+ socket.emit(EVENTS.ROOM_LEAVE)
+ resetAllRoomState()
+ }, [socket])
+
+ const updateSettings = useCallback(
+ (settings: {
+ name?: string
+ password?: string | null
+ audioQuality?: import('@music-together/shared').AudioQuality
+ hidden?: boolean
+ permanent?: boolean
+ allowTemporaryAdminTrackRemoval?: boolean
+ allowTemporaryAdminQueueClear?: boolean
+ }) => {
+ socket.emit(EVENTS.ROOM_SETTINGS, settings)
+ },
+ [socket],
+ )
+
+ const setUserRole = useCallback(
+ (userId: string, role: 'admin' | 'member') => {
+ socket.emit(EVENTS.ROOM_SET_ROLE, { userId, role })
+ },
+ [socket],
+ )
+
+ return { leaveRoom, updateSettings, setUserRole }
+}
diff --git a/packages/client/src/hooks/useSearch.ts b/packages/client/src/hooks/useSearch.ts
new file mode 100644
index 0000000000000000000000000000000000000000..35551b78c70f0342728587c290d96e642dbbaa10
--- /dev/null
+++ b/packages/client/src/hooks/useSearch.ts
@@ -0,0 +1,138 @@
+import { SERVER_URL } from '@/lib/config'
+import type { MusicSource, Playlist, Track } from '@music-together/shared'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+
+const PAGE_SIZE = 20
+
+type SearchResult = Track | Playlist
+
+/**
+ * 搜索逻辑 hook — 管理搜索/翻页/abort/竞态保护。
+ * 从 SearchDialog 中提取,使 UI 组件只关注渲染。
+ */
+export function useSearch(source: MusicSource, type: 'song' | 'album' | 'playlist' = 'song', roomId?: string) {
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [loadingMore, setLoadingMore] = useState(false)
+ const [page, setPage] = useState(1)
+ const [hasMore, setHasMore] = useState(false)
+ const [hasSearched, setHasSearched] = useState(false)
+ const abortRef = useRef(null)
+ const loadMoreAbortRef = useRef(null)
+ const searchIdRef = useRef(0)
+ const lastKeywordRef = useRef('')
+
+ // Cancel any in-flight requests on unmount
+ useEffect(() => {
+ return () => {
+ abortRef.current?.abort()
+ loadMoreAbortRef.current?.abort()
+ }
+ }, [])
+
+ const fetchPage = useCallback(
+ async (
+ searchSource: MusicSource,
+ searchKeyword: string,
+ searchPage: number,
+ signal: AbortSignal,
+ searchType: 'song' | 'album' | 'playlist',
+ ): Promise<{ tracks: SearchResult[]; hasMore: boolean }> => {
+ const params = new URLSearchParams({
+ source: searchSource,
+ keyword: searchKeyword,
+ limit: String(PAGE_SIZE),
+ page: String(searchPage),
+ type: searchType,
+ })
+ if (roomId) params.set('roomId', roomId)
+ const res = await fetch(`${SERVER_URL}/api/music/search?${params.toString()}`, { signal, credentials: 'include' })
+ if (!res.ok) throw new Error('Search failed')
+ const data = await res.json()
+ const tracks = data.tracks || []
+ return { tracks, hasMore: data.hasMore ?? tracks.length >= PAGE_SIZE }
+ },
+ [roomId],
+ )
+
+ const search = useCallback(
+ (keyword: string) => {
+ const trimmed = keyword.trim()
+ if (!trimmed) return
+
+ abortRef.current?.abort()
+ loadMoreAbortRef.current?.abort()
+ const controller = new AbortController()
+ abortRef.current = controller
+ const currentSearchId = ++searchIdRef.current
+ lastKeywordRef.current = trimmed
+
+ setLoading(true)
+ setHasSearched(true)
+ fetchPage(source, trimmed, 1, controller.signal, type)
+ .then((data) => {
+ if (searchIdRef.current !== currentSearchId) return
+ setResults(data.tracks)
+ setPage(1)
+ setHasMore(data.hasMore)
+ })
+ .catch((err) => {
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ if (searchIdRef.current !== currentSearchId) return
+ toast.error('搜索失败,请重试')
+ setResults([])
+ setHasMore(false)
+ })
+ .finally(() => {
+ if (searchIdRef.current === currentSearchId) {
+ setLoading(false)
+ }
+ })
+ },
+ [source, type, fetchPage],
+ )
+
+ const loadMore = useCallback(() => {
+ if (loadingMore) return
+ loadMoreAbortRef.current?.abort()
+ const controller = new AbortController()
+ loadMoreAbortRef.current = controller
+ const currentSearchId = searchIdRef.current
+ const nextPage = page + 1
+
+ setLoadingMore(true)
+ fetchPage(source, lastKeywordRef.current, nextPage, controller.signal, type)
+ .then((data) => {
+ if (searchIdRef.current !== currentSearchId) return
+ setResults((prev) => [...prev, ...data.tracks])
+ setPage(nextPage)
+ setHasMore(data.hasMore)
+ })
+ .catch((err) => {
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ if (searchIdRef.current !== currentSearchId) return
+ toast.error('加载失败,请重试')
+ })
+ .finally(() => {
+ if (searchIdRef.current === currentSearchId) {
+ setLoadingMore(false)
+ }
+ })
+ }, [loadingMore, page, source, type, fetchPage])
+
+ const resetState = useCallback(() => {
+ abortRef.current?.abort()
+ loadMoreAbortRef.current?.abort()
+ searchIdRef.current++
+ setResults([])
+ setPage(1)
+ setHasMore(false)
+ setHasSearched(false)
+ setLoading(false)
+ setLoadingMore(false)
+ lastKeywordRef.current = ''
+ }, [])
+
+ return { results, loading, loadingMore, hasMore, hasSearched, page, search, loadMore, resetState }
+}
diff --git a/packages/client/src/hooks/useSocketEvent.ts b/packages/client/src/hooks/useSocketEvent.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c3abe170fd70f53f448a25f20a6ed540857ace8d
--- /dev/null
+++ b/packages/client/src/hooks/useSocketEvent.ts
@@ -0,0 +1,26 @@
+import type { ServerToClientEvents } from '@music-together/shared'
+import { useSocketContext } from '@/providers/socket-context'
+import { useEffect, useEffectEvent } from 'react'
+
+/**
+ * Generic hook to subscribe to a typed Socket.IO event.
+ * Automatically handles on/off lifecycle and keeps the handler reference stable.
+ *
+ * @example
+ * useSocketEvent(EVENTS.ROOM_STATE, useCallback((room) => { ... }, []))
+ */
+export function useSocketEvent(event: E, handler: ServerToClientEvents[E]) {
+ const { socket } = useSocketContext()
+ const onEvent = useEffectEvent((...args: unknown[]) => {
+ ;(handler as unknown as (...eventArgs: unknown[]) => void)(...args)
+ })
+
+ useEffect(() => {
+ const wrapper = onEvent as unknown as ServerToClientEvents[E]
+
+ socket.on(event, wrapper as never)
+ return () => {
+ socket.off(event, wrapper as never)
+ }
+ }, [socket, event])
+}
diff --git a/packages/client/src/hooks/useVersionCheck.ts b/packages/client/src/hooks/useVersionCheck.ts
new file mode 100644
index 0000000000000000000000000000000000000000..77f8608ad782071f97cd06e14764513b6ac6ade5
--- /dev/null
+++ b/packages/client/src/hooks/useVersionCheck.ts
@@ -0,0 +1,29 @@
+import { SERVER_URL } from '@/lib/config'
+import { useEffect, useState } from 'react'
+
+/**
+ * Check once on mount whether the server is running a newer version
+ * than the client was built with. Returns `true` when an update is available.
+ */
+export function useVersionCheck() {
+ const [hasUpdate, setHasUpdate] = useState(false)
+
+ useEffect(() => {
+ const controller = new AbortController()
+
+ fetch(`${SERVER_URL}/api/version`, { signal: controller.signal })
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data: { version: string } | null) => {
+ if (data?.version && data.version !== __APP_VERSION__) {
+ setHasUpdate(true)
+ }
+ })
+ .catch(() => {
+ // Silently ignore — network errors, server down, etc.
+ })
+
+ return () => controller.abort()
+ }, [])
+
+ return hasUpdate
+}
diff --git a/packages/client/src/hooks/useVote.ts b/packages/client/src/hooks/useVote.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b521a6568cd78ea87a2f544c8cdc1dcfa29ded14
--- /dev/null
+++ b/packages/client/src/hooks/useVote.ts
@@ -0,0 +1,90 @@
+import { useCallback, useEffect, useState } from 'react'
+import { EVENTS } from '@music-together/shared'
+import type { PlayMode, VoteAction, VoteState } from '@music-together/shared'
+import { useSocketContext } from '@/providers/socket-context'
+import { useSocketEvent } from './useSocketEvent'
+import { toast } from 'sonner'
+
+const ACTION_LABELS: Record = {
+ pause: '暂停',
+ resume: '播放',
+ next: '下一首',
+ prev: '上一首',
+ 'set-mode': '切换播放模式',
+ 'play-track': '指定播放',
+ 'remove-track': '投票移除',
+}
+
+const PLAY_MODE_LABELS: Record = {
+ sequential: '顺序播放',
+ 'loop-all': '列表循环',
+ 'loop-one': '单曲循环',
+ shuffle: '随机播放',
+}
+
+/** Get a human-readable label for a vote action, including payload context */
+export function getVoteActionLabel(action: VoteAction, payload?: Record): string {
+ if (action === 'set-mode' && payload?.mode) {
+ const modeLabel = PLAY_MODE_LABELS[payload.mode as PlayMode] ?? payload.mode
+ return `切换为${modeLabel}`
+ }
+ if (action === 'play-track' && payload?.trackTitle) {
+ return `播放「${payload.trackTitle}」`
+ }
+ if (action === 'remove-track' && payload?.trackTitle) {
+ return `移除「${payload.trackTitle}」`
+ }
+ return ACTION_LABELS[action]
+}
+
+export function useVote() {
+ const { socket } = useSocketContext()
+ const [activeVote, setActiveVote] = useState(null)
+
+ useSocketEvent(
+ EVENTS.VOTE_STARTED,
+ useCallback((vote: VoteState) => {
+ setActiveVote(vote)
+ }, []),
+ )
+
+ useSocketEvent(
+ EVENTS.VOTE_RESULT,
+ useCallback((data: { passed: boolean; action: VoteAction; reason?: string }) => {
+ setActiveVote(null)
+ const label = ACTION_LABELS[data.action]
+ if (data.passed) {
+ toast.success(`投票通过:${label}`)
+ } else {
+ const reasonText = data.reason === 'host_veto' ? '(房主否决)' : data.reason === 'timeout' ? '(超时)' : ''
+ toast.error(`投票未通过:${label}${reasonText}`)
+ }
+ }, []),
+ )
+
+ // Clear active vote on disconnect
+ useEffect(() => {
+ const onDisconnect = () => setActiveVote(null)
+ socket.on('disconnect', onDisconnect)
+ return () => {
+ socket.off('disconnect', onDisconnect)
+ }
+ }, [socket])
+
+ const startVote = useCallback(
+ (action: VoteAction, payload?: Record) => {
+ socket.emit(EVENTS.VOTE_START, { action, payload })
+ toast.info(`已发起投票:${getVoteActionLabel(action, payload)}`)
+ },
+ [socket],
+ )
+
+ const castVote = useCallback(
+ (approve: boolean) => {
+ socket.emit(EVENTS.VOTE_CAST, { approve })
+ },
+ [socket],
+ )
+
+ return { activeVote, startVote, castVote }
+}
diff --git a/packages/client/src/index.css b/packages/client/src/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..e25df0203429c84c261e87d0db3f4a2dafb11ee4
--- /dev/null
+++ b/packages/client/src/index.css
@@ -0,0 +1,704 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+@import 'shadcn/tailwind.css';
+@import '@fontsource/plus-jakarta-sans/300.css';
+@import '@fontsource/plus-jakarta-sans/400.css';
+@import '@fontsource/plus-jakarta-sans/500.css';
+@import '@fontsource/plus-jakarta-sans/600.css';
+@import '@fontsource/plus-jakarta-sans/700.css';
+
+/* Plus Jakarta Sans is bundled locally by Vite. */
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --animate-marquee: marquee 10s ease-in-out infinite;
+}
+
+/* ===== Soft Zinc palette — dark, subtle blue tint ===== */
+:root {
+ color-scheme: dark;
+ --radius: 0.875rem;
+ --background: #080b12;
+ --foreground: #f7f1e8;
+ --card: #141b27;
+ --card-foreground: #f7f1e8;
+ --popover: #172031;
+ --popover-foreground: #f7f1e8;
+ --primary: #e3b76c;
+ --primary-foreground: #17110a;
+ --secondary: #222c3a;
+ --secondary-foreground: #f7f1e8;
+ --muted: #1b2330;
+ --muted-foreground: #a8a098;
+ --accent: #2a3546;
+ --accent-foreground: #f7f1e8;
+ --destructive: #e76f73;
+ --destructive-foreground: #fff7f7;
+ --border: rgb(240 223 195 / 13%);
+ --input: rgb(240 223 195 / 16%);
+ --ring: #e3b76c;
+ --chart-1: #e3b76c;
+ --chart-2: #74b8a5;
+ --chart-3: #e76f73;
+ --chart-4: #76a7ff;
+ --chart-5: #f3d79e;
+ --sidebar: #111823;
+ --sidebar-foreground: #f7f1e8;
+ --sidebar-primary: #e3b76c;
+ --sidebar-primary-foreground: #17110a;
+ --sidebar-accent: #222c3a;
+ --sidebar-accent-foreground: #f7f1e8;
+ --sidebar-border: rgb(240 223 195 / 13%);
+ --sidebar-ring: #e3b76c;
+ --theme-accent-rgb: 227 183 108;
+ --theme-highlight-rgb: 243 215 158;
+ --theme-secondary-rgb: 116 184 165;
+ --theme-border-rgb: 240 223 195;
+}
+
+:root[data-color-preset='ocean'] {
+ --background: #071417;
+ --foreground: #ecfbf8;
+ --card: #102429;
+ --card-foreground: #ecfbf8;
+ --popover: #122a30;
+ --popover-foreground: #ecfbf8;
+ --primary: #62d0c1;
+ --primary-foreground: #07201d;
+ --secondary: #1b373d;
+ --secondary-foreground: #e3f5f2;
+ --muted: #152b30;
+ --muted-foreground: #a0bab5;
+ --accent: #1e454a;
+ --accent-foreground: #ecfbf8;
+ --border: rgb(184 244 235 / 14%);
+ --input: rgb(184 244 235 / 17%);
+ --ring: #62d0c1;
+ --chart-1: #62d0c1;
+ --chart-2: #8ab9ff;
+ --chart-3: #f3b06d;
+ --chart-4: #e88fba;
+ --chart-5: #c3ef9d;
+ --sidebar: #0d1e23;
+ --sidebar-foreground: #ecfbf8;
+ --sidebar-primary: #62d0c1;
+ --sidebar-primary-foreground: #07201d;
+ --sidebar-accent: #1b373d;
+ --sidebar-accent-foreground: #ecfbf8;
+ --sidebar-border: rgb(184 244 235 / 14%);
+ --sidebar-ring: #62d0c1;
+ --theme-accent-rgb: 98 208 193;
+ --theme-highlight-rgb: 181 240 227;
+ --theme-secondary-rgb: 138 185 255;
+ --theme-border-rgb: 184 244 235;
+}
+
+:root[data-color-preset='rose'] {
+ --background: #170c15;
+ --foreground: #fff1f6;
+ --card: #2a1523;
+ --card-foreground: #fff1f6;
+ --popover: #321827;
+ --popover-foreground: #fff1f6;
+ --primary: #f09aba;
+ --primary-foreground: #2a0e1b;
+ --secondary: #432032;
+ --secondary-foreground: #fff1f6;
+ --muted: #331a29;
+ --muted-foreground: #c4a4b2;
+ --accent: #54283d;
+ --accent-foreground: #fff1f6;
+ --border: rgb(255 211 228 / 14%);
+ --input: rgb(255 211 228 / 17%);
+ --ring: #f09aba;
+ --chart-1: #f09aba;
+ --chart-2: #92cfbe;
+ --chart-3: #f4c176;
+ --chart-4: #9db4ff;
+ --chart-5: #d1a0ec;
+ --sidebar: #21111d;
+ --sidebar-foreground: #fff1f6;
+ --sidebar-primary: #f09aba;
+ --sidebar-primary-foreground: #2a0e1b;
+ --sidebar-accent: #432032;
+ --sidebar-accent-foreground: #fff1f6;
+ --sidebar-border: rgb(255 211 228 / 14%);
+ --sidebar-ring: #f09aba;
+ --theme-accent-rgb: 240 154 186;
+ --theme-highlight-rgb: 255 211 227;
+ --theme-secondary-rgb: 146 207 190;
+ --theme-border-rgb: 255 211 228;
+}
+
+:root[data-color-preset='violet'] {
+ --background: #0f0b1b;
+ --foreground: #f5f1ff;
+ --card: #1e1830;
+ --card-foreground: #f5f1ff;
+ --popover: #261d3d;
+ --popover-foreground: #f5f1ff;
+ --primary: #bba7ff;
+ --primary-foreground: #1d1337;
+ --secondary: #31274d;
+ --secondary-foreground: #f5f1ff;
+ --muted: #28203d;
+ --muted-foreground: #b7adc9;
+ --accent: #41315f;
+ --accent-foreground: #f5f1ff;
+ --border: rgb(223 211 255 / 14%);
+ --input: rgb(223 211 255 / 17%);
+ --ring: #bba7ff;
+ --chart-1: #bba7ff;
+ --chart-2: #78cbb8;
+ --chart-3: #f0a7b8;
+ --chart-4: #8fb8ff;
+ --chart-5: #efcc7f;
+ --sidebar: #18122a;
+ --sidebar-foreground: #f5f1ff;
+ --sidebar-primary: #bba7ff;
+ --sidebar-primary-foreground: #1d1337;
+ --sidebar-accent: #31274d;
+ --sidebar-accent-foreground: #f5f1ff;
+ --sidebar-border: rgb(223 211 255 / 14%);
+ --sidebar-ring: #bba7ff;
+ --theme-accent-rgb: 187 167 255;
+ --theme-highlight-rgb: 222 212 255;
+ --theme-secondary-rgb: 120 203 184;
+ --theme-border-rgb: 223 211 255;
+}
+
+:root[data-color-preset='sunset'] {
+ --background: #1a0d0b;
+ --foreground: #fff4ec;
+ --card: #2c1818;
+ --card-foreground: #fff4ec;
+ --popover: #361c1a;
+ --popover-foreground: #fff4ec;
+ --primary: #ff9d68;
+ --primary-foreground: #321308;
+ --secondary: #4a2922;
+ --secondary-foreground: #fff4ec;
+ --muted: #351e1b;
+ --muted-foreground: #c9aaa0;
+ --accent: #613025;
+ --accent-foreground: #fff4ec;
+ --border: rgb(255 220 198 / 14%);
+ --input: rgb(255 220 198 / 17%);
+ --ring: #ff9d68;
+ --sidebar: #241312;
+ --sidebar-foreground: #fff4ec;
+ --sidebar-primary: #ff9d68;
+ --sidebar-primary-foreground: #321308;
+ --sidebar-accent: #4a2922;
+ --sidebar-accent-foreground: #fff4ec;
+ --sidebar-border: rgb(255 220 198 / 14%);
+ --sidebar-ring: #ff9d68;
+ --theme-accent-rgb: 255 157 104;
+ --theme-highlight-rgb: 255 220 198;
+ --theme-secondary-rgb: 244 190 112;
+ --theme-border-rgb: 255 220 198;
+}
+
+:root[data-color-preset='mint'] {
+ --background: #081512;
+ --foreground: #effff8;
+ --card: #122620;
+ --card-foreground: #effff8;
+ --popover: #173229;
+ --popover-foreground: #effff8;
+ --primary: #a8e06f;
+ --primary-foreground: #142008;
+ --secondary: #244537;
+ --secondary-foreground: #effff8;
+ --muted: #1a3329;
+ --muted-foreground: #a8c4b5;
+ --accent: #2f5944;
+ --accent-foreground: #effff8;
+ --border: rgb(208 255 224 / 14%);
+ --input: rgb(208 255 224 / 17%);
+ --ring: #a8e06f;
+ --sidebar: #0e211a;
+ --sidebar-foreground: #effff8;
+ --sidebar-primary: #a8e06f;
+ --sidebar-primary-foreground: #142008;
+ --sidebar-accent: #244537;
+ --sidebar-accent-foreground: #effff8;
+ --sidebar-border: rgb(208 255 224 / 14%);
+ --sidebar-ring: #a8e06f;
+ --theme-accent-rgb: 168 224 111;
+ --theme-highlight-rgb: 208 255 224;
+ --theme-secondary-rgb: 98 207 178;
+ --theme-border-rgb: 208 255 224;
+}
+
+:root[data-color-preset='mono'] {
+ --background: #101112;
+ --foreground: #f5f5f5;
+ --card: #1d1f21;
+ --card-foreground: #f5f5f5;
+ --popover: #25282b;
+ --popover-foreground: #f5f5f5;
+ --primary: #e5e7eb;
+ --primary-foreground: #151618;
+ --secondary: #34373b;
+ --secondary-foreground: #f5f5f5;
+ --muted: #292c2f;
+ --muted-foreground: #a9adb2;
+ --accent: #454a50;
+ --accent-foreground: #ffffff;
+ --border: rgb(245 245 245 / 15%);
+ --input: rgb(245 245 245 / 18%);
+ --ring: #e5e7eb;
+ --sidebar: #181a1c;
+ --sidebar-foreground: #f5f5f5;
+ --sidebar-primary: #e5e7eb;
+ --sidebar-primary-foreground: #151618;
+ --sidebar-accent: #34373b;
+ --sidebar-accent-foreground: #f5f5f5;
+ --sidebar-border: rgb(245 245 245 / 15%);
+ --sidebar-ring: #e5e7eb;
+ --theme-accent-rgb: 229 231 235;
+ --theme-highlight-rgb: 245 245 245;
+ --theme-secondary-rgb: 164 174 184;
+ --theme-border-rgb: 245 245 245;
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground antialiased;
+ font-family:
+ 'Plus Jakarta Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ }
+}
+
+@layer components {
+ .mt-page-surface {
+ position: relative;
+ isolation: isolate;
+ background-color: var(--background);
+ }
+
+ .mt-page-surface::before {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ content: '';
+ opacity: 0.18;
+ background-image:
+ linear-gradient(rgb(255 255 255 / 3%) 1px, transparent 1px),
+ linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px);
+ background-size: 42px 42px;
+ mask-image: linear-gradient(to bottom, black, transparent 76%);
+ }
+
+ .mt-home-page::after {
+ position: fixed;
+ inset: -20%;
+ z-index: 0;
+ pointer-events: none;
+ content: '';
+ opacity: 0.42;
+ background:
+ linear-gradient(118deg, transparent 12%, rgb(var(--theme-accent-rgb) / 12%) 38%, transparent 59%),
+ linear-gradient(286deg, transparent 25%, rgb(var(--theme-secondary-rgb) / 10%) 48%, transparent 72%),
+ linear-gradient(180deg, transparent 20%, rgb(var(--theme-highlight-rgb) / 4%) 54%, transparent 84%);
+ background-size: 170% 170%, 160% 160%, 100% 100%;
+ background-position: 0% 20%, 100% 70%, center;
+ filter: blur(28px);
+ animation: mt-home-light-drift 24s ease-in-out infinite alternate;
+ }
+
+ .mt-page-surface > :not(.mt-global-background) {
+ position: relative;
+ z-index: 1;
+ }
+
+ .mt-global-background {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ background-position: center;
+ background-size: cover;
+ filter: saturate(0.85) brightness(var(--mt-background-brightness, 0.6));
+ }
+
+ .mt-global-background-glass {
+ transform: scale(1.03);
+ }
+
+ .mt-global-background::before {
+ position: absolute;
+ inset: 0;
+ background: var(--mt-auto-tint, transparent);
+ content: '';
+ mix-blend-mode: soft-light;
+ opacity: 0.28;
+ }
+
+ .mt-global-background::after {
+ position: absolute;
+ inset: 0;
+ background-color: rgb(8 11 18 / 54%);
+ content: '';
+ }
+
+ .mt-global-background-glass::after {
+ backdrop-filter: blur(14px);
+ }
+
+ .mt-card,
+ .mt-card-strong {
+ position: relative;
+ overflow: hidden;
+ border: 1px solid rgb(var(--theme-border-rgb) / 13%);
+ background-color: rgb(20 27 39 / 94%);
+ box-shadow:
+ 0 16px 44px rgb(0 0 0 / 24%),
+ inset 0 1px 0 rgb(255 255 255 / 5%);
+ backdrop-filter: blur(18px);
+ transition:
+ border-color 180ms ease,
+ box-shadow 180ms ease;
+ }
+
+ .mt-card::after,
+ .mt-card-strong::after {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ border-radius: inherit;
+ background: linear-gradient(
+ 135deg,
+ rgb(var(--theme-highlight-rgb) / 14%),
+ transparent 34%,
+ transparent 68%,
+ rgb(var(--theme-secondary-rgb) / 10%)
+ );
+ content: '';
+ opacity: 0;
+ transition: opacity 180ms ease;
+ }
+
+ .mt-card:hover::after,
+ .mt-card-strong:hover::after {
+ opacity: 1;
+ }
+
+ .mt-card:hover,
+ .mt-card-strong:hover {
+ border-color: rgb(var(--theme-highlight-rgb) / 28%);
+ box-shadow:
+ 0 20px 52px rgb(0 0 0 / 30%),
+ 0 0 28px rgb(var(--theme-accent-rgb) / 7%),
+ inset 0 1px 0 rgb(255 255 255 / 7%);
+ }
+
+ .mt-card > *,
+ .mt-card-strong > * {
+ position: relative;
+ z-index: 1;
+ }
+
+ .mt-card-strong {
+ background-color: rgb(28 36 50 / 96%);
+ box-shadow:
+ 0 24px 64px rgb(0 0 0 / 34%),
+ inset 0 1px 0 rgb(255 255 255 / 6%);
+ }
+
+ /* Homepage surfaces use a lighter glass treatment so the shared background remains visible. */
+ .mt-home-page .mt-card,
+ .mt-home-page .mt-card-strong {
+ border-color: rgb(var(--theme-border-rgb) / 20%);
+ background-color: color-mix(in srgb, var(--card) 58%, transparent);
+ backdrop-filter: blur(24px) saturate(1.18);
+ box-shadow:
+ 0 18px 48px rgb(0 0 0 / 22%),
+ inset 0 1px 0 rgb(255 255 255 / 10%),
+ inset 0 0 0 1px rgb(var(--theme-highlight-rgb) / 4%);
+ }
+
+ .mt-home-page .mt-card:hover,
+ .mt-home-page .mt-card-strong:hover {
+ border-color: rgb(var(--theme-highlight-rgb) / 34%);
+ box-shadow:
+ 0 22px 56px rgb(0 0 0 / 28%),
+ 0 0 32px rgb(var(--theme-accent-rgb) / 10%),
+ inset 0 1px 0 rgb(255 255 255 / 13%);
+ }
+
+ .mt-icon-box {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgb(var(--theme-highlight-rgb) / 17%);
+ border-radius: 1rem;
+ background-color: rgb(var(--theme-accent-rgb) / 8%);
+ color: rgb(var(--theme-highlight-rgb));
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%);
+ }
+
+ .mt-hero {
+ position: relative;
+ display: grid;
+ min-height: 240px;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid rgb(var(--theme-border-rgb) / 13%);
+ border-radius: 2rem;
+ background:
+ radial-gradient(circle at 10% 42%, rgb(var(--theme-accent-rgb) / 22%), transparent 42%),
+ radial-gradient(circle at 90% 38%, rgb(var(--theme-secondary-rgb) / 20%), transparent 44%),
+ linear-gradient(180deg, rgb(255 255 255 / 6%), rgb(255 255 255 / 2%)),
+ repeating-linear-gradient(90deg, transparent 0 32px, rgb(255 255 255 / 3%) 33px, transparent 34px),
+ rgb(20 27 39 / 96%);
+ box-shadow:
+ 0 24px 64px rgb(0 0 0 / 30%),
+ inset 0 1px 0 rgb(255 255 255 / 7%);
+ }
+
+ .mt-home-page .mt-hero {
+ border-color: rgb(var(--theme-border-rgb) / 24%);
+ background:
+ linear-gradient(118deg, rgb(var(--theme-accent-rgb) / 12%), transparent 42%),
+ linear-gradient(300deg, rgb(var(--theme-secondary-rgb) / 10%), transparent 48%),
+ color-mix(in srgb, var(--card) 52%, transparent);
+ backdrop-filter: blur(26px) saturate(1.2);
+ }
+
+ .mt-home-page > header {
+ border-color: rgb(var(--theme-border-rgb) / 20%);
+ background-color: color-mix(in srgb, var(--background) 54%, transparent);
+ backdrop-filter: blur(22px) saturate(1.15);
+ }
+
+ .mt-hero::before {
+ position: absolute;
+ width: min(760px, 96vw);
+ aspect-ratio: 1;
+ border: 1px solid rgb(var(--theme-highlight-rgb) / 9%);
+ border-radius: 999px;
+ box-shadow:
+ inset 0 0 0 46px rgb(var(--theme-highlight-rgb) / 1.8%),
+ inset 0 0 0 90px rgb(var(--theme-highlight-rgb) / 1.2%),
+ inset 0 0 0 140px rgb(var(--theme-highlight-rgb) / 0.8%),
+ 0 0 90px rgb(var(--theme-accent-rgb) / 8%);
+ content: '';
+ transform: translateY(24%);
+ rotate: 0deg;
+ animation: mt-slow-disc 18s linear infinite;
+ }
+
+ .mt-hero::after {
+ position: absolute;
+ inset: 1px;
+ border-radius: inherit;
+ background:
+ linear-gradient(110deg, transparent 0 34%, rgb(var(--theme-highlight-rgb) / 12%) 46%, transparent 58% 100%),
+ repeating-linear-gradient(90deg, transparent 0 34px, rgb(255 255 255 / 4.5%) 35px, transparent 36px);
+ content: '';
+ opacity: 0.34;
+ mix-blend-mode: screen;
+ pointer-events: none;
+ transform: translateX(-18%);
+ animation: mt-hero-sheen 8s ease-in-out infinite;
+ }
+
+ .mt-hero > * {
+ position: relative;
+ z-index: 1;
+ }
+
+ .mt-hero h1 {
+ font-size: clamp(2rem, 5vw, 3.5rem);
+ line-height: 1.15;
+ }
+
+ .mt-player-stage {
+ position: relative;
+ overflow: hidden;
+ border: 1px solid rgb(var(--theme-border-rgb) / 13%);
+ border-radius: 1.5rem;
+ background-color: rgb(6 9 15 / 82%);
+ box-shadow:
+ 0 24px 64px rgb(0 0 0 / 34%),
+ inset 0 1px 0 rgb(255 255 255 / 6%);
+ }
+
+ .mt-player-stage::before {
+ position: absolute;
+ inset: 10%;
+ z-index: 0;
+ pointer-events: none;
+ border: 1px solid rgb(var(--theme-highlight-rgb) / 8%);
+ border-radius: 999px;
+ box-shadow: inset 0 0 0 72px rgb(var(--theme-highlight-rgb) / 1.2%);
+ content: '';
+ }
+
+ .mt-player-stage > * {
+ position: relative;
+ z-index: 1;
+ }
+
+ .mt-soft-divider {
+ height: 1px;
+ background-color: rgb(var(--theme-border-rgb) / 16%);
+ }
+
+ @keyframes mt-hero-sheen {
+ 0%,
+ 100% {
+ opacity: 0.18;
+ transform: translateX(-24%);
+ }
+
+ 48% {
+ opacity: 0.42;
+ transform: translateX(18%);
+ }
+ }
+
+ @keyframes mt-slow-disc {
+ to {
+ rotate: 360deg;
+ }
+ }
+
+ @keyframes mt-home-light-drift {
+ 0% {
+ background-position: 0% 20%, 100% 70%, center;
+ transform: rotate(-2deg) scale(1);
+ }
+
+ 100% {
+ background-position: 100% 55%, 0% 35%, center;
+ transform: rotate(2deg) scale(1.06);
+ }
+ }
+}
+
+/* ===== Scrollbar styling ===== */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: rgb(var(--theme-accent-rgb) / 22%);
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: rgb(var(--theme-accent-rgb) / 42%);
+}
+
+/* Firefox scrollbar */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: rgb(var(--theme-accent-rgb) / 24%) transparent;
+}
+
+/* Utility: hide scrollbar while keeping scroll functional */
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
+.scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+/* ===== Marquee text overflow animation ===== */
+@keyframes marquee {
+ 0%,
+ 15% {
+ transform: translateX(0);
+ }
+ 45%,
+ 55% {
+ transform: translateX(var(--marquee-distance));
+ }
+ 85%,
+ 100% {
+ transform: translateX(0);
+ }
+}
+
+/* ===== Reduced motion ===== */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+/* ===== AMLL Lyric Player overrides ===== */
+.amll-container {
+ color: var(--foreground);
+ opacity: 0.9;
+}
+
+/* 翻译歌词子行(lyricSubLine 中第一个) */
+.amll-container [class*='lyricSubLine']:first-of-type {
+ font-size: max(var(--amll-translated-font-size, 0.65em), 10px) !important;
+}
+
+/* 罗马音歌词子行(lyricSubLine 中第二个;仅在同时有翻译+罗马音时生效) */
+.amll-container [class*='lyricSubLine'] + [class*='lyricSubLine'] {
+ font-size: max(var(--amll-roman-font-size, 0.5em), 10px) !important;
+}
+
+/* 逐词罗马音注音(word 级别的 ruby 文本) */
+.amll-container [class*='romanWord'] {
+ font-size: max(var(--amll-roman-font-size, 0.5em), 8px) !important;
+}
diff --git a/packages/client/src/lib/audioQuality.ts b/packages/client/src/lib/audioQuality.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f92e855338e41d41998c0c793c117f6b0d0986b9
--- /dev/null
+++ b/packages/client/src/lib/audioQuality.ts
@@ -0,0 +1,63 @@
+import type { AudioQuality, MusicSource, PlatformAuthStatus } from '@music-together/shared'
+
+export interface AudioQualityOption {
+ value: AudioQuality
+ label: string
+ platform?: MusicSource
+ description?: string
+}
+
+const BASE_OPTIONS: AudioQualityOption[] = [
+ { value: 128, label: '标准 128kbps' },
+ { value: 192, label: '较高 192kbps' },
+ { value: 320, label: '高品质 320kbps' },
+]
+
+const PLATFORM_OPTIONS: Record = {
+ netease: [
+ { value: 999, label: '无损 SQ', platform: 'netease' },
+ { value: 'netease_hires', label: 'Hi-Res', platform: 'netease' },
+ { value: 'netease_jyeffect', label: '高清臻音', platform: 'netease' },
+ { value: 'netease_spatial', label: '沉浸环绕声', platform: 'netease' },
+ { value: 'netease_master', label: '超清母带', platform: 'netease' },
+ { value: 'netease_dolby', label: '杜比全景声', platform: 'netease', description: '浏览器兼容性有限' },
+ ],
+ tencent: [
+ { value: 'tencent_flac', label: '无损', platform: 'tencent' },
+ { value: 'tencent_master', label: '臻品母带', platform: 'tencent' },
+ ],
+ kugou: [
+ { value: 'kugou_hires', label: '酷狗 Hi-Res', platform: 'kugou' },
+ { value: 'kugou_master', label: '酷狗蝰蛇母带 2.0', platform: 'kugou' },
+ ],
+ kugou_concept: [
+ { value: 'kugou_hires', label: '酷狗概念版 Hi-Res', platform: 'kugou_concept' },
+ { value: 'kugou_master', label: '酷狗概念版 蝰蛇母带 2.0', platform: 'kugou_concept' },
+ ],
+ bilibili: [
+ { value: 'bilibili_64', label: 'B站 64K', platform: 'bilibili' },
+ { value: 'bilibili_132', label: 'B站 132K', platform: 'bilibili' },
+ { value: 'bilibili_192', label: 'B站 192K', platform: 'bilibili' },
+ { value: 'bilibili_hires', label: 'B站 Hi-Res', platform: 'bilibili' },
+ ],
+}
+
+export function getAudioQualityOptions(statuses: PlatformAuthStatus[]): AudioQualityOption[] {
+ void statuses
+ // Room quality is shared by all providers. Provider-specific formats are
+ // selected by the server from `highest`; exposing them here made a Tencent
+ // or Kugou room appear to offer unrelated Netease formats.
+ return [
+ ...BASE_OPTIONS,
+ { value: 999, label: '无损 SQ', description: '需要 VIP 账号' },
+ { value: 'highest', label: '尽量高' },
+ ]
+}
+
+export function getAudioQualityLabel(quality: AudioQuality, statuses: PlatformAuthStatus[] = []): string {
+ const allOptions = [...BASE_OPTIONS, ...Object.values(PLATFORM_OPTIONS).flat()]
+ return (
+ [...getAudioQualityOptions(statuses), ...allOptions].find((option) => option.value === quality)?.label ??
+ String(quality)
+ )
+}
diff --git a/packages/client/src/lib/audioUnlock.ts b/packages/client/src/lib/audioUnlock.ts
new file mode 100644
index 0000000000000000000000000000000000000000..48d528fd8be078c802609e4deaf18f2a10b67a38
--- /dev/null
+++ b/packages/client/src/lib/audioUnlock.ts
@@ -0,0 +1,33 @@
+import { Howl, Howler } from 'howler'
+
+let unlocked = false
+
+export function isAudioUnlocked(): boolean {
+ return unlocked
+}
+
+/**
+ * Call within a real user interaction (click / keydown).
+ * Unlocks the Howler global AudioContext so all subsequent
+ * playback works without further interaction.
+ */
+export async function unlockAudio(): Promise {
+ if (unlocked) return
+
+ // 1. Resume Howler's global AudioContext
+ const ctx = Howler.ctx
+ if (ctx && ctx.state === 'suspended') {
+ await ctx.resume()
+ }
+
+ // 2. Play a silent WAV to force-activate (iOS Safari compat)
+ const silentHowl = new Howl({
+ src: ['data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA='],
+ volume: 0,
+ html5: true,
+ })
+ silentHowl.play()
+ silentHowl.once('end', () => silentHowl.unload())
+
+ unlocked = true
+}
diff --git a/packages/client/src/lib/clockSync.ts b/packages/client/src/lib/clockSync.ts
new file mode 100644
index 0000000000000000000000000000000000000000..52f67c27be71f211ea30c2c57a9fc0157fe135c7
--- /dev/null
+++ b/packages/client/src/lib/clockSync.ts
@@ -0,0 +1,204 @@
+/**
+ * NTP-inspired clock synchronisation engine.
+ *
+ * Maintains a running estimate of the offset between the local clock
+ * (`Date.now()`) and the server clock so that `getServerTime()` returns
+ * a value aligned with the server's `Date.now()`.
+ *
+ * Algorithm:
+ * 1. Client sends NTP_PING with `clientPingId` (= monotonic counter).
+ * 2. Server immediately replies NTP_PONG with `{ clientPingId, serverTime }`.
+ * 3. Client records `t0` (when ping was sent, via `performance.now()`)
+ * and `t2` (when pong arrived, via `performance.now()`).
+ * 4. RTT = t2 - t0
+ * offset = serverTime - (clientLocalTimeAtPing + RTT / 2)
+ * 5. We keep a sliding window of samples and compute a **time-decayed
+ * weighted median** offset — recent samples contribute more, giving
+ * both the robustness of median filtering and fast convergence when
+ * network conditions change (e.g. WiFi → cellular handoff).
+ */
+
+import { NTP } from '@music-together/shared'
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface NTPSample {
+ rttMs: number
+ offsetMs: number
+ /** `Date.now()` when the sample was recorded */
+ timestamp: number
+}
+
+interface PendingPing {
+ /** `performance.now()` when the ping was sent */
+ sentAt: number
+ /** `Date.now()` when the ping was sent – used for offset calculation */
+ localTime: number
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/** Half-life (ms) for exponential decay weighting of NTP samples.
+ * Samples older than ~30s contribute roughly half the weight of fresh ones. */
+const DECAY_HALF_LIFE_MS = 30_000
+
+// ---------------------------------------------------------------------------
+// State (module-level singleton – one clock per app)
+// ---------------------------------------------------------------------------
+
+const samples: NTPSample[] = []
+const pending = new Map()
+let pingCounter = 0
+let medianOffset = 0
+let calibrated = false
+
+// Anchor pair for monotonic getServerTime().
+// `performance.now()` is monotonic and immune to system clock adjustments
+// (NTP sync, manual time change, sleep/wake). We anchor a known server-time
+// to a performance.now() reading and derive future server-times from the
+// elapsed monotonic time, eliminating Date.now() jitter from getServerTime().
+let anchorPerfNow = performance.now()
+let anchorServerTime = Date.now() // uncalibrated initially; updated on each pong
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Compute time-decayed weighted median of offset values.
+ * Each sample's weight = exp(-age / halfLife), so recent samples dominate.
+ * We find the offset where cumulative weight first exceeds 50% of total.
+ */
+function computeWeightedMedian(now: number): number {
+ if (samples.length === 0) return 0
+ if (samples.length === 1) return samples[0].offsetMs
+
+ // Build (offset, weight) pairs
+ const pairs: { offset: number; weight: number }[] = []
+ for (const s of samples) {
+ const age = now - s.timestamp
+ const weight = Math.exp((-age * Math.LN2) / DECAY_HALF_LIFE_MS)
+ pairs.push({ offset: s.offsetMs, weight })
+ }
+
+ // Sort by offset ascending
+ pairs.sort((a, b) => a.offset - b.offset)
+
+ // Find weighted median
+ let totalWeight = 0
+ for (const p of pairs) totalWeight += p.weight
+ const halfWeight = totalWeight / 2
+
+ let cumWeight = 0
+ for (const p of pairs) {
+ cumWeight += p.weight
+ if (cumWeight >= halfWeight) return p.offset
+ }
+
+ // Fallback (should not reach here)
+ return pairs[pairs.length - 1].offset
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/** Returns the current server time (ms) compensated by the measured offset.
+ * Uses a monotonic `performance.now()` anchor so the result is immune to
+ * system clock adjustments (NTP sync, manual time change, sleep/wake). */
+export function getServerTime(): number {
+ return anchorServerTime + (performance.now() - anchorPerfNow)
+}
+
+/** Whether the initial calibration phase has completed. */
+export function isCalibrated(): boolean {
+ return calibrated
+}
+
+/** Current smoothed RTT (ms) – the median of all stored samples. */
+export function getMedianRTT(): number {
+ if (samples.length === 0) return 0
+ const sorted = samples.map((s) => s.rttMs).sort((a, b) => a - b)
+ return sorted[Math.floor(sorted.length / 2)]
+}
+
+/** Current clock offset (ms). Positive = local clock is behind server. */
+export function getClockOffset(): number {
+ return medianOffset
+}
+
+// ---------------------------------------------------------------------------
+// Ping / Pong helpers (called by useClockSync hook)
+// ---------------------------------------------------------------------------
+
+/**
+ * Record a pending ping. Returns the `clientPingId` to send to the server.
+ */
+export function recordPing(): number {
+ const id = ++pingCounter
+ const now = Date.now()
+ pending.set(id, {
+ sentAt: performance.now(),
+ localTime: now,
+ })
+ // Purge stale entries older than 10 seconds (server never responded)
+ const staleThreshold = now - 10_000
+ for (const [k, v] of pending) {
+ if (v.localTime < staleThreshold) pending.delete(k)
+ else break // Map preserves insertion order; once we hit a fresh entry, stop
+ }
+ return id
+}
+
+/**
+ * Process a pong response from the server.
+ * Returns the computed RTT so the caller can forward it to the server.
+ */
+export function processPong(clientPingId: number, serverTime: number): number | null {
+ const ping = pending.get(clientPingId)
+ if (!ping) return null
+ pending.delete(clientPingId)
+
+ const t2 = performance.now()
+ const rttMs = t2 - ping.sentAt
+ // Discard obviously bad samples (negative or huge RTT)
+ if (rttMs < 0 || rttMs > 10_000) return null
+
+ const now = Date.now()
+ const oneWay = rttMs / 2
+ const offsetMs = serverTime - (ping.localTime + oneWay)
+
+ samples.push({ rttMs, offsetMs, timestamp: now })
+ // Keep sliding window bounded
+ if (samples.length > NTP.MAX_MEASUREMENTS) {
+ samples.shift()
+ }
+
+ // Recalculate time-decayed weighted median offset
+ medianOffset = computeWeightedMedian(now)
+
+ // Refresh the monotonic anchor so getServerTime() stays accurate
+ anchorPerfNow = performance.now()
+ anchorServerTime = Date.now() + medianOffset
+
+ if (!calibrated && samples.length >= NTP.MAX_INITIAL_SAMPLES) {
+ calibrated = true
+ }
+
+ return rttMs
+}
+
+/** Reset all state (useful on disconnect). */
+export function resetClockSync(): void {
+ samples.length = 0
+ pending.clear()
+ pingCounter = 0
+ medianOffset = 0
+ calibrated = false
+ anchorPerfNow = performance.now()
+ anchorServerTime = Date.now()
+}
diff --git a/packages/client/src/lib/config.ts b/packages/client/src/lib/config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d91e5a00fe2effcdfae73db4b821068b442ceb39
--- /dev/null
+++ b/packages/client/src/lib/config.ts
@@ -0,0 +1,29 @@
+const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
+
+function trimTrailingSlash(url: string): string {
+ return url.replace(/\/+$/, '')
+}
+
+function resolveServerUrl(): string {
+ const configuredUrl = import.meta.env.VITE_SERVER_URL?.trim()
+
+ if (configuredUrl) {
+ try {
+ const parsed = new URL(configuredUrl)
+ if (import.meta.env.DEV && LOCALHOST_HOSTS.has(parsed.hostname) && !LOCALHOST_HOSTS.has(window.location.hostname)) {
+ parsed.hostname = window.location.hostname
+ }
+ return trimTrailingSlash(parsed.toString())
+ } catch {
+ return trimTrailingSlash(configuredUrl)
+ }
+ }
+
+ if (import.meta.env.DEV) {
+ return `${window.location.protocol}//${window.location.hostname}:3001`
+ }
+
+ return window.location.origin
+}
+
+export const SERVER_URL = resolveServerUrl()
diff --git a/packages/client/src/lib/constants.ts b/packages/client/src/lib/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..55b3d0f35cffd86bfbc4b60584b46cc2b60d1228
--- /dev/null
+++ b/packages/client/src/lib/constants.ts
@@ -0,0 +1,45 @@
+// ---------------------------------------------------------------------------
+// Client-side timing constants (ms / s)
+// ---------------------------------------------------------------------------
+
+/** Deduplication window for PLAYER_PLAY events */
+export const PLAYER_PLAY_DEDUP_MS = 2000
+
+/** Throttle interval for currentTime store updates */
+export const CURRENT_TIME_THROTTLE_MS = 100
+
+/** User-configurable range for steady-state sync packets (seconds) */
+export const SYNC_PACKET_INTERVAL_MIN_SECONDS = 1
+export const SYNC_PACKET_INTERVAL_MAX_SECONDS = 60
+
+/** Logical drift target for the pitch-preserving tempo controller. */
+export const DRIFT_DEAD_ZONE_MS = 5
+
+/** Proportional gain for SoundTouch tempo correction. */
+export const DRIFT_TEMPO_KP = 0.15
+
+/** Maximum tempo correction; SoundTouch compensates pitch in the worklet. */
+export const MAX_TEMPO_ADJUSTMENT = 0.01
+
+/** Drift threshold (ms) before hard-seeking to correct position */
+export const DRIFT_SEEK_THRESHOLD_MS = 500
+
+/** EMA smoothing factor for drift measurements (0–1, higher = more responsive). */
+export const DRIFT_SMOOTH_ALPHA = 0.35
+
+/** Extra margin (ms) added to the median RTT when computing the adaptive
+ * hard-seek threshold. Final threshold = max(DRIFT_SEEK_THRESHOLD_MS,
+ * medianRTT + DRIFT_SEEK_RTT_MARGIN_MS). This prevents high-latency
+ * NTP jitter from repeatedly triggering hard seeks. */
+export const DRIFT_SEEK_RTT_MARGIN_MS = 250
+
+/** Number of consecutive sync responses whose smoothed drift exceeds
+ * the hard-seek threshold before actually seeking. Prevents a single
+ * noisy measurement from causing an audible jump. */
+export const HARD_SEEK_CONFIRM_COUNT = 2
+
+/** Safety clamp for network delay estimation (seconds) — prevents clock-skew outliers */
+export const MAX_NETWORK_DELAY_S = 5
+
+/** Safety timeout for lobby action loading state */
+export const ACTION_LOADING_TIMEOUT_MS = 15_000
diff --git a/packages/client/src/lib/cover.ts b/packages/client/src/lib/cover.ts
new file mode 100644
index 0000000000000000000000000000000000000000..eb8b68f48b2f650292830be430df8dc0804b825c
--- /dev/null
+++ b/packages/client/src/lib/cover.ts
@@ -0,0 +1,24 @@
+import { SERVER_URL } from './config'
+
+const PROXY_COVER_HOSTS = new Set(['y.gtimg.cn', 'imgessl.kugou.com'])
+
+export function isBilibiliCoverUrl(coverUrl: string): boolean {
+ try {
+ return new URL(coverUrl).hostname.endsWith('.hdslb.com')
+ } catch {
+ return false
+ }
+}
+
+/** Route non-Bilibili cover CDNs that reject browser-origin image requests through our cover proxy. */
+export function getProxiedCoverUrl(coverUrl: string): string {
+ try {
+ const { hostname } = new URL(coverUrl)
+ if (PROXY_COVER_HOSTS.has(hostname)) {
+ return `${SERVER_URL}/api/music/cover-proxy?url=${encodeURIComponent(coverUrl)}`
+ }
+ } catch {
+ // Keep invalid URLs unchanged so the image element's normal error handling applies.
+ }
+ return coverUrl
+}
diff --git a/packages/client/src/lib/format.ts b/packages/client/src/lib/format.ts
new file mode 100644
index 0000000000000000000000000000000000000000..885545e0e1455d879445c51a285fbaace401aca3
--- /dev/null
+++ b/packages/client/src/lib/format.ts
@@ -0,0 +1,15 @@
+/** Format seconds to mm:ss (for playback time display) */
+export function formatTime(seconds: number): string {
+ if (!isFinite(seconds) || seconds < 0) return '0:00'
+ const m = Math.floor(seconds / 60)
+ const s = Math.floor(seconds % 60)
+ return `${m}:${s.toString().padStart(2, '0')}`
+}
+
+/** Format seconds to mm:ss, returns '--:--' for invalid/zero values (for track duration display) */
+export function formatDuration(seconds: number): string {
+ if (!seconds || !isFinite(seconds) || seconds <= 0) return '--:--'
+ const m = Math.floor(seconds / 60)
+ const s = Math.floor(seconds % 60)
+ return `${m}:${s.toString().padStart(2, '0')}`
+}
diff --git a/packages/client/src/lib/identityAuth.ts b/packages/client/src/lib/identityAuth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1e3644aa832a87ae405b7f28188610012bec00d9
--- /dev/null
+++ b/packages/client/src/lib/identityAuth.ts
@@ -0,0 +1,92 @@
+import { SERVER_URL } from '@/lib/config'
+import { fetchCurrentProfile } from '@/lib/profileApi'
+import { storage } from '@/lib/storage'
+import type { AccountProfile } from '@/stores/accountStore'
+import type { TypedSocket } from '@/lib/socket'
+
+export async function requestJson(path: string, init?: RequestInit): Promise {
+ const response = await fetch(`${SERVER_URL}${path}`, {
+ ...init,
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', ...init?.headers },
+ })
+ if (!response.ok) {
+ const body = (await response.json().catch(() => null)) as { error?: string } | null
+ throw new Error(body?.error ?? `Request failed: ${response.status}`)
+ }
+ if (response.status === 204) return undefined as T
+ return (await response.json()) as T
+}
+
+function reconnectSocket(socket: TypedSocket): Promise {
+ return new Promise((resolve) => {
+ let finished = false
+ const finish = () => {
+ if (finished) return
+ finished = true
+ socket.off('connect', finish)
+ window.clearTimeout(timeout)
+ resolve()
+ }
+ const timeout = window.setTimeout(finish, 3_000)
+ socket.on('connect', finish)
+ if (socket.connected) socket.disconnect()
+ socket.connect()
+ })
+}
+
+function clearIdentityBoundBrowserState(): void {
+ storage.setAuthCookies([])
+ storage.clearRejoinToken()
+}
+
+export async function setInitialPassword(password: string): Promise {
+ await requestJson<{ accountId: string }>('/api/auth/me/password', {
+ method: 'POST',
+ body: JSON.stringify({ password }),
+ })
+ const profile = await fetchCurrentProfile()
+ if (!profile) throw new Error('请先设置昵称')
+ return profile
+}
+
+export async function updateAccountId(
+ socket: TypedSocket,
+ accountId: string,
+ currentPassword?: string,
+): Promise {
+ const result = await requestJson('/api/auth/me/account-id', {
+ method: 'PATCH',
+ body: JSON.stringify({ accountId: accountId.trim().toLowerCase(), currentPassword }),
+ })
+ storage.setUserId(result.id)
+ storage.clearRejoinToken()
+ const profile = await fetchCurrentProfile()
+ if (!profile) throw new Error('账号资料恢复失败')
+ await reconnectSocket(socket)
+ return profile
+}
+
+export async function loginIdentity(socket: TypedSocket, accountId: string, password: string): Promise {
+ const result = await requestJson<{ userId: string; expiresAt: number }>('/api/auth/identity/recover', {
+ method: 'POST',
+ body: JSON.stringify({ accountId: accountId.trim(), password }),
+ })
+ clearIdentityBoundBrowserState()
+ storage.setUserId(result.userId)
+ const profile = await fetchCurrentProfile()
+ if (!profile) throw new Error('账号资料恢复失败')
+ await reconnectSocket(socket)
+ return profile
+}
+
+export async function logoutIdentity(socket: TypedSocket): Promise {
+ await requestJson<{ userId: string; expiresAt: number }>('/api/auth/identity/logout', {
+ method: 'POST',
+ })
+ clearIdentityBoundBrowserState()
+ storage.clearNickname()
+ const profile = await fetchCurrentProfile()
+ await reconnectSocket(socket)
+ return profile
+}
diff --git a/packages/client/src/lib/lyricOffset.ts b/packages/client/src/lib/lyricOffset.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ceb1447368348ae18b320d7ed34dcf9d00ff1834
--- /dev/null
+++ b/packages/client/src/lib/lyricOffset.ts
@@ -0,0 +1,15 @@
+import type { Track } from '@music-together/shared'
+
+/**
+ * Bilibili videos can contain a custom intro/outro, so their lyric adjustment
+ * must be scoped to the video instead of changing the matched song globally.
+ */
+export function getLyricOffsetKey(track: Track | null): string | null {
+ if (!track?.lyricId) return null
+
+ const lyricSource = track.metadataSource ?? track.source
+ if (track.source === 'bilibili') {
+ return `bilibili:${track.urlId}:${lyricSource}:${track.lyricId}`
+ }
+ return `${lyricSource}:${track.lyricId}`
+}
diff --git a/packages/client/src/lib/platform.ts b/packages/client/src/lib/platform.ts
new file mode 100644
index 0000000000000000000000000000000000000000..159fa64b8554cdb270b32affabf6cf9585bad026
--- /dev/null
+++ b/packages/client/src/lib/platform.ts
@@ -0,0 +1,70 @@
+import type { MusicSource, MyPlatformAuth, PlatformAuthStatus } from '@music-together/shared'
+
+/** Full platform display names (used in dialogs, titles, descriptions) */
+export const PLATFORM_LABELS: Record = {
+ netease: '网易云音乐',
+ tencent: 'QQ 音乐',
+ kugou: '酷狗音乐',
+ kugou_concept: '酷狗概念版',
+ bilibili: 'B站',
+}
+
+/** Short platform labels (used in compact UI like tabs) */
+export const PLATFORM_SHORT_LABELS: Record = {
+ netease: '网易云',
+ tencent: 'QQ 音乐',
+ kugou: '酷狗',
+ kugou_concept: '概念版',
+ bilibili: 'B站',
+}
+
+/** Tab highlight colors per platform */
+export const PLATFORM_COLORS: Record = {
+ netease: 'data-[state=active]:text-red-500',
+ tencent: 'data-[state=active]:text-green-500',
+ kugou: 'data-[state=active]:text-blue-500',
+ kugou_concept: 'data-[state=active]:text-sky-500',
+ bilibili: 'data-[state=active]:text-pink-500',
+}
+
+/** Active platform selector styles */
+export const PLATFORM_ACTIVE: Record = {
+ netease: 'bg-red-500/15',
+ tencent: 'bg-green-500/15',
+ kugou: 'bg-blue-500/15',
+ kugou_concept: 'bg-sky-500/15',
+ bilibili: 'bg-pink-500/15',
+}
+
+/** Active platform text color */
+export const PLATFORM_TEXT: Record = {
+ netease: 'text-red-500',
+ tencent: 'text-green-500',
+ kugou: 'text-blue-500',
+ kugou_concept: 'text-sky-500',
+ bilibili: 'text-pink-500',
+}
+
+/** Normalized membership labels shared by all platforms. */
+export const VIP_LABELS: Record = {
+ 0: '',
+ 1: 'VIP',
+ 2: 'SVIP',
+}
+
+export function getVipDisplayLabel(vipType = 0, providerLabel?: string): string {
+ return providerLabel?.trim() || VIP_LABELS[vipType] || (vipType > 0 ? 'VIP' : '')
+}
+
+/** Find a platform's auth status from the room-wide status list */
+export function getPlatformStatus(
+ platform: MusicSource,
+ statusList: PlatformAuthStatus[],
+): PlatformAuthStatus | undefined {
+ return statusList.find((s) => s.platform === platform)
+}
+
+/** Find the current user's auth status for a platform */
+export function getMyPlatformStatus(platform: MusicSource, myStatusList: MyPlatformAuth[]): MyPlatformAuth | undefined {
+ return myStatusList.find((s) => s.platform === platform)
+}
diff --git a/packages/client/src/lib/profileApi.ts b/packages/client/src/lib/profileApi.ts
new file mode 100644
index 0000000000000000000000000000000000000000..55170cf53c7dd62ff44d1d47d2e8a552ef7b6fa9
--- /dev/null
+++ b/packages/client/src/lib/profileApi.ts
@@ -0,0 +1,57 @@
+import { useAccountStore, type AccountProfile } from '@/stores/accountStore'
+import { useRoomStore } from '@/stores/roomStore'
+import { SERVER_URL } from './config'
+import { storage } from './storage'
+
+function storeProfile(profile: AccountProfile): AccountProfile {
+ useAccountStore.getState().setProfile(profile)
+ storage.setUserId(profile.id)
+ if (profile.nickname) storage.setNickname(profile.nickname)
+ useRoomStore.getState().updateUserProfile(profile.id, profile)
+ return profile
+}
+
+async function requestProfile(path: string, init?: RequestInit): Promise {
+ const response = await fetch(`${SERVER_URL}${path}`, {
+ ...init,
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', ...init?.headers },
+ })
+ if (!response.ok) {
+ const body = (await response.json().catch(() => null)) as { error?: string } | null
+ throw new Error(body?.error ?? `Request failed: ${response.status}`)
+ }
+ if (response.status === 204) {
+ useAccountStore.getState().setProfile(null)
+ storage.clearUserId()
+ return null
+ }
+ return storeProfile((await response.json()) as AccountProfile)
+}
+
+export function fetchCurrentProfile(): Promise {
+ return requestProfile('/api/auth/me')
+}
+
+export async function updateCurrentNickname(nickname: string): Promise {
+ const profile = await requestProfile('/api/auth/me', {
+ method: 'PATCH',
+ body: JSON.stringify({ nickname: nickname.trim() }),
+ })
+ if (!profile) throw new Error('账号创建失败')
+ return profile
+}
+
+export async function uploadCurrentAvatar(image: string): Promise {
+ const profile = await requestProfile('/api/auth/me/avatar', {
+ method: 'POST',
+ body: JSON.stringify({ image }),
+ })
+ if (!profile) throw new Error('头像保存失败')
+ return profile
+}
+
+export function resolveAvatarUrl(avatarUrl?: string | null): string | undefined {
+ if (!avatarUrl) return undefined
+ return avatarUrl.startsWith('/uploads/') ? `${SERVER_URL}${avatarUrl}` : avatarUrl
+}
diff --git a/packages/client/src/lib/resetStores.ts b/packages/client/src/lib/resetStores.ts
new file mode 100644
index 0000000000000000000000000000000000000000..37efded35bb1f72b4032329c802defabfd89c69b
--- /dev/null
+++ b/packages/client/src/lib/resetStores.ts
@@ -0,0 +1,12 @@
+import { useRoomStore } from '@/stores/roomStore'
+import { usePlayerStore } from '@/stores/playerStore'
+import { useChatStore } from '@/stores/chatStore'
+import { resetClockSync } from '@/lib/clockSync'
+
+/** Reset all room-related stores at once (used on leave/disconnect) */
+export function resetAllRoomState() {
+ useRoomStore.getState().reset()
+ usePlayerStore.getState().reset()
+ useChatStore.getState().reset()
+ resetClockSync()
+}
diff --git a/packages/client/src/lib/socket.ts b/packages/client/src/lib/socket.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ba3159628eb32f63d1f5e30411c80b3a263b50b2
--- /dev/null
+++ b/packages/client/src/lib/socket.ts
@@ -0,0 +1,163 @@
+import type { ClientToServerEvents, ServerToClientEvents } from '@music-together/shared'
+import { SERVER_URL } from './config'
+
+interface ConnectionEvents {
+ connect: () => void
+ disconnect: () => void
+ connect_error: (error: Error) => void
+}
+
+type SocketInboundEvents = ServerToClientEvents & ConnectionEvents
+type StoredHandler = (...args: unknown[]) => void
+
+export type TypedSocket = {
+ connected: boolean
+ connect: () => void
+ disconnect: () => void
+ on: (event: E, handler: SocketInboundEvents[E]) => void
+ off: (event: E, handler: SocketInboundEvents[E]) => void
+ emit: (event: E, ...args: Parameters) => void
+}
+
+let socket: TypedSocket | null = null
+
+export function getSocket(): TypedSocket {
+ if (!socket) {
+ socket = createWebSocket()
+ }
+ return socket
+}
+
+export function connectSocket(): TypedSocket {
+ const s = getSocket()
+ s.connect()
+ return s
+}
+
+export function disconnectSocket(): void {
+ if (socket) {
+ socket.disconnect()
+ socket = null
+ }
+}
+
+export function waitForConnect(): Promise {
+ const s = getSocket()
+ if (s.connected) return Promise.resolve(s)
+ return new Promise((resolve) => {
+ const handler = () => {
+ s.off('connect', handler)
+ resolve(s)
+ }
+ s.on('connect', handler)
+ s.connect()
+ })
+}
+
+function createWebSocket(): TypedSocket {
+ let ws: WebSocket | null = null
+ let connected = false
+ const handlers = new Map>()
+ let reconnectTimer: ReturnType | null = null
+ let shouldReconnect = true
+
+ const connect = () => {
+ shouldReconnect = true
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+ return
+ }
+
+ const wsUrl = SERVER_URL.replace(/^http/, 'ws') + '/ws'
+ ws = new WebSocket(wsUrl)
+
+ ws.onopen = () => {
+ connected = true
+ const connectHandlers = handlers.get('connect')
+ if (connectHandlers) {
+ for (const h of connectHandlers) h()
+ }
+ }
+
+ ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data)
+ if (msg && typeof msg.event === 'string') {
+ const eventHandlers = handlers.get(msg.event)
+ if (eventHandlers) {
+ for (const h of eventHandlers) h(msg.data)
+ }
+ }
+ } catch (err) {
+ console.error('Failed to parse WebSocket message', err)
+ }
+ }
+
+ ws.onclose = () => {
+ connected = false
+ const disconnectHandlers = handlers.get('disconnect')
+ if (disconnectHandlers) {
+ for (const h of disconnectHandlers) h()
+ }
+
+ if (shouldReconnect) {
+ reconnectTimer = setTimeout(() => {
+ connect()
+ }, 2000)
+ }
+ }
+
+ ws.onerror = (err) => {
+ console.error('WebSocket error', err)
+ const errorHandlers = handlers.get('connect_error')
+ if (errorHandlers) {
+ for (const h of errorHandlers) h(new Error('WebSocket error'))
+ }
+ }
+ }
+
+ const disconnect = () => {
+ shouldReconnect = false
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
+ if (ws) {
+ ws.close()
+ ws = null
+ }
+ connected = false
+ }
+
+ const on = (event: E, handler: SocketInboundEvents[E]) => {
+ let set = handlers.get(event as string)
+ if (!set) {
+ set = new Set()
+ handlers.set(event as string, set)
+ }
+ set.add(handler as unknown as StoredHandler)
+ }
+
+ const off =