feat: add web application sources

This commit is contained in:
DiheChen committed 2026-10-30 01:32:51 +08:00
1 parent ec08d487e1
commit c4008505e6
133 files changed
+20466

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/icon-64x64.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<title>Alisten</title>
<meta name="theme-color" content="#000000" />
<meta name="description" content="Alisten:在线音乐同步播放应用" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Alisten" />
<link rel="apple-touch-icon" href="/icon-180x180.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>
+11960
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "alisten-web",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"lint": "eslint",
"lint:fix": "eslint --fix",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"lucide-react": "^1.60.0",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
"devDependencies": {
"@antfu/eslint-config": "^9.5.1",
"@eslint-react/eslint-plugin": "^5.28.1",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.19.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"eslint": "^10.13.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"vite": "^8.3.4",
"vite-plugin-pwa": "^2.0.0",
"vitest": "^5.0.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Alisten"><rect width="512" height="512" rx="115.2" fill="#000000"/><g transform="translate(255.97 256) scale(1.56)"><path fill="#68D268" d="M72.46 -8.5A117 117 0 0 0 -28.87 -67A17 17 0 0 0 -43.59 -58.5A117 117 0 0 0 -43.59 58.5A17 17 0 0 0 -28.87 67A117 117 0 0 0 72.46 8.5A17 17 0 0 0 72.46 -8.5Z"/><path fill="none" stroke="#F4F4F5" stroke-width="14" stroke-linecap="round" d="M-68.36 50.95A136 136 0 0 1 -68.36 -50.95M78.3 33.73A136 136 0 0 1 -9.94 84.68M-9.94 -84.68A136 136 0 0 1 78.3 -33.73"/></g></svg>

After

Width:  |  Height:  |  Size: 595 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-87.26 -93.68 174.57 187.35" role="img" aria-label="Alisten"><style>.i{stroke:#202024}.f{fill:#202024}@media (prefers-color-scheme:dark){.i{stroke:#F4F4F5}.f{fill:#F4F4F5}}</style><path fill="#68D268" d="M72.46 -8.5A117 117 0 0 0 -28.87 -67A17 17 0 0 0 -43.59 -58.5A117 117 0 0 0 -43.59 58.5A17 17 0 0 0 -28.87 67A117 117 0 0 0 72.46 8.5A17 17 0 0 0 72.46 -8.5Z"/><path fill="none" class="i" stroke-width="14" stroke-linecap="round" d="M-68.36 50.95A136 136 0 0 1 -68.36 -50.95M78.3 33.73A136 136 0 0 1 -9.94 84.68M-9.94 -84.68A136 136 0 0 1 78.3 -33.73"/></svg>

After

Width:  |  Height:  |  Size: 612 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 25.75 377.85 86.51" role="img" aria-label="Alisten"><style>.i{stroke:#202024}.f{fill:#202024}@media (prefers-color-scheme:dark){.i{stroke:#F4F4F5}.f{fill:#F4F4F5}}</style><g transform="translate(38.37 69)"><path fill="#68D268" d="M32.61 -3.83A52.65 52.65 0 0 0 -12.99 -30.15A7.65 7.65 0 0 0 -19.62 -26.32A52.65 52.65 0 0 0 -19.62 26.32A7.65 7.65 0 0 0 -12.99 30.15A52.65 52.65 0 0 0 32.61 3.83A7.65 7.65 0 0 0 32.61 -3.83Z"/><path fill="none" class="i" stroke-width="6.3" stroke-linecap="round" d="M-30.76 22.93A61.2 61.2 0 0 1 -30.76 -22.93M35.24 15.18A61.2 61.2 0 0 1 -4.47 38.1M-4.47 -38.1A61.2 61.2 0 0 1 35.24 -15.18"/></g><g transform="translate(102.85 0)"><path fill="none" class="i" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" d="M40.5 78A18.5 18.5 0 1 1 3.5 78A18.5 18.5 0 1 1 40.5 78M40.5 59.5V96.5M59.5 37.5V96.5M79.5 59.5V96.5M121.74 63.06A13 9.25 0 1 0 111.5 78A13 9.25 0 1 1 101.26 92.94M149.5 43.5V87.5A9 9 0 0 0 158.5 96.5M140.5 59.5H160.5M176.5 78H213.5A18.5 18.5 0 1 0 208.75 90.38M230.5 59.5V96.5M230.5 78A18.5 18.5 0 0 1 267.5 78V96.5"/><path class="f" d="M79.5 40.4a4.6 4.6 0 1 1 0 9.2a4.6 4.6 0 1 1 0 -9.2Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+9
View File
@@ -0,0 +1,9 @@
globalThis.addEventListener('notificationclick', (event) => {
event.notification.close()
const { clients, registration: { scope } } = globalThis
// 第一次安装时打开的页面不受 Service Worker 控制,也要找出来。
event.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
const app = windows.find(client => client.url.startsWith(scope))
return app ? app.focus() : clients.openWindow(scope)
}))
})
+334
View File
@@ -0,0 +1,334 @@
import type { LucideIcon } from 'lucide-react'
import type { Page, Panel, RoomPage } from './route'
import type { SearchQuery } from '@/features/search'
import { ChevronLeft, ChevronRight, Headphones, History, House, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
import { Suspense } from 'react'
import { roomStore } from '@/core/room'
import { ChatPanel } from '@/features/chat'
import { HistoryPage } from '@/features/history'
import { ListenPage } from '@/features/lyrics'
import { AudioEngine, ManualStartPrompt, PlayerBar, playerStore } from '@/features/playback'
import { QueuePage, voteSkip } from '@/features/queue'
import { copyInviteLink, EntryDialog, Lobby, roomEntryStore, RoomGate } from '@/features/rooms'
import { HeaderSearch, PlaylistPage, SearchPage, searchStore } from '@/features/search'
import { profileStore, SettingsPage } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { Avatar, IconButton, lazyPage, Notifications, notificationStore, Skeleton } from '@/shared/ui'
import { DetailPanel } from './detail-panel'
import { ErrorBoundary, RegionFailed } from './error-boundary'
import { navigationStore } from './navigation'
import { pwaStore } from './pwa'
import { UpdatePrompt } from './update-prompt'
import { withViewTransition } from './view-transition'
// adminStore 不在 main.tsx 中启动:静态导入会把后台页打包进入口 chunk。
const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
module.adminStore.start()
return module.AdminPage
}))
const pageLoading = <div className="page-content"><Skeleton rows={6} /></div>
const menu: { page: 'lobby' | 'settings' | 'history' | RoomPage, label: string, icon: LucideIcon }[] = [
{ page: 'listen', label: '正在聆听', icon: Headphones },
{ page: 'queue', label: '播放列表', icon: ListMusic },
{ page: 'chat', label: '房间聊天', icon: MessageCircle },
{ page: 'history', label: '播放历史', icon: History },
{ page: 'lobby', label: '发现房间', icon: House },
{ page: 'settings', label: '设置', icon: Settings },
]
const mobileMenu: { page: RoomPage, label: string, icon: LucideIcon }[] = [
{ page: 'listen', label: '聆听', icon: Headphones },
{ page: 'queue', label: '播放列表', icon: ListMusic },
{ page: 'chat', label: '聊天', icon: MessageCircle },
]
const pageClasses: Partial<Record<Page, string>> = {
queue: 'scrollbar-hidden',
history: 'scrollbar-hidden',
settings: 'settings-view',
}
function closeMenu(element: HTMLElement): void {
element.closest('details')?.removeAttribute('open')
}
function search(query: SearchQuery): void {
if (roomEntryStore.state.phase === 'idle')
notificationStore.info('先加入一个房间,再搜索和点歌。')
navigationStore.search(query)
}
function ConnectionStatus() {
const status = useStore(roomStore, s => s.status)
const inRoom = status !== null && status !== 'stopped'
const connected = status === 'open'
return (
<button
type="button"
className={`connection-status text-ui-xs ${connected ? 'connected' : ''}`}
title={inRoom && !connected ? '点击重试连接' : '房间连接状态'}
disabled={!inRoom || connected}
onClick={() => roomStore.reconnect()}
>
{connected ? <Wifi size={12} /> : <WifiOff size={12} />}
<span>{!inRoom ? '未加入' : connected ? '同步在线' : status === 'connecting' ? '连接中' : '重连中'}</span>
</button>
)
}
function ProfileButton() {
const member = useStore(profileStore, s => s.member)
return (
<button type="button" className="profile-button" aria-label="设置" title={member?.name ?? '设置'} onClick={() => navigationStore.open('settings')}>
{member ? <Avatar hash={member.avatarHash} name={member.name} className="text-ui-base" /> : <UserRound size={20} strokeWidth={1.7} aria-hidden="true" />}
</button>
)
}
function Header({ page }: { page: Page }) {
const canBack = useStore(navigationStore, s => s.canBack)
const canForward = useStore(navigationStore, s => s.canForward)
const inRoom = useStore(roomEntryStore, s => s.phase !== 'idle')
const joined = useStore(roomEntryStore, s => s.phase === 'joined')
const roomName = useStore(roomEntryStore, s => s.room?.name)
const home = inRoom ? 'listen' : 'lobby'
return (
<header className="workspace-header">
<div className="header-leading">
<details
className="header-menu"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
event.currentTarget.removeAttribute('open')
}}
onKeyDown={(event) => {
if (event.key === 'Escape')
event.currentTarget.removeAttribute('open')
}}
>
<summary aria-label="更多功能" title="更多功能">
<MoreHorizontal size={20} />
</summary>
<nav className="main-nav" aria-label="主导航">
{menu.map(({ page: target, label, icon: Icon }) => (
<button
type="button"
key={target}
className={`nav-item text-ui-base ${page === target ? 'selected' : ''}`}
aria-current={page === target ? 'page' : undefined}
onClick={(event) => {
navigationStore.open(target)
closeMenu(event.currentTarget)
}}
>
<Icon size={20} strokeWidth={1.7} />
{label}
</button>
))}
<button
type="button"
className="nav-item text-ui-base"
disabled={!joined}
onClick={(event) => {
const { room } = roomEntryStore.state
if (room)
void copyInviteLink(room)
closeMenu(event.currentTarget)
}}
>
<Share2 size={20} />
复制邀请链接
</button>
</nav>
</details>
<IconButton icon={ChevronLeft} label="后退" disabled={!canBack} onClick={() => navigationStore.back()} />
<IconButton icon={ChevronRight} label="前进" className="header-forward" disabled={!canForward} onClick={() => navigationStore.forward()} />
</div>
<div className="header-search-area">
<IconButton
icon={House}
label={inRoom ? '返回聆听' : '发现房间'}
title={inRoom ? roomName : '发现房间'}
className="home-widget"
aria-current={page === home ? 'page' : undefined}
onClick={() => navigationStore.open(home)}
/>
<HeaderSearch onSearch={search} />
<IconButton
icon={Search}
label="搜索音乐"
className="mobile-search-button"
aria-current={page === 'search' ? 'page' : undefined}
onClick={() => search(searchStore.query)}
/>
</div>
<div className="header-actions">
<ConnectionStatus />
<ProfileButton />
</div>
</header>
)
}
// 页面自己订阅地址里的参数:筛选框每按一次键都改写地址,应用壳不必跟着重新渲染。
function RoutedLobby({ onEntered }: { onEntered: (ref: string) => void }) {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'lobby')
return null
return <Lobby filter={route.filter} onFilter={filter => navigationStore.navigate({ ...route, filter })} onEntered={onEntered} />
}
function RoutedHistory() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'history')
return null
return <HistoryPage filter={route.filter} onFilter={filter => navigationStore.navigate({ ...route, filter })} />
}
function RoutedSearch() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'search')
return null
return (
<SearchPage
inlineSearchClassName="mobile-search-box"
onSearch={search}
onOpenPlaylist={({ source, id, name }) => navigationStore.navigate({ name: 'playlist', ref: route.ref, panel: route.panel, source, id, page: 1 }, name)}
/>
)
}
function RoutedPlaylist() {
const route = useStore(navigationStore, s => s.route)
const title = useStore(navigationStore, s => s.title)
if (route.name !== 'playlist')
return null
return <PlaylistPage title={title} page={route.page} onPage={page => navigationStore.navigate({ ...route, page })} />
}
export function App() {
const page = useStore(navigationStore, s => s.route.name)
const panel = useStore(navigationStore, s => s.route.panel)
const joined = useStore(roomEntryStore, s => s.phase === 'joined')
const songStartedAtMs = useStore(roomStore, s => s.room?.nowPlaying?.startedAtMs)
const browse = () => navigationStore.open('lobby')
const showRoom = (ref: string) => navigationStore.navigate({ name: 'listen', ref, panel: null })
// 加入房间前不显示面板:底部的切换按钮此时不可用,而聊天视图的关闭按钮在 RoomGate 内,打开的聊天面板会无法关闭。
const shownPanel = joined && panel !== page ? panel : null
const switchPanel = (next: Panel | null) => {
if (shownPanel && next && next !== shownPanel && next !== page)
withViewTransition(() => navigationStore.setPanel(next))
else
navigationStore.setPanel(next)
}
const togglePanel = (target: 'queue' | 'chat') => {
const { route } = navigationStore.state
if (window.matchMedia('(max-width: 767px)').matches) {
navigationStore.open(target)
}
else if (route.name === target) {
navigationStore.navigate({ name: 'listen', ref: route.ref, panel: route.panel === target ? null : route.panel })
}
else {
switchPanel(panel === target ? null : target)
}
}
return (
<>
{/* 播放引擎在房间为空时就已挂载,出错只可能源于运行环境,手动重试很可能同样失败,因此不提供重试按钮。 */}
<ErrorBoundary fallback={null} resetKey={songStartedAtMs} onError={() => notificationStore.error('音乐无法播放。下一首歌开始时会自动再试,也可以刷新页面再试。', 8000)}>
<AudioEngine onNextTrack={() => void voteSkip()} />
</ErrorBoundary>
<div className="app-shell">
<div className="workspace">
<ErrorBoundary fallback={(retry, retryButton) => <header className="workspace-header region-failed"><RegionFailed message="顶栏出错了" retry={retry} retryButton={retryButton} /></header>}>
<Header page={page} />
</ErrorBoundary>
<div className="workspace-body">
<main className={`main-content ${pageClasses[page] ?? ''}`} key={page}>
<ErrorBoundary>
<Suspense fallback={pageLoading}>
{page === 'lobby' && <RoutedLobby onEntered={showRoom} />}
{page === 'admin' && <AdminPage onEntered={showRoom} />}
{page === 'listen' && <RoomGate onBrowse={browse}><ListenPage /></RoomGate>}
{page === 'queue' && <RoomGate onBrowse={browse}><QueuePage /></RoomGate>}
{page === 'search' && <RoomGate onBrowse={browse}><RoutedSearch /></RoomGate>}
{page === 'playlist' && <RoomGate onBrowse={browse}><RoutedPlaylist /></RoomGate>}
{page === 'chat' && <RoomGate onBrowse={browse}><ChatPanel /></RoomGate>}
{page === 'history' && <RoutedHistory />}
{page === 'settings' && <SettingsPage />}
</Suspense>
</ErrorBoundary>
</main>
<DetailPanel view={shownPanel}>
{view => view === 'chat'
? <ErrorBoundary><Suspense fallback={pageLoading}><RoomGate onBrowse={browse}><ChatPanel compact onClose={() => switchPanel(null)} /></RoomGate></Suspense></ErrorBoundary>
: (
<>
<div className="detail-tabs" aria-label="侧栏视图">
<button type="button" className="detail-tab text-ui-base" aria-pressed={view === 'queue'} disabled={page === 'queue'} onClick={() => switchPanel('queue')}>播放列表</button>
<button type="button" className="detail-tab text-ui-base" aria-pressed={view === 'history'} disabled={page === 'history'} onClick={() => switchPanel('history')}>播放历史</button>
<IconButton icon={X} label="关闭侧栏" onClick={() => switchPanel(null)} />
</div>
<ErrorBoundary>
<Suspense fallback={pageLoading}>
{view === 'queue' ? <RoomGate onBrowse={browse}><QueuePage compact /></RoomGate> : <HistoryPage compact />}
</Suspense>
</ErrorBoundary>
</>
)}
</DetailPanel>
</div>
</div>
<ErrorBoundary fallback={(retry, retryButton) => <section className="player-bar region-failed" aria-label="播放器"><RegionFailed message="播放器出错了" retry={retry} retryButton={retryButton} /></section>}>
<PlayerBar
onSkip={voteSkip}
onLyrics={() => navigationStore.open('listen')}
onQueue={() => togglePanel('queue')}
onChat={() => togglePanel('chat')}
queueOpen={joined && (page === 'queue' || panel === 'queue')}
chatOpen={joined && (page === 'chat' || panel === 'chat')}
canOpenPanels={joined}
/>
</ErrorBoundary>
<nav className="mobile-nav" aria-label="底部导航">
{mobileMenu.map(({ page: target, label, icon: Icon }) => (
<button
type="button"
className={`text-ui-xs ${page === target ? 'selected' : ''}`}
key={target}
aria-current={page === target ? 'page' : undefined}
onClick={() => navigationStore.open(target)}
>
<Icon size={24} strokeWidth={1.7} />
<span>{label}</span>
</button>
))}
</nav>
</div>
<ErrorBoundary
fallback={null}
resetOn={roomEntryStore}
onError={() => {
// 弹窗已不再显示,进房流程却仍停在等待票据,页面会一直显示骨架屏:与关闭弹窗一样,放弃这次进入。
navigationStore.leaveRoom()
roomEntryStore.reset()
notificationStore.error('加入房间的窗口出错了,没有加入房间,再选一次房间。')
}}
>
<EntryDialog onCancel={() => navigationStore.leaveRoom()} />
</ErrorBoundary>
<ErrorBoundary fallback={null} announce="开始播放的提示出错了。点一下页面,下一首歌会自动播放。" resetOn={playerStore}>
<ManualStartPrompt />
</ErrorBoundary>
<ErrorBoundary fallback={null} announce="更新提示出错了。关闭应用再打开,就会用上新版本。" resetOn={pwaStore}>
<UpdatePrompt />
</ErrorBoundary>
<ErrorBoundary fallback={null} announce="通知出错了,稍后恢复。" resetOn={notificationStore}>
<Notifications />
</ErrorBoundary>
</>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { ConnectionStatus } from '@/core/realtime'
import { errorText } from '@/core/errors'
import { roomStore } from '@/core/room'
import { notificationStore } from '@/shared/ui'
// 连接上的 INVALID_ARGUMENT 只可能是前端发出的报文不对,用户能做的只有刷新页面。
const connectionTexts = { INVALID_ARGUMENT: '连接房间时出错了,请刷新页面再试。' }
export function watchConnection(): void {
let last: ConnectionStatus | null = null
let outageNotified = false
roomStore.addEventListener('change', () => {
const status = roomStore.state.status
if (status === last)
return
last = status
if (status === 'open') {
outageNotified = false
notificationStore.success('已连上房间')
}
else if (status === 'reconnecting' && !outageNotified) {
outageNotified = true
notificationStore.error('与房间的连接中断了,正在自动重连。', 5000)
}
})
// 握手时票据被拒绝会以 4401 断开,进房流程随后重新打开加入弹窗,不另外弹通知。
roomStore.errors.on((error) => {
if (error.code === 'UNAUTHORIZED')
return
const message = errorText(error.code, error.message, connectionTexts)
// 协议版本不一致时页面随后自动刷新,用户不用处理。
if (error.code === 'CLIENT_OUTDATED')
notificationStore.info(message)
else
notificationStore.error(message)
})
}
+42
View File
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react'
import type { Panel } from './route'
import { useEffect, useRef, useState } from 'react'
import { canPushViews } from './view-transition'
export function DetailPanel({ view, children }: { view: Panel | null, children: (view: Panel) => ReactNode }) {
const [shown, setShown] = useState(view)
const [lastView, setLastView] = useState(view)
const [swapped, setSwapped] = useState(false)
const panelRef = useRef<HTMLElement>(null)
if (view !== lastView) {
setLastView(view)
setSwapped(view !== null && lastView !== null)
if (view)
setShown(view)
}
const open = view !== null
useEffect(() => {
if (open || !shown || !panelRef.current)
return
const duration = Number.parseFloat(getComputedStyle(panelRef.current).animationDuration) * 1000
// 动画被取消或面板被断点隐藏时不会触发 animationend,由定时器兜底卸载。
const timer = setTimeout(setShown, duration ? duration + 50 : 0, null)
return () => clearTimeout(timer)
}, [open, shown])
if (!shown)
return null
return (
<aside
ref={panelRef}
className={`detail-panel${open ? '' : ' detail-panel-closing'}`}
aria-hidden={!open || undefined}
inert={!open}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget && !open)
setShown(null)
}}
>
<div className={`detail-panel-content${swapped && !canPushViews() ? ' detail-panel-swap' : ''}`} key={shown}>{children(shown)}</div>
</aside>
)
}
+95
View File
@@ -0,0 +1,95 @@
import type { ReactNode, RefObject } from 'react'
import { Component, createRef } from 'react'
import { EmptyState } from '@/shared/ui'
interface ErrorBoundaryProps {
children: ReactNode
// fallback 为函数时,须把 retryButton 挂到"重试"按钮上:出错时若焦点在区域内,边界通过它把焦点移到按钮上。
fallback?: ReactNode | ((retry: () => void, retryButton: RefObject<HTMLButtonElement | null>) => ReactNode)
resetOn?: EventTarget
// 只在值变化时重试一次。resetOn 在 Store 每次变化时都会重试,Store 变化频繁时,持续出错的区域会反复失败。
resetKey?: number
onError?: () => void
// 仅当 fallback 为 null 且出错时焦点在区域内,才向屏幕阅读器播报。
announce?: string
}
// 出错状态保存在实例上。主内容和面板内容的边界位于以页面或视图为 key 的元素内,切换时随之重新挂载,出错状态即被清除。
export class ErrorBoundary extends Component<ErrorBoundaryProps, { failed: boolean }, Element | null> {
state = { failed: false }
retryButton = createRef<HTMLButtonElement>()
announcement = createRef<HTMLParagraphElement>()
static getDerivedStateFromError() {
return { failed: true }
}
componentDidMount() {
this.props.resetOn?.addEventListener('change', this.reset)
}
// 出错的内容卸载时,其中的焦点会落到 body 上:卸载前先记下当前焦点。
getSnapshotBeforeUpdate() {
return document.activeElement
}
componentDidUpdate(previous: ErrorBoundaryProps, previousState: { failed: boolean }, focused: Element | null) {
const { resetOn, resetKey, announce } = this.props
if (previous.resetOn !== resetOn) {
previous.resetOn?.removeEventListener('change', this.reset)
resetOn?.addEventListener('change', this.reset)
}
if (previousState.failed && previous.resetKey !== resetKey)
this.reset()
const focusLost = this.state.failed && !previousState.failed && focused !== null && !focused.isConnected && document.activeElement === document.body
if (!focusLost)
return
this.retryButton.current?.focus()
if (this.announcement.current && announce)
this.announcement.current.textContent = announce
}
componentDidCatch() {
this.props.onError?.()
}
componentWillUnmount() {
this.props.resetOn?.removeEventListener('change', this.reset)
}
retry = () => {
this.setState({ failed: false })
}
reset = () => {
if (this.state.failed)
this.retry()
}
render() {
if (!this.state.failed)
return this.props.children
const { fallback, announce } = this.props
if (fallback === undefined) {
return (
<div className="page-content">
<EmptyState title="这部分内容出错了" action={<button type="button" className="button-primary" ref={this.retryButton} onClick={this.retry}>重试</button>}>
再试一次,或者先看看别的内容。播放不受影响。
</EmptyState>
</div>
)
}
if (fallback === null)
return announce ? <p className="sr-only" role="status" ref={this.announcement} /> : null
return typeof fallback === 'function' ? fallback(this.retry, this.retryButton) : fallback
}
}
export function RegionFailed({ message, retry, retryButton }: { message: string, retry: () => void, retryButton: RefObject<HTMLButtonElement | null> }) {
return (
<>
<p className="text-ui-caption muted" role="status">{message}</p>
<button type="button" className="button-secondary" ref={retryButton} onClick={retry}>重试</button>
</>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { brandStore } from '@/core/brand'
import { chatStore } from '@/features/chat'
import { historyStore } from '@/features/history'
import { lyricsStore } from '@/features/lyrics'
import { playerStore, reminderStore, volumeStore } from '@/features/playback'
import { roomEntryStore } from '@/features/rooms'
import { searchStore } from '@/features/search'
import { appearanceStore, performanceStore, profileStore } from '@/features/settings'
import { setViewportHeight } from '@/shared/lib/viewport'
import { App } from './app'
import { watchConnection } from './connection-notices'
import { navigationStore } from './navigation'
import { pwaStore } from './pwa'
import './style.css'
const root = document.getElementById('root')
if (!root)
throw new Error('index.html 缺少 #root 节点')
// 在首次渲染和进入房间之前启动:外观、性能等级、音量和品牌名要在第一帧生效,订阅房间的 Store 不能错过进入房间。
// 聊天面板按需加载,歌词页也不常驻,但聊天、搜索和歌词的 Store 要从进入房间起跟随房间变化,不能等到页面挂载。
appearanceStore.start()
performanceStore.start()
volumeStore.start()
brandStore.start()
profileStore.start()
playerStore.start()
reminderStore.start()
historyStore.start()
roomEntryStore.start()
chatStore.start()
searchStore.start()
lyricsStore.start()
// 启动时获取成员身份,并借此发现已失效的成员 token。失败不影响浏览大厅,之后的请求会再次尝试。
void profileStore.load().catch(() => {})
watchConnection()
pwaStore.start()
navigationStore.start()
setViewportHeight()
window.addEventListener('resize', setViewportHeight, { passive: true })
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
+161
View File
@@ -0,0 +1,161 @@
import type { Panel, RoomPage, Route } from './route'
import type { SearchQuery } from '@/features/search'
import { roomEntryStore } from '@/features/rooms'
import { searchStore } from '@/features/search'
import { canGoBack, canGoForward, currentEntry, goBack, startHistory, writeEntry } from '@/shared/lib/session-history'
import { Store } from '@/shared/lib/store'
import { formatRoute, isNewEntry, leaveStep, parseRoute } from './route'
interface NavigationState {
route: Route
// 写入这条记录时已经知道的页面标题,比如打开的歌单的名字。
title: string | undefined
canBack: boolean
canForward: boolean
}
const lobby: Route = { name: 'lobby', filter: '', panel: null }
function isCurrentRoom(ref: string): boolean {
const { ref: entered, room } = roomEntryStore.state
return ref === entered || ref === room?.id || ref === room?.slug
}
// 显示什么由地址决定;后退和前进就是浏览器历史记录的后退和前进。
class NavigationStore extends Store<NavigationState> {
#narrow: MediaQueryList | null = null
// 取消进入房间之后正在离开的房间:进房时的 ref、UUID 和别名。
#leaving: ReadonlySet<string> | null = null
constructor() {
super({ route: lobby, title: undefined, canBack: false, canForward: false })
}
start(): void {
const fresh = startHistory(this.#onPop)
this.#narrow = window.matchMedia('(max-width: 767px)')
this.#narrow.addEventListener('change', this.#onResize)
const parsed = parseRoute(window.location)
let route = this.#fit(parsed ?? lobby)
// 上次的房间记在所有标签页共用的 localStorage 里,后台又常在另一个标签页打开:后台不恢复房间,免得两个标签页同时放歌。
if (!('ref' in route) && route.name !== 'admin') {
const lastRoom = roomEntryStore.resume()
// 直接打开网站时进入上次的房间;刷新或者回到发现房间时停在原处。
if (lastRoom && fresh && parsed && formatRoute(parsed) === '/')
route = { name: 'listen', ref: lastRoom, panel: null }
}
const url = formatRoute(route)
if (url !== window.location.pathname + window.location.search)
writeEntry(url, true)
this.#show(route)
}
navigate(route: Route, title?: string): void {
const from = this.state.route
const url = formatRoute(route)
if (url === formatRoute(from))
return
writeEntry(url, !isNewEntry(from, route), title)
this.#show(route)
}
replace(route: Route): void {
writeEntry(formatRoute(route), true)
this.#show(route)
}
// 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。
open(page: 'lobby' | 'admin' | 'settings' | 'history' | RoomPage): void {
const { panel } = this.state.route
if (page === 'lobby' || page === 'history')
this.navigate({ name: page, filter: '', panel })
else if (page === 'admin' || page === 'settings')
this.navigate({ name: page, panel })
else
this.#openRoomPage(ref => ({ name: page, ref, panel }))
}
search(query: SearchQuery): void {
const { panel } = this.state.route
this.#openRoomPage(ref => ({ name: 'search', ref, panel, query }))
}
setPanel(panel: Panel | null): void {
this.navigate({ ...this.state.route, panel })
}
// 取消进入房间:要在 roomEntryStore.reset() 之前调用,离开时靠进房的信息认出这个房间的地址。
leaveRoom(): void {
const { ref, room } = roomEntryStore.state
this.#leaving = new Set([ref, room?.id, room?.slug].filter(value => typeof value === 'string'))
this.#leaveFrom(this.state.route, this.#leaving)
}
back(): void {
window.history.back()
}
forward(): void {
window.history.forward()
}
#openRoomPage(route: (ref: string) => Route): void {
const { route: current } = this.state
const ref = 'ref' in current ? current.ref : roomEntryStore.state.ref
this.navigate(ref ? route(ref) : lobby)
}
// 返回这一步是否已经处理:后退了,或者换成了发现房间。
#leaveFrom(route: Route, leaving: ReadonlySet<string>): boolean {
switch (leaveStep(route, leaving, canGoBack(currentEntry().position))) {
case 'back':
goBack()
return true
case 'lobby':
this.#leaving = null
this.replace(lobby)
return true
case 'stay':
this.#leaving = null
return false
}
}
// 窄屏没有侧栏:打开带侧栏的地址时去掉它。
#fit(route: Route): Route {
return this.#narrow?.matches && route.panel ? { ...route, panel: null } : route
}
#show(next: Route): void {
// 关闭弹窗回到的记录地址不变:沿用原来的对象,订阅 route 的组件不必重新渲染。
const route = formatRoute(next) === formatRoute(this.state.route) ? this.state.route : next
if ('ref' in route && !isCurrentRoom(route.ref))
void roomEntryStore.enter(route.ref)
if (route.name === 'search')
searchStore.show(route.query)
else if (route.name === 'playlist')
searchStore.showPlaylist(route.source, route.id)
else
searchStore.hide()
const { position, title } = currentEntry()
this.setState({ route, title, canBack: canGoBack(position), canForward: canGoForward(position) })
}
#onPop = (): void => {
const parsed = parseRoute(window.location) ?? lobby
if (this.#leaving && this.#leaveFrom(parsed, this.#leaving))
return
const route = this.#fit(parsed)
if (route !== parsed)
writeEntry(formatRoute(route), true)
this.#show(route)
}
#onResize = (): void => {
const route = this.#fit(this.state.route)
if (route !== this.state.route)
this.replace(route)
}
}
export const navigationStore = new NavigationStore()
+73
View File
@@ -0,0 +1,73 @@
import { registerSW } from 'virtual:pwa-register'
import { CloseCode, reconnectDelay } from '@/core/realtime'
import { roomStore } from '@/core/room'
import { Store } from '@/shared/lib/store'
const updateCheckIntervalMs = 30 * 60 * 1000
const outdatedReloadsKey = 'alisten.outdated-reloads'
class PwaStore extends Store<{ needRefresh: boolean }> {
#registration: ServiceWorkerRegistration | undefined
#updateServiceWorker: (() => Promise<void>) | undefined
#outdated = false
constructor() {
super({ needRefresh: false })
}
start(): void {
this.#updateServiceWorker = registerSW({
onRegisteredSW: (_url, registration) => {
this.#registration = registration
setInterval(() => {
if (document.visibilityState === 'visible')
void registration?.update()
}, updateCheckIntervalMs)
},
onNeedRefresh: () => {
if (this.#outdated)
void this.#updateServiceWorker?.()
else
this.setState({ needRefresh: true })
},
})
roomStore.addEventListener('change', this.#onSession)
}
// 新的 Service Worker 接管页面后,由 vite-plugin-pwa 刷新页面。
update(): void {
this.setState({ needRefresh: false })
void this.#updateServiceWorker?.()
}
dismiss(): void {
this.setState({ needRefresh: false })
}
#onSession = (): void => {
const { status, stopCode } = roomStore.state
// 握手成功说明前端与服务端的协议版本一致。
if (status === 'open')
sessionStorage.removeItem(outdatedReloadsKey)
if (stopCode === CloseCode.clientOutdated)
this.#reloadOutdated()
}
// 先让新的 Service Worker 接管再刷新,否则刷新后加载的仍是缓存里的旧版本。
// 发布期间前端和服务端可能短暂处于不同的协议版本,因此连续刷新的间隔按重连的退避规则递增。
#reloadOutdated(): void {
if (this.#outdated)
return
this.#outdated = true
const reloads = Number(sessionStorage.getItem(outdatedReloadsKey) ?? 0) + 1
sessionStorage.setItem(outdatedReloadsKey, String(reloads))
const registration = this.#registration
if (registration?.waiting)
void this.#updateServiceWorker?.()
else
void registration?.update()
setTimeout(() => window.location.reload(), reconnectDelay(reloads))
}
}
export const pwaStore = new PwaStore()
+141
View File
@@ -0,0 +1,141 @@
import type { Source } from '@/core/realtime'
import type { SearchSource } from '@/features/history'
import type { SearchQuery } from '@/features/search'
import { canOpenPlaylists, defaultSearchSource, keywordSearch } from '@/features/search'
export type RoomPage = 'listen' | 'queue' | 'chat'
export type Panel = 'queue' | 'history' | 'chat'
// panel 是宽屏右侧的侧栏,和当前页面相同时不显示。
export type Route
= | { name: 'lobby', filter: string, panel: Panel | null }
| { name: 'admin', panel: Panel | null }
| { name: 'settings', panel: Panel | null }
| { name: 'history', filter: string, panel: Panel | null }
| { name: RoomPage, ref: string, panel: Panel | null }
| { name: 'search', ref: string, panel: Panel | null, query: SearchQuery }
| { name: 'playlist', ref: string, panel: Panel | null, source: Source, id: string, page: number }
export type Page = Route['name']
function parseSource(value: string | null): SearchSource | null {
return value === 'qq' || value === 'netease' ? value : null
}
function parsePanel(params: URLSearchParams): Panel | null {
const panel = params.get('panel')
return panel === 'queue' || panel === 'history' || panel === 'chat' ? panel : null
}
function parsePage(params: URLSearchParams): number {
const page = Number(params.get('page'))
return Number.isInteger(page) && page > 1 ? page : 1
}
function parseQuery(params: URLSearchParams): SearchQuery {
const view = params.get('view')
if (view === 'recommend')
return { view }
const keyword = params.get('q')?.trim() ?? ''
const query = keywordSearch(keyword, parseSource(params.get('source')) ?? defaultSearchSource, view === 'tracks' || view === 'playlists' ? view : 'all')
return keyword && query.view !== 'all' ? { ...query, page: parsePage(params) } : query
}
function writeQuery(query: SearchQuery, params: URLSearchParams): void {
if (query.view === 'recommend') {
params.set('view', 'recommend')
return
}
if (query.keyword)
params.set('q', query.keyword)
// 有关键词时总是写出来源:以后默认来源再变,旧链接的含义也不变。
if (query.keyword || query.source !== defaultSearchSource)
params.set('source', query.source)
if (query.view !== 'all')
params.set('view', query.view)
if (query.page > 1)
params.set('page', String(query.page))
}
export function parseRoute({ pathname, search }: Pick<Location, 'pathname' | 'search'>): Route | null {
const params = new URLSearchParams(search)
const panel = parsePanel(params)
if (pathname === '/')
return { name: 'lobby', filter: params.get('q') ?? '', panel }
if (pathname === '/admin')
return { name: 'admin', panel }
if (pathname === '/settings')
return { name: 'settings', panel }
if (pathname === '/history')
return { name: 'history', filter: params.get('q') ?? '', panel }
// 不做 decodeURIComponent:UUID、别名和歌单 ID 不含需要转义的字符,而它遇到不合法的转义序列会抛出异常。
const room = /^\/r\/([^/]+)(\/.*)?$/.exec(pathname)
if (!room)
return null
const [, ref, subpath = ''] = room
switch (subpath) {
case '':
return { name: 'listen', ref, panel }
case '/queue':
return { name: 'queue', ref, panel }
case '/chat':
return { name: 'chat', ref, panel }
case '/search':
return { name: 'search', ref, panel, query: parseQuery(params) }
}
const playlist = /^\/playlists\/([^/]+)\/([^/]+)$/.exec(subpath)
const source = parseSource(playlist?.[1] ?? null)
if (!playlist || !source || !canOpenPlaylists(source))
return null
return { name: 'playlist', ref, panel, source, id: playlist[2], page: parsePage(params) }
}
function pathOf(route: Route): string {
switch (route.name) {
case 'lobby':
return '/'
case 'admin':
case 'settings':
case 'history':
return `/${route.name}`
case 'listen':
return `/r/${route.ref}`
case 'playlist':
return `/r/${route.ref}/playlists/${route.source}/${route.id}`
default:
return `/r/${route.ref}/${route.name}`
}
}
export function formatRoute(route: Route): string {
const params = new URLSearchParams()
if ((route.name === 'lobby' || route.name === 'history') && route.filter)
params.set('q', route.filter)
if (route.name === 'search')
writeQuery(route.query, params)
if (route.name === 'playlist' && route.page > 1)
params.set('page', String(route.page))
if (route.panel)
params.set('panel', route.panel)
const query = params.toString()
return query ? `${pathOf(route)}?${query}` : pathOf(route)
}
// 换页面、进入另一个房间、提交新的关键词和打开另一个歌单写入新记录,后退一次回到上一个页面或上一次搜索;
// 同一个页面里换展示方式、开关侧栏和输入筛选词只替换当前地址。
export function isNewEntry(from: Route, to: Route): boolean {
if (from.name !== to.name || ('ref' in from && 'ref' in to && from.ref !== to.ref))
return true
if (from.name === 'search' && to.name === 'search')
return to.query.view !== 'recommend' && to.query.keyword !== (from.query.view === 'recommend' ? '' : from.query.keyword)
if (from.name === 'playlist' && to.name === 'playlist')
return to.source !== from.source || to.id !== from.id
return false
}
// 取消进入房间时离开这个房间的地址:在它的页面上就后退,一直退到进入房间之前的页面;没有可以后退的记录(直接打开的房间链接)时换成发现房间;不在它的页面上就留在原处。
export function leaveStep(route: Route, leaving: ReadonlySet<string>, canBack: boolean): 'back' | 'lobby' | 'stay' {
if (!('ref' in route) || !leaving.has(route.ref))
return 'stay'
return canBack ? 'back' : 'lobby'
}
+151
View File
@@ -0,0 +1,151 @@
@import 'tailwindcss';
/* @theme 和 @utility 只在 Tailwind 入口样式的导入链中生效:改从 JS 导入,text-ui-*、muted 和 accent 就不会生成,构建也不报错。 */
@import '@/shared/ui/theme.css';
@layer base {
html, body, #root { height: 100%; overflow: hidden; }
body { margin: 0; background: var(--color-background); color: var(--color-foreground); font-size: var(--text-ui-base); line-height: 1.5; }
* { box-sizing: border-box; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color 150ms, color 150ms, opacity 150ms; }
button, a, input, select, textarea { outline: none; }
button:focus-visible, a:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
button:disabled { cursor: default; opacity: .45; }
button:active:not(:disabled) { transform: scale(.98); }
h1, h2, h3, p { margin: 0; }
h1, h2, h3, strong { font-weight: 600; }
svg { flex-shrink: 0; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: var(--color-border-hover); border-radius: 8px; }
::selection { background: var(--color-selected); }
input:not([type='range']):not([type='checkbox']), textarea, select { width: 100%; min-width: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--color-input-border); background: var(--color-input); color: var(--color-foreground); font-size: var(--text-ui-base); }
input::placeholder, textarea::placeholder { color: var(--color-foreground-subtlest); opacity: 1; }
input:focus, textarea:focus, select:focus { border-color: var(--color-input-border-focused) !important; background: var(--color-input-focused); }
input[type='range'], input[type='checkbox'] { accent-color: var(--color-primary); }
input[type='range'] { width: 100%; height: 18px; cursor: pointer; }
input[type='checkbox'] { width: 16px; height: 16px; flex-shrink: 0; }
@media (max-width: 767px) {
input:not([type='range']):not([type='checkbox']), textarea, select { font-size: var(--text-mobile-input-safe); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
:root[data-performance='off'] *, :root[data-performance='off'] *::before, :root[data-performance='off'] *::after,
:root[data-reduced-motion] *, :root[data-reduced-motion] *::before, :root[data-reduced-motion] *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
:root[data-performance='off'] * { box-shadow: none !important; filter: none !important; will-change: auto !important; }
:root[data-performance='low'] button { transition: opacity 160ms; }
}
.app-shell { height: calc(var(--vh, 1vh) * 100); height: 100dvh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 104px; overflow: hidden; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--color-frame); }
.workspace { grid-column: 1; grid-row: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.app-shell > .player-bar { grid-column: 1; grid-row: 2; }
.workspace-header { position: relative; z-index: 30; height: 76px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 24px; background: var(--color-frame); color: var(--color-foreground); border: 0; border-radius: 0; }
.header-leading { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.region-failed { justify-content: center; gap: 12px; }
.header-menu { position: relative; }
.header-menu summary { list-style: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; }
.header-menu summary::-webkit-details-marker { display: none; }
.header-menu summary:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
.header-menu summary:hover { background: var(--color-frame-hover); }
.main-nav { display: flex; flex-direction: column; gap: 4px; }
.header-menu .main-nav { position: absolute; left: 0; top: calc(100% + 12px); width: 180px; padding: 6px; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-popover); box-shadow: 0 8px 24px var(--color-shadow); transform-origin: top left; }
/* details 收起时内容仍在 DOM 中,动画挂在 [open] 上才会在每次展开时重放。 */
.header-menu[open] .main-nav { animation: popover-in 200ms cubic-bezier(.2, .8, .2, 1) both; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); text-align: left; }
.nav-item svg { color: var(--color-brand); }
.nav-item:hover { background: var(--color-hover); }
.nav-item.selected { background: var(--color-selected); }
.header-search-area { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; max-width: 680px; margin: 0 auto; }
.header-leading > .icon-button, .header-search-area > .icon-button { --color-hover: var(--color-frame-hover); }
.home-widget { width: 44px; height: 44px; border-radius: 50%; background: var(--color-frame-surface); }
.home-widget[aria-current='page'] { color: var(--color-brand); }
.header-actions { display: flex; align-items: center; gap: 8px; }
.connection-status { display: flex; align-items: center; gap: 4px; padding: 4px 6px; color: var(--color-foreground-subtle); white-space: nowrap; border: 0; background: none; border-radius: 4px; }
.connection-status:disabled { opacity: 1; }
.connection-status.connected { color: var(--color-success); }
.profile-button { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%; background: var(--color-frame-surface); color: var(--color-foreground-subtle); }
.profile-button .avatar { width: 36px; }
.icon-button.mobile-search-button { display: none; }
.mobile-search-box { display: none; }
.workspace-body { display: flex; flex: 1; min-height: 0; gap: 8px; padding: 8px; background: var(--color-frame); }
.main-content { flex: 1; min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; border: 0; border-radius: 12px; background: var(--color-content); animation: page-swap-in 180ms cubic-bezier(.2, .8, .2, 1) both; }
.main-content.scrollbar-hidden { scrollbar-width: none; scrollbar-gutter: auto; }
.main-content.scrollbar-hidden::-webkit-scrollbar { display: none; }
.main-content.settings-view { overflow: hidden; scrollbar-gutter: auto; background: var(--color-background); }
@keyframes page-swap-in {
from { opacity: 0; }
to { opacity: 1; }
}
.detail-panel { --detail-panel-width: 480px; width: var(--detail-panel-width); flex-shrink: 0; position: relative; min-height: 0; display: flex; flex-direction: column; border: 0; border-radius: 12px; background: var(--color-panel); overflow: hidden; animation: detail-panel-open 240ms cubic-bezier(.2, .8, .2, 1) both; }
.detail-panel-content { view-transition-name: detail-panel-content; width: var(--detail-panel-width); flex: 1; min-height: 0; display: flex; flex-direction: column; }
.detail-panel-closing { pointer-events: none; animation-name: detail-panel-close; }
@keyframes detail-panel-open {
from { width: 0; margin-left: -8px; opacity: 0; transform: translateX(16px); }
to { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
}
@keyframes detail-panel-close {
from { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
to { width: 0; margin-left: -8px; opacity: 0; transform: translateX(16px); }
}
/* 视图过渡只移动面板内容:它的过渡分组裁剪在面板内,根快照不做动画,顶栏、主内容和播放器保持不动。 */
::view-transition-group(root) { animation: none; overflow: clip; }
::view-transition-image-pair(root) { isolation: auto; }
::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; animation: none; }
::view-transition-group(detail-panel-content) { overflow: clip; }
::view-transition-image-pair(detail-panel-content) { isolation: auto; }
::view-transition-old(detail-panel-content), ::view-transition-new(detail-panel-content) { background: var(--color-panel); mix-blend-mode: normal; }
::view-transition-old(detail-panel-content) { animation: view-recede 300ms cubic-bezier(.2, .8, .2, 1) both; }
::view-transition-new(detail-panel-content) { animation: view-push-in 300ms cubic-bezier(.2, .8, .2, 1) both; }
@keyframes view-recede {
from { transform: translateX(0); }
to { transform: translateX(-14%); }
}
@keyframes view-push-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.detail-panel-swap { animation: detail-panel-swap-in 180ms cubic-bezier(.2, .8, .2, 1) both; }
@keyframes detail-panel-swap-in {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
:root[data-performance='low'] .header-menu[open] .main-nav { animation: fade-in 160ms ease both; }
:root[data-performance='low'] .main-content { animation: fade-in 140ms ease both; }
:root[data-performance='low'] .detail-panel { animation: fade-in 120ms ease both; }
:root[data-performance='low'] .detail-panel-closing { animation-name: fade-out; }
:root[data-performance='low'] .detail-panel-swap { animation: fade-in 140ms ease both; }
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.detail-tabs { display: flex; align-items: center; gap: 20px; padding: 12px 16px; flex-shrink: 0; }
.detail-tabs .icon-button { margin-left: auto; }
.detail-tab { padding: 8px 0; color: var(--color-foreground-subtle); border-bottom: 2px solid transparent; }
.detail-tab[aria-pressed='true'] { color: var(--color-foreground); border-bottom-color: var(--color-brand); }
.mobile-nav { display: none; }
@media (max-width: 1100px) {
.detail-panel { --detail-panel-width: 420px; }
}
@media (max-width: 767px) {
.app-shell { grid-template-rows: minmax(0, 1fr) auto auto; padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); padding-bottom: 0; }
.workspace-header { height: 64px; padding: 0 12px; gap: 8px; }
.header-leading { gap: 0; }
.header-leading .icon-button, .header-leading .header-menu summary { width: 28px; height: 36px; }
.header-leading .header-forward { display: none; }
.header-search-area { gap: 6px; }
.header-search-area > .header-search { display: none; }
.home-widget { width: 32px; height: 36px; }
.icon-button.mobile-search-button { display: inline-flex; color: var(--color-foreground-subtle); }
.mobile-search-button[aria-current='page'] { color: var(--color-brand); }
.mobile-search-box { display: block; }
.connection-status { display: none; }
.header-actions { gap: 0; }
.detail-panel { display: none; }
.workspace-body { padding: 0; gap: 0; background: var(--color-content); }
.main-content { border: 0; border-radius: 0; }
.app-shell > .player-bar { margin: 6px 8px; }
.mobile-nav { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); grid-row: 3; grid-column: 1; background: var(--color-frame); padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); border: 0; }
.mobile-nav button { display: flex; flex-direction: column; gap: 2px; align-items: center; justify-content: center; min-height: 48px; background: none; color: var(--color-foreground-subtle); border: 0; border-radius: 8px; }
.mobile-nav button:active { background: var(--color-frame-hover); }
.mobile-nav .selected { color: var(--color-brand); }
}
+18
View File
@@ -0,0 +1,18 @@
import { useStore } from '@/shared/lib/use-store'
import { Modal } from '@/shared/ui'
import { pwaStore } from './pwa'
export function UpdatePrompt() {
const needRefresh = useStore(pwaStore, s => s.needRefresh)
if (!needRefresh)
return null
return (
<Modal title="新版本已就绪" onClose={() => pwaStore.dismiss()}>
<p className="text-ui-base muted">更新后会重新加载应用,并重新连上房间。</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={() => pwaStore.dismiss()}>稍后再说</button>
<button type="button" className="button-primary" onClick={() => pwaStore.update()}>立即更新</button>
</div>
</Modal>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { flushSync } from 'react-dom'
import { performanceStore } from '@/features/settings'
export function canPushViews(): boolean {
return 'startViewTransition' in document && performanceStore.smoothMotion()
}
export function withViewTransition(update: () => void): void {
if (!canPushViews()) {
update()
return
}
document.startViewTransition(() => {
// eslint-disable-next-line react/dom-no-flush-sync -- 视图过渡要求在更新回调中同步提交
flushSync(update)
})
}
+175
View File
@@ -0,0 +1,175 @@
import type { Auth, CreateTicketRequest, CreateTicketResponse, ErrorResponse, HttpRequest } from './http.gen'
import type { ErrorCode, Member } from '@/core/realtime'
import { Emitter } from '@/shared/lib/emitter'
import { deleteMemberToken, loadAdminToken, loadMemberToken, saveMemberToken } from './credentials'
import { httpClient } from './http.gen'
export class ApiError extends Error {
readonly code: ErrorCode | null
readonly status: number
constructor(message: string, code: ErrorCode | null, status: number) {
super(message)
this.name = 'ApiError'
this.code = code
this.status = status
}
}
// 被拒绝的请求不会重发,操作没有生效:调用方的提示应说明是哪项操作。
export class IdentityRenewedError extends ApiError {
constructor() {
super('刚才的操作没有生效,再试一次。', 'UNAUTHORIZED', 401)
this.name = 'IdentityRenewedError'
}
}
// rejected:被拒绝的 token 已删除,新身份尚未取回;renewalFailed:新身份没有取回,之后需要成员身份的请求在没有 token 时会先获取一个。
export type IdentityEvent
= | { kind: 'rejected' }
| { kind: 'renewed', member: Member }
| { kind: 'renewalFailed' }
export const identityEvents = new Emitter<IdentityEvent>()
function isErrorResponse(body: unknown): body is ErrorResponse {
if (typeof body !== 'object' || body === null || !('error' in body))
return false
const { error } = body
return typeof error === 'object' && error !== null && 'code' in error && 'message' in error && typeof error.message === 'string'
}
async function responseError(response: Response): Promise<ApiError> {
// 入口或代理自行返回的错误(如服务端不可用时的 502)不带协议定义的错误响应体。
const body: unknown = await response.json().catch(() => null)
if (isErrorResponse(body))
return new ApiError(body.error.message, body.error.code, response.status)
return new ApiError('服务器暂时不可用,请稍后再试。', null, response.status)
}
async function credential(auth: Auth): Promise<string | null> {
switch (auth) {
case 'none':
return null
case 'member':
return memberToken()
case 'admin':
return loadAdminToken()
}
}
async function sendOnce<T>(request: HttpRequest): Promise<T> {
const headers = new Headers()
const token = await credential(request.auth)
if (token)
headers.set('Authorization', `Bearer ${token}`)
let body: string | undefined
if (request.body !== undefined) {
headers.set('Content-Type', 'application/json')
body = JSON.stringify(request.body)
}
let response: Response
try {
response = await fetch(request.path, { method: request.method, headers, body })
}
catch {
throw new ApiError('无法连接服务器,请检查网络后再试。', null, 0)
}
if (!response.ok)
throw await responseError(response)
if (response.status === 204)
return undefined as T
return await response.json() as T
}
function isUnauthorized(error: unknown): boolean {
return error instanceof ApiError && error.code === 'UNAUTHORIZED'
}
// 被拒绝的请求不重发:它以原来的身份发出,是否以新身份重发应由用户决定。
async function send<T>(request: HttpRequest): Promise<T> {
// 没有保存的 token 时,请求使用的是刚获取的 token,它被拒绝并不说明原来的身份已经失效。
const stored = request.auth === 'member' ? loadMemberToken() : null
try {
return await sendOnce<T>(request)
}
catch (error) {
if (stored === null || !isUnauthorized(error))
throw error
await renewIdentity(stored)
throw new IdentityRenewedError()
}
}
const unrenewed = httpClient(sendOnce)
const renewing = httpClient(send)
// 换取票据返回的 UNAUTHORIZED 既可能表示成员 token 无效,也可能表示房间密码错误,需要用 GET /api/me 区分。
// 换取票据的请求本身不更换身份,否则房间密码错误也会触发更换身份。
async function createTicket(id: string, body: CreateTicketRequest): Promise<CreateTicketResponse> {
const stored = loadMemberToken()
try {
return await unrenewed.createTicket(id, body)
}
catch (error) {
if (stored !== null && isUnauthorized(error))
await confirmMemberToken()
throw error
}
}
async function confirmMemberToken(): Promise<void> {
try {
await renewing.getMe()
}
catch (error) {
if (error instanceof IdentityRenewedError)
throw error
}
}
export const api = {
...renewing,
createTicket,
}
let creatingMember: Promise<string> | null = null
function memberToken(): Promise<string> {
const stored = loadMemberToken()
if (stored)
return Promise.resolve(stored)
creatingMember ??= api.createMember().then(({ memberToken }) => {
saveMemberToken(memberToken)
return memberToken
}).finally(() => {
creatingMember = null
})
return creatingMember
}
// 同一个 token 只更换一次身份:同时被拒绝的请求共用这一次,更换完成后才到达的拒绝也不再更换。
let renewal: { rejected: string, done: Promise<void> } | null = null
function renewIdentity(rejected: string): Promise<void> {
if (renewal?.rejected !== rejected)
renewal = { rejected, done: replaceIdentity(rejected) }
return renewal.done
}
// 更换过程中用 unrenewed 读取成员:新 token 也被拒绝时不再更换,避免循环。
async function replaceIdentity(rejected: string): Promise<void> {
// 其他标签页可能已经更换了身份,只删除被拒绝的那个 token。
if (loadMemberToken() === rejected)
deleteMemberToken()
identityEvents.emit({ kind: 'rejected' })
let member: Member
try {
member = (await unrenewed.getMe()).member
}
catch (error) {
identityEvents.emit({ kind: 'renewalFailed' })
throw error
}
identityEvents.emit({ kind: 'renewed', member })
}
+39
View File
@@ -0,0 +1,39 @@
const memberTokenKey = 'alisten.member-token'
const adminTokenKey = 'alisten.admin-token'
const ticketKeyPrefix = 'alisten.ticket.'
export function loadMemberToken(): string | null {
return localStorage.getItem(memberTokenKey)
}
export function saveMemberToken(token: string): void {
localStorage.setItem(memberTokenKey, token)
}
export function deleteMemberToken(): void {
localStorage.removeItem(memberTokenKey)
}
export function loadTicket(roomId: string): string | null {
return localStorage.getItem(ticketKeyPrefix + roomId)
}
export function saveTicket(roomId: string, ticket: string): void {
localStorage.setItem(ticketKeyPrefix + roomId, ticket)
}
export function deleteTicket(roomId: string): void {
localStorage.removeItem(ticketKeyPrefix + roomId)
}
export function loadAdminToken(): string | null {
return sessionStorage.getItem(adminTokenKey)
}
export function saveAdminToken(token: string): void {
sessionStorage.setItem(adminTokenKey, token)
}
export function deleteAdminToken(): void {
sessionStorage.removeItem(adminTokenKey)
}
+184
View File
@@ -0,0 +1,184 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { APIKey, ErrorInfo, Member, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia } from '../realtime/protocol.gen'
export interface AdminCheckBilibiliLoginRequest {
key: string
}
export interface AdminCheckNeteaseLoginRequest {
key: string
}
export interface AdminCheckQQLoginRequest {
key: string
type: QQLoginType
}
export interface AdminCreateRoomRequest {
description: string
name: string
password?: string
slug?: string
}
export interface AdminCreateRoomResponse {
id: string
}
export interface AdminGetBilibiliCredentialResponse {
account: SourceAccount | null
set: boolean
}
export interface AdminGetNeteaseCredentialResponse {
account: SourceAccount | null
set: boolean
}
export interface AdminGetQQCredentialResponse {
account: SourceAccount | null
set: boolean
}
export interface AdminGetRoomApiKeyResponse {
createdAtMs: number
set: boolean
}
export interface AdminGetYoutubeCredentialResponse {
set: boolean
updatedAtMs: number
}
export interface AdminListMediaResponse {
items: StoredMedia[]
}
export interface AdminListRoomsResponse {
items: RoomListing[]
}
export type AdminResetRoomApiKeyRequest = Record<string, never>
export interface AdminSetYoutubeCredentialRequest {
cookies: string
}
export type AdminStartBilibiliLoginRequest = Record<string, never>
export type AdminStartNeteaseLoginRequest = Record<string, never>
export interface AdminStartQQLoginRequest {
type: QQLoginType
}
export interface AdminUpdateRoomRequest {
description?: string
name?: string
password?: string
slug?: string
}
export interface AdminUpdateSiteRequest {
brandName: string
}
export interface AdminUpdateSiteResponse {
brandName: string
}
export type CreateMemberRequest = Record<string, never>
export interface CreateMemberResponse {
member: Member
memberToken: string
}
export interface CreateRoomRequest {
description: string
name: string
password?: string
}
export interface CreateRoomResponse {
id: string
}
export interface CreateTicketRequest {
password?: string
}
export interface CreateTicketResponse {
ticket: string
}
export interface GetMeResponse {
member: Member
}
export interface GetSiteResponse {
brandName: string
}
export interface ListRoomsResponse {
items: RoomListing[]
}
export interface UpdateMeRequest {
email?: string
name?: string
}
export interface UpdateMeResponse {
member: Member
}
export interface ErrorResponse {
error: ErrorInfo
}
export type Auth = 'none' | 'member' | 'admin'
export interface HttpRequest {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
path: string
auth: Auth
body?: unknown
}
export type HttpSend = <T>(request: HttpRequest) => Promise<T>
export function httpClient(send: HttpSend) {
return {
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
getMe: () => send<GetMeResponse>({ method: 'GET', path: '/api/me', auth: 'member' }),
updateMe: (body: UpdateMeRequest) => send<UpdateMeResponse>({ method: 'PATCH', path: '/api/me', auth: 'member', body }),
listRooms: () => send<ListRoomsResponse>({ method: 'GET', path: '/api/rooms', auth: 'none' }),
getRoom: (ref: string) => send<RoomListing>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
createTicket: (id: string, body: CreateTicketRequest) => send<CreateTicketResponse>({ method: 'POST', path: `/api/rooms/${encodeURIComponent(id)}/tickets`, auth: 'member', body }),
getSite: () => send<GetSiteResponse>({ method: 'GET', path: '/api/site', auth: 'none' }),
adminListRooms: () => send<AdminListRoomsResponse>({ method: 'GET', path: '/api/admin/rooms', auth: 'admin' }),
adminCreateRoom: (body: AdminCreateRoomRequest) => send<AdminCreateRoomResponse>({ method: 'POST', path: '/api/admin/rooms', auth: 'admin', body }),
adminUpdateRoom: (id: string, body: AdminUpdateRoomRequest) => send<void>({ method: 'PATCH', path: `/api/admin/rooms/${encodeURIComponent(id)}`, auth: 'admin', body }),
adminDeleteRoom: (id: string) => send<void>({ method: 'DELETE', path: `/api/admin/rooms/${encodeURIComponent(id)}`, auth: 'admin' }),
adminGetRoomApiKey: (id: string) => send<AdminGetRoomApiKeyResponse>({ method: 'GET', path: `/api/admin/rooms/${encodeURIComponent(id)}/api-key`, auth: 'admin' }),
adminResetRoomApiKey: (id: string) => send<APIKey>({ method: 'POST', path: `/api/admin/rooms/${encodeURIComponent(id)}/api-key`, auth: 'admin', body: {} }),
adminGetNeteaseCredential: () => send<AdminGetNeteaseCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/netease', auth: 'admin' }),
adminGetQqCredential: () => send<AdminGetQQCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/qq', auth: 'admin' }),
adminStartNeteaseLogin: () => send<QRLogin>({ method: 'POST', path: '/api/admin/credentials/netease/qr-logins', auth: 'admin', body: {} }),
adminCheckNeteaseLogin: (body: AdminCheckNeteaseLoginRequest) => send<QRLoginResult>({ method: 'POST', path: '/api/admin/credentials/netease/qr-logins/status', auth: 'admin', body }),
adminStartQqLogin: (body: AdminStartQQLoginRequest) => send<QRLogin>({ method: 'POST', path: '/api/admin/credentials/qq/qr-logins', auth: 'admin', body }),
adminCheckQqLogin: (body: AdminCheckQQLoginRequest) => send<QRLoginResult>({ method: 'POST', path: '/api/admin/credentials/qq/qr-logins/status', auth: 'admin', body }),
adminGetBilibiliCredential: () => send<AdminGetBilibiliCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/bilibili', auth: 'admin' }),
adminStartBilibiliLogin: () => send<QRLogin>({ method: 'POST', path: '/api/admin/credentials/bilibili/qr-logins', auth: 'admin', body: {} }),
adminCheckBilibiliLogin: (body: AdminCheckBilibiliLoginRequest) => send<QRLoginResult>({ method: 'POST', path: '/api/admin/credentials/bilibili/qr-logins/status', auth: 'admin', body }),
adminGetYoutubeCredential: () => send<AdminGetYoutubeCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/youtube', auth: 'admin' }),
adminSetYoutubeCredential: (body: AdminSetYoutubeCredentialRequest) => send<void>({ method: 'PUT', path: '/api/admin/credentials/youtube', auth: 'admin', body }),
adminDeleteYoutubeCredential: () => send<void>({ method: 'DELETE', path: '/api/admin/credentials/youtube', auth: 'admin' }),
adminListMedia: () => send<AdminListMediaResponse>({ method: 'GET', path: '/api/admin/media', auth: 'admin' }),
adminDeleteMedia: (key: string) => send<void>({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }),
adminUpdateSite: (body: AdminUpdateSiteRequest) => send<AdminUpdateSiteResponse>({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }),
}
}
+4
View File
@@ -0,0 +1,4 @@
export { api, ApiError, identityEvents, IdentityRenewedError } from './client'
export type { IdentityEvent } from './client'
export { deleteAdminToken, deleteTicket, loadAdminToken, loadTicket, saveAdminToken, saveTicket } from './credentials'
export type * from './http.gen'
+39
View File
@@ -0,0 +1,39 @@
import { api } from '@/core/api'
import { Store } from '@/shared/lib/store'
const nameKey = 'alisten.brand-name'
const defaultName = 'Alisten'
class BrandStore extends Store<{ name: string }> {
// 只采用最后一次读取或保存带回的名字:先发出的读取可能在后台改名之前就读到了旧的名字。
#requests = 0
constructor() {
super({ name: defaultName })
}
// 先用这台设备记下的品牌名,打开页面时才不会先闪一下默认的名字;取不到最新的品牌名时沿用它,不提示。
start(): void {
this.setState({ name: localStorage.getItem(nameKey) || defaultName })
void this.load().catch(() => {})
}
async load(): Promise<void> {
const request = ++this.#requests
const { brandName } = await api.getSite()
if (request === this.#requests)
this.#remember(brandName)
}
setName(name: string): void {
++this.#requests
this.#remember(name)
}
#remember(name: string): void {
localStorage.setItem(nameKey, name)
this.setState({ name })
}
}
export const brandStore = new BrandStore()
+1
View File
@@ -0,0 +1 @@
export { brandStore } from './brand-store'
+59
View File
@@ -0,0 +1,59 @@
// 每 5 秒采集一个样本,12 个样本约覆盖 1 分钟。
const sampleLimit = 12
interface Sample {
offsetMs: number
roundTripMs: number
}
export class Clock extends EventTarget {
readonly #localNow: () => number
#samples: Sample[] = []
#resetAtMs = Number.NEGATIVE_INFINITY
#offsetMs: number | null = null
constructor(localNow: () => number) {
super()
this.#localNow = localNow
}
get offsetMs(): number | null {
return this.#offsetMs
}
now(): number {
return this.#localNow()
}
serverNow(): number | null {
return this.#offsetMs === null ? null : this.#localNow() + this.#offsetMs
}
addSample(sentAtMs: number, receivedAtMs: number, serverTimeMs: number): void {
// 清空样本之前发出的 ping 跨越了清空时刻,其往返时间不可信。
if (sentAtMs < this.#resetAtMs)
return
this.#samples = [
...this.#samples,
{ offsetMs: serverTimeMs - (sentAtMs + receivedAtMs) / 2, roundTripMs: receivedAtMs - sentAtMs },
].slice(-sampleLimit)
const best = this.#samples.reduce((a, b) => (b.roundTripMs < a.roundTripMs ? b : a))
this.#setOffset(best.offsetMs)
}
reset(): void {
this.#samples = []
this.#resetAtMs = this.#localNow()
this.#setOffset(null)
}
#setOffset(offsetMs: number | null): void {
if (offsetMs === this.#offsetMs)
return
this.#offsetMs = offsetMs
this.dispatchEvent(new Event('change'))
}
}
// performance.now() 不受系统校时影响。
export const clock = new Clock(() => performance.timeOrigin + performance.now())
+1
View File
@@ -0,0 +1 @@
export { clock } from './clock'
+23
View File
@@ -0,0 +1,23 @@
import type { ErrorCode } from '@/core/realtime'
import { ApiError } from '@/core/api'
import { RequestError } from '@/core/realtime'
export type ErrorTexts = Partial<Record<ErrorCode, string | ((message: string) => string)>>
// 这两个错误码的 message 写给接口调用方,带字段路径或协议版本,界面上换成用户能照着做的说法;其余错误码的 message 本身就是给用户看的。
const texts: ErrorTexts = {
INVALID_ARGUMENT: '提交的内容不符合要求,请检查后再试。',
CLIENT_OUTDATED: '应用已经更新,稍后会自动刷新页面。',
}
export function errorText(code: ErrorCode, message: string, overrides: ErrorTexts = {}): string {
const text = overrides[code] ?? texts[code]
return typeof text === 'function' ? text(message) : text ?? message
}
// 没有错误码的 ApiError 和 RequestError 由前端构造,message 本来就是给用户看的;其他错误来自浏览器或程序缺陷,它们的 message 不给用户看。
export function errorMessage(error: unknown, overrides?: ErrorTexts): string {
if (!(error instanceof ApiError || error instanceof RequestError))
return '出错了,请稍后再试。'
return error.code ? errorText(error.code, error.message, overrides) : error.message
}
+2
View File
@@ -0,0 +1,2 @@
export { errorMessage, errorText } from './error-message'
export type { ErrorTexts } from './error-message'
+7
View File
@@ -0,0 +1,7 @@
const firstDelayMs = 3000
const maxDelayMs = 30_000
// attempt 从 1 开始计数。
export function reconnectDelay(attempt: number): number {
return Math.min(firstDelayMs * 2 ** (attempt - 1), maxDelayMs)
}
+243
View File
@@ -0,0 +1,243 @@
import type { ErrorCode, ErrorInfo, HelloResponse, RequestDataMap, RequestMessage, RequestType, ResponseDataMap, RoomEventMessage, ServerMessage } from './protocol.gen'
import { clock } from '@/core/clock'
import { reconnectDelay } from './backoff'
import { CloseCode, PROTOCOL_VERSION } from './protocol.gen'
const requestTimeoutMs = 60_000
const pingIntervalMs = 5000
const stopCodes = new Set<number>([CloseCode.invalidArgument, CloseCode.unauthorized, CloseCode.notFound, CloseCode.clientOutdated])
// 浏览器在连接失败时先触发 error,再以 1006 触发 close。
const abnormalClosure = 1006
export type ConnectionStatus = 'connecting' | 'open' | 'reconnecting' | 'stopped'
export class RequestError extends Error {
readonly code: ErrorCode | null
constructor(message: string, code: ErrorCode | null) {
super(message)
this.name = 'RequestError'
this.code = code
}
}
export interface ConnectionHandlers {
status: (status: ConnectionStatus) => void
hello: (response: HelloResponse) => void
event: (event: RoomEventMessage) => void
error: (error: ErrorInfo) => void
stop: (code: number) => void
}
interface Pending {
resolve: (data: unknown) => void
reject: (error: RequestError) => void
timer: ReturnType<typeof setTimeout>
}
function socketUrl(): string {
return `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`
}
export class Connection {
readonly #roomId: string
readonly #ticket: string
readonly #handlers: ConnectionHandlers
#status: ConnectionStatus = 'connecting'
#socket: WebSocket | null = null
#ready = false
#attempt = 0
#nextId = 0
#pending = new Map<string, Pending>()
#retryTimer: ReturnType<typeof setTimeout> | undefined
#pingTimer: ReturnType<typeof setInterval> | undefined
constructor(roomId: string, ticket: string, handlers: ConnectionHandlers) {
this.#roomId = roomId
this.#ticket = ticket
this.#handlers = handlers
}
start(): void {
document.addEventListener('visibilitychange', this.#onVisibilityChange)
window.addEventListener('online', this.check)
window.addEventListener('pageshow', this.#onPageShow)
this.#open()
}
stop(): void {
this.#halt()
}
request<T extends RequestType>(type: T, data: RequestDataMap[T]): Promise<ResponseDataMap[T]> {
if (!this.#ready)
return Promise.reject(new RequestError('还没有连上房间,连上之后再试一次。', null))
return this.#send(type, data)
}
// 不以收不到回包作为断线的依据:连接已失效时,写入 ping 会触发 close 或 error。
readonly check = (): void => {
if (this.#status === 'stopped')
return
const socket = this.#socket
if (socket?.readyState === WebSocket.OPEN) {
if (this.#ready)
this.#ping()
return
}
if (socket?.readyState === WebSocket.CONNECTING)
return
this.#detach()
clearTimeout(this.#retryTimer)
this.#attempt = 0
this.#open()
}
#onVisibilityChange = (): void => {
if (document.visibilityState !== 'visible')
return
clock.reset()
this.check()
}
#onPageShow = (event: PageTransitionEvent): void => {
if (event.persisted)
this.check()
}
#open(): void {
const socket = new WebSocket(socketUrl())
this.#socket = socket
socket.addEventListener('open', () => {
if (socket === this.#socket)
this.#hello(socket)
})
socket.addEventListener('message', (event) => {
if (socket === this.#socket)
this.#receive(event)
})
socket.addEventListener('close', event => this.#lost(socket, event.code))
socket.addEventListener('error', () => this.#lost(socket, abnormalClosure))
}
#hello(socket: WebSocket): void {
this.#send('hello', { roomId: this.#roomId, ticket: this.#ticket, protocolVersion: PROTOCOL_VERSION }).then((response) => {
if (socket !== this.#socket)
return
this.#ready = true
this.#attempt = 0
clock.reset()
this.#setStatus('open')
this.#handlers.hello(response)
this.#ping()
this.#pingTimer = setInterval(() => this.#ping(), pingIntervalMs)
}, (error: RequestError) => {
if (socket !== this.#socket)
return
// 服务端返回错误后会以对应的关闭码断开,是否重连由关闭码决定。
if (error.code)
this.#handlers.error({ code: error.code, message: error.message })
else
this.#lost(socket, abnormalClosure)
})
}
#ping(): void {
const sentAt = clock.now()
// 服务端只接受整数毫秒,而 performance.now() 带小数。
this.#send('ping', { clientTimeMs: Math.round(sentAt) }).then((response) => {
clock.addSample(sentAt, clock.now(), response.serverTimeMs)
}, () => {})
}
#send<T extends RequestType>(type: T, data: RequestDataMap[T]): Promise<ResponseDataMap[T]> {
const socket = this.#socket
if (!socket)
return Promise.reject(new RequestError('还没有连上房间,连上之后再试一次。', null))
const id = String(++this.#nextId)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.#pending.delete(id)
reject(new RequestError('服务器没有及时响应,请稍后再试。', null))
}, requestTimeoutMs)
this.#pending.set(id, { resolve: resolve as (data: unknown) => void, reject, timer })
const message: RequestMessage<T> = { kind: 'request', type, id, data }
socket.send(JSON.stringify(message))
})
}
#receive(event: MessageEvent): void {
let message: ServerMessage
try {
message = JSON.parse(event.data as string) as ServerMessage
}
catch {
return
}
if (message.kind === 'response') {
const pending = this.#pending.get(message.id)
if (!pending)
return
this.#pending.delete(message.id)
clearTimeout(pending.timer)
if (message.error)
pending.reject(new RequestError(message.error.message, message.error.code))
else
pending.resolve(message.data)
return
}
if (message.type === 'error')
this.#handlers.error(message.data)
// 握手响应里的快照已经包含握手之前的事件。
else if (this.#ready)
this.#handlers.event(message)
}
#lost(socket: WebSocket, code: number): void {
if (socket !== this.#socket)
return
this.#detach()
if (stopCodes.has(code)) {
this.#halt()
this.#handlers.stop(code)
return
}
this.#attempt += 1
this.#setStatus('reconnecting')
this.#retryTimer = setTimeout(() => this.#open(), reconnectDelay(this.#attempt))
}
#detach(): void {
const socket = this.#socket
if (!socket)
return
this.#socket = null
this.#ready = false
clearInterval(this.#pingTimer)
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN)
socket.close()
for (const pending of this.#pending.values()) {
clearTimeout(pending.timer)
pending.reject(new RequestError('与房间的连接断开了,连上之后再试一次。', null))
}
this.#pending.clear()
}
#setStatus(status: ConnectionStatus): void {
if (status === this.#status)
return
this.#status = status
this.#handlers.status(status)
}
#halt(): void {
this.#detach()
clearTimeout(this.#retryTimer)
document.removeEventListener('visibilitychange', this.#onVisibilityChange)
window.removeEventListener('online', this.check)
window.removeEventListener('pageshow', this.#onPageShow)
this.#setStatus('stopped')
}
}
+5
View File
@@ -0,0 +1,5 @@
export { reconnectDelay } from './backoff'
export { Connection, RequestError } from './connection'
export type { ConnectionStatus } from './connection'
export type * from './protocol.gen'
export { CloseCode, isLink, roomRequests } from './protocol.gen'
+440
View File
@@ -0,0 +1,440 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '09dbf7ea495db969'
export interface APIKey {
key: string
}
export type ActivityKind = 'added' | 'removed' | 'liked' | 'voted' | 'skipped' | 'failed'
export interface ChatMessageEvent {
content: string
member: Member
sentAtMs: number
}
export interface ChatSendRequest {
content: string
}
export type Empty = Record<string, never>
export interface Entry {
auto: boolean
by: Member | null
id: string
likes: number
meta: TrackMeta
status: EntryStatus
track: Track
}
export type EntryStatus = 'preparing' | 'ready'
export type ErrorCode = 'INVALID_ARGUMENT' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'QUEUE_FULL' | 'NOTHING_PLAYING' | 'RATE_LIMITED' | 'PROVIDER_ERROR' | 'UNAVAILABLE' | 'CLIENT_OUTDATED' | 'INTERNAL'
export interface ErrorInfo {
code: ErrorCode
message: string
}
export interface HelloRequest {
protocolVersion: string
roomId: string
ticket: string
}
export interface HelloResponse {
member: Member
room: RoomSnapshot
serverTimeMs: number
}
export interface Member {
avatarHash: string
id: string
name: string
}
export interface Members {
onlineCount: number
sessions: Member[]
}
export interface NowPlaying {
durationMs: number
entry: Entry
gain: number
lyric: string
mediaUrl: string
startedAtMs: number
}
export interface PingRequest {
clientTimeMs: number
}
export interface PingResponse {
clientTimeMs: number
serverTimeMs: number
}
export type PlayMode = 'sequential' | 'random' | 'rotation'
export interface PlaybackUpdatedEvent {
nowPlaying: NowPlaying | null
}
export type PlaybackVoteSkipRequest = Record<string, never>
export interface PlaybackVoteSkipResponse {
required: number
skipped: boolean
votes: number
}
export interface PlaylistSummary {
coverUrl: string
creator: string
description: string
id: string
name: string
playCount: number
source: Source
trackCount: number
}
export interface PlaylistTracksRequest {
playlistId: string
source: Source
}
export interface PlaylistTracksResponse {
items: TrackSummary[]
total: number
}
export type QQLoginType = 'qq' | 'wx'
export interface QRLogin {
expiresAtMs: number
key: string
qrImage: string
}
export interface QRLoginResult {
message: string
status: QRLoginStatus
}
export type QRLoginStatus = 'waiting' | 'scanned' | 'success' | 'expired' | 'failed'
export type QueueAddRequest = { track: Track, requester?: string } | { source: Source, keyword: string, requester?: string }
export interface QueueAddResponse {
entry: Entry
}
export interface QueueLikeRequest {
entryId: string
}
export interface QueueLikeResponse {
likes: number
}
export interface QueueRemoveRequest {
entryId: string
}
export interface QueueUpdatedEvent {
items: Entry[]
pending: Entry | null
}
export type RecommendListRequest = Record<string, never>
export interface RecommendListResponse {
items: TrackSummary[]
}
export interface RoomActivityEvent {
entry?: Entry
kind: ActivityKind
member?: Member
message: string
required?: number
votes?: number
}
export interface RoomInfo {
description: string
id: string
kind: RoomKind
name: string
}
export type RoomKind = 'persistent' | 'ephemeral'
export interface RoomListing {
description: string
hasPassword: boolean
id: string
kind: RoomKind
name: string
onlineCount: number
slug: string | null
}
export interface RoomSnapshot {
items: Entry[]
members: Members
nowPlaying: NowPlaying | null
pending: Entry | null
playMode: PlayMode
room: RoomInfo
seq: number
term: number
}
export type RoomSyncRequest = Record<string, never>
export interface RoomSyncResponse {
room: RoomSnapshot
}
export interface RoomUpdatedEvent {
description: string
name: string
}
export interface SearchPlaylistsRequest {
keyword: string
page: number
pageSize: number
source: Source
}
export interface SearchPlaylistsResponse {
items: PlaylistSummary[]
total: number
}
export interface SearchTracksRequest {
keyword: string
page: number
pageSize: number
source: Source
}
export interface SearchTracksResponse {
items: TrackSummary[]
total: number
}
export interface SettingsSetPlayModeRequest {
playMode: PlayMode
}
export interface SettingsUpdatedEvent {
playMode: PlayMode
}
export type Source = 'netease' | 'qq' | 'bilibili' | 'link'
export interface SourceAccount {
id: string
name: string | null
verified: boolean
vip: boolean | null
}
export interface StoredMedia {
key: string
lastPlayedAtMs: number | null
link: string
size: number
source: Source
storedAtMs: number
title: string
}
export interface Track {
id: string
source: Source
}
export interface TrackMeta {
album: string
artist: string
coverUrl: string
durationMs: number
pageUrl: string
title: string
}
export interface TrackSummary {
meta: TrackMeta
track: Track
}
export interface RequestDataMap {
'hello': HelloRequest
'ping': PingRequest
'room.sync': RoomSyncRequest
'queue.add': QueueAddRequest
'queue.remove': QueueRemoveRequest
'queue.like': QueueLikeRequest
'playback.voteSkip': PlaybackVoteSkipRequest
'settings.setPlayMode': SettingsSetPlayModeRequest
'chat.send': ChatSendRequest
'search.tracks': SearchTracksRequest
'search.playlists': SearchPlaylistsRequest
'playlist.tracks': PlaylistTracksRequest
'recommend.list': RecommendListRequest
}
export interface ResponseDataMap {
'hello': HelloResponse
'ping': PingResponse
'room.sync': RoomSyncResponse
'queue.add': QueueAddResponse
'queue.remove': Empty
'queue.like': QueueLikeResponse
'playback.voteSkip': PlaybackVoteSkipResponse
'settings.setPlayMode': Empty
'chat.send': Empty
'search.tracks': SearchTracksResponse
'search.playlists': SearchPlaylistsResponse
'playlist.tracks': PlaylistTracksResponse
'recommend.list': RecommendListResponse
}
export interface ConnectionEventDataMap {
'error': ErrorInfo
}
export interface RoomEventDataMap {
'playback.updated': PlaybackUpdatedEvent
'queue.updated': QueueUpdatedEvent
'members.updated': Members
'settings.updated': SettingsUpdatedEvent
'room.updated': RoomUpdatedEvent
'chat.message': ChatMessageEvent
'room.activity': RoomActivityEvent
}
export type RequestType = keyof RequestDataMap
export type ConnectionEventType = keyof ConnectionEventDataMap
export type RoomEventType = keyof RoomEventDataMap
export interface RequestMessage<T extends RequestType = RequestType> {
kind: 'request'
type: T
id: string
data: RequestDataMap[T]
}
export type ResponseMessage<T extends RequestType = RequestType> = {
[K in T]: { kind: 'response', type: K, id: string } & (
| { data: ResponseDataMap[K], error?: undefined }
| { data: Empty, error: ErrorInfo }
)
}[T]
export type ConnectionEventMessage<T extends ConnectionEventType = ConnectionEventType> = {
[K in T]: { kind: 'event', type: K, data: ConnectionEventDataMap[K] }
}[T]
export type RoomEventMessage<T extends RoomEventType = RoomEventType> = {
[K in T]: { kind: 'event', type: K, term: number, seq: number, data: RoomEventDataMap[K] }
}[T]
export type ServerMessage = ResponseMessage | ConnectionEventMessage | RoomEventMessage
export const CloseCode = {
invalidArgument: 1008,
internal: 1011,
serviceRestart: 1012,
unavailable: 1013,
unauthorized: 4401,
notFound: 4404,
handshakeTimeout: 4408,
clientOutdated: 4426,
sendQueueFull: 4429,
} as const
export type Send = <T extends RequestType>(type: T, data: RequestDataMap[T]) => Promise<ResponseDataMap[T]>
export function roomRequests(send: Send) {
return {
roomSync: () => send('room.sync', {}),
queueAdd: (data: QueueAddRequest) => send('queue.add', data),
queueRemove: (data: QueueRemoveRequest) => send('queue.remove', data),
queueLike: (data: QueueLikeRequest) => send('queue.like', data),
playbackVoteSkip: () => send('playback.voteSkip', {}),
settingsSetPlayMode: (data: SettingsSetPlayModeRequest) => send('settings.setPlayMode', data),
chatSend: (data: ChatSendRequest) => send('chat.send', data),
searchTracks: (data: SearchTracksRequest) => send('search.tracks', data),
searchPlaylists: (data: SearchPlaylistsRequest) => send('search.playlists', data),
playlistTracks: (data: PlaylistTracksRequest) => send('playlist.tracks', data),
recommendList: () => send('recommend.list', {}),
}
}
function validURL(value: string): boolean {
if (/[\u0000-\u0020\u007F\\]/.test(value))
return false
const scheme = /^https?:\/\//i.exec(value)
if (scheme === null)
return false
const rest = value.slice(scheme[0].length)
const end = rest.search(/[/?#]/)
const authority = end < 0 ? rest : rest.slice(0, end)
const tail = end < 0 ? '' : rest.slice(end)
let host = authority
let port = ''
if (authority.startsWith('[')) {
const close = authority.indexOf(']') + 1
host = authority.slice(0, close)
if (close === 0 || !/^\[[0-9A-Fa-f:.]+\]$/.test(host))
return false
const after = authority.slice(close)
if (after !== '') {
if (!after.startsWith(':'))
return false
port = after.slice(1)
}
}
else {
const colon = authority.indexOf(':')
if (colon >= 0) {
host = authority.slice(0, colon)
port = authority.slice(colon + 1)
}
if (!/^[\w.~\u0080-\u{10FFFF}-]+$/u.test(host))
return false
const labels = host.replace(/\.$/, '').split('.')
if (/^(?:\d+|0x[0-9a-f]*)$/i.test(labels[labels.length - 1]) && !/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/.test(host))
return false
}
if (!/^\d*$/.test(port) || Number(port) > 65535)
return false
const hash = tail.indexOf('#')
const path = (hash < 0 ? tail : tail.slice(0, hash)).split('?')[0]
const fragment = hash < 0 ? '' : tail.slice(hash + 1)
const badEscape = /%(?![0-9A-Fa-f]{2})/
if (badEscape.test(path) || badEscape.test(fragment))
return false
try {
return new URL(value).hostname !== ''
}
catch {
return false
}
}
export function isLink(value: string): boolean {
return new TextEncoder().encode(value).length <= 2048 && validURL(value)
}
+2
View File
@@ -0,0 +1,2 @@
export type { RoomNotice, RoomState } from './replica'
export { roomStore } from './room-store'
+109
View File
@@ -0,0 +1,109 @@
import type { RoomEventMessage, RoomSnapshot } from '@/core/realtime'
export type RoomState = Omit<RoomSnapshot, 'term' | 'seq'>
export type RoomNotice = RoomEventMessage<'chat.message' | 'room.activity'>
export interface Outcome {
readonly changed: boolean
readonly notice: RoomNotice | null
readonly sync: boolean
}
const ignored: Outcome = { changed: false, notice: null, sync: false }
function isNotice(event: RoomEventMessage): event is RoomNotice {
return event.type === 'chat.message' || event.type === 'room.activity'
}
function after(term: number, seq: number, thanTerm: number, thanSeq: number): boolean {
return term > thanTerm || (term === thanTerm && seq > thanSeq)
}
export class RoomReplica {
state: RoomState
#term = 0
#seq = 0
#waiting = false
#noticeTerm = 0
#noticeSeq = 0
constructor(snapshot: RoomSnapshot) {
this.state = this.#apply(snapshot)
}
receive(event: RoomEventMessage): Outcome {
if (event.term < this.#term)
return ignored
if (!this.#waiting) {
if (event.term === this.#term && event.seq <= this.#seq)
return ignored
if (event.term === this.#term && event.seq === this.#seq + 1) {
this.#seq = event.seq
if (isNotice(event))
return { changed: false, notice: this.#notice(event), sync: false }
this.#update(event)
return { changed: true, notice: null, sync: false }
}
this.#waiting = true
return { changed: false, notice: isNotice(event) ? this.#notice(event) : null, sync: true }
}
// 快照不包含聊天,等待期间通知照常显示;状态事件以快照为准。
return { changed: false, notice: isNotice(event) ? this.#notice(event) : null, sync: false }
}
// 请求在途期间收到的状态事件以快照为准,所以先进入等待,再发出请求。
async sync(fetchSnapshot: () => Promise<RoomSnapshot>): Promise<void> {
this.#waiting = true
try {
this.applySnapshot(await fetchSnapshot())
}
finally {
this.#waiting = false
}
}
applySnapshot(snapshot: RoomSnapshot): void {
this.state = this.#apply(snapshot)
this.#waiting = false
}
#apply(snapshot: RoomSnapshot): RoomState {
const { term, seq, ...state } = snapshot
this.#term = term
this.#seq = seq
if (after(term, seq, this.#noticeTerm, this.#noticeSeq)) {
this.#noticeTerm = term
this.#noticeSeq = seq
}
return state
}
#notice(event: RoomNotice): RoomNotice | null {
if (!after(event.term, event.seq, this.#noticeTerm, this.#noticeSeq))
return null
this.#noticeTerm = event.term
this.#noticeSeq = event.seq
return event
}
#update(event: Exclude<RoomEventMessage, RoomNotice>): void {
switch (event.type) {
case 'playback.updated':
this.state = { ...this.state, nowPlaying: event.data.nowPlaying }
break
case 'queue.updated':
this.state = { ...this.state, pending: event.data.pending, items: event.data.items }
break
case 'members.updated':
this.state = { ...this.state, members: event.data }
break
case 'settings.updated':
this.state = { ...this.state, playMode: event.data.playMode }
break
case 'room.updated':
this.state = { ...this.state, room: { ...this.state.room, name: event.data.name, description: event.data.description } }
break
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { RoomNotice, RoomState } from './replica'
import type { ConnectionStatus, ErrorInfo, HelloResponse, Member, RequestDataMap, RequestType, ResponseDataMap, RoomEventMessage } from '@/core/realtime'
import { Connection, RequestError, roomRequests } from '@/core/realtime'
import { Emitter } from '@/shared/lib/emitter'
import { Store } from '@/shared/lib/store'
import { RoomReplica } from './replica'
interface RoomStoreState {
roomId: string | null
status: ConnectionStatus | null
// 重连期间保留上一次的状态。
room: RoomState | null
// room 最近一次的更新来自按顺序应用的实时事件,而不是握手或 room.sync 的快照。
live: boolean
member: Member | null
stopCode: number | null
}
const idle: RoomStoreState = { roomId: null, status: null, room: null, live: false, member: null, stopCode: null }
class RoomStore extends Store<RoomStoreState> {
readonly notices = new Emitter<RoomNotice>()
readonly errors = new Emitter<ErrorInfo>()
readonly requests = roomRequests((type, data) => this.request(type, data))
#connection: Connection | null = null
#replica: RoomReplica | null = null
#syncing: Promise<void> | null = null
constructor() {
super(idle)
}
join(roomId: string, ticket: string): void {
this.leave()
const connection = new Connection(roomId, ticket, {
status: (status) => {
if (connection === this.#connection)
this.setState({ status })
},
hello: response => this.#hello(connection, response),
event: event => this.#event(connection, event),
error: (error) => {
if (connection === this.#connection)
this.errors.emit(error)
},
stop: code => this.#stop(connection, code),
})
this.#connection = connection
this.setState({ roomId, status: 'connecting' })
connection.start()
}
leave(): void {
const connection = this.#connection
this.#connection = null
connection?.stop()
this.#replica = null
this.#syncing = null
this.setState(idle)
}
reconnect(): void {
this.#connection?.check()
}
request<T extends RequestType>(type: T, data: RequestDataMap[T]): Promise<ResponseDataMap[T]> {
if (!this.#connection)
return Promise.reject(new RequestError('还没有加入房间', null))
return this.#connection.request(type, data)
}
sync(): Promise<void> {
const replica = this.#replica
if (this.#syncing || !replica)
return this.#syncing ?? Promise.resolve()
const syncing = replica.sync(() => this.request('room.sync', {}).then(({ room }) => room)).then(() => {
if (replica === this.#replica)
this.setState({ room: replica.state, live: false })
}, () => {}).finally(() => {
if (this.#syncing === syncing)
this.#syncing = null
})
this.#syncing = syncing
return syncing
}
#hello(connection: Connection, response: HelloResponse): void {
if (connection !== this.#connection)
return
if (this.#replica)
this.#replica.applySnapshot(response.room)
else
this.#replica = new RoomReplica(response.room)
this.setState({ room: this.#replica.state, live: false, member: response.member })
}
#event(connection: Connection, event: RoomEventMessage): void {
const replica = this.#replica
if (connection !== this.#connection || !replica)
return
const outcome = replica.receive(event)
if (outcome.notice)
this.notices.emit(outcome.notice)
if (outcome.changed)
this.setState({ room: replica.state, live: true })
if (outcome.sync)
void this.sync()
}
#stop(connection: Connection, code: number): void {
if (connection !== this.#connection)
return
this.#replica = null
this.setState({ room: null, live: false, member: null, stopCode: code })
}
}
export const roomStore = new RoomStore()
+62
View File
@@ -0,0 +1,62 @@
import { LogOut } from 'lucide-react'
import { useState } from 'react'
import { brandStore } from '@/core/brand'
import { useStore } from '@/shared/lib/use-store'
import { adminStore } from './admin-store'
import { PersistentRooms } from './persistent-rooms'
import { SiteSettings } from './site-settings'
import { SourceAccounts } from './source-accounts'
import { StoredMedia } from './stored-media'
import './admin.css'
function SignIn() {
const [token, setToken] = useState('')
return (
<form
className="form-stack admin-sign-in"
onSubmit={(event) => {
event.preventDefault()
adminStore.signIn(token)
}}
>
<label>
管理 token
<input type="password" autoComplete="off" autoFocus required value={token} onChange={event => setToken(event.target.value)} />
</label>
<p className="text-ui-caption muted">管理 token 只保存在这个标签页中,关闭标签页后要重新输入。</p>
<button type="submit" className="button-primary" disabled={!token}>进入后台</button>
</form>
)
}
export function AdminPage({ onEntered }: { onEntered: (ref: string) => void }) {
const signedIn = useStore(adminStore, s => s.signedIn)
const brandName = useStore(brandStore, s => s.name)
return (
<div className="page-content admin-page">
<div className="admin-heading">
<div className="page-title">
<span className="text-ui-sm accent">{brandName}</span>
<h1 className="text-ui-xl">后台</h1>
<p className="text-ui-base muted">管理品牌名、常驻房间、音源账号和已存储的媒体。</p>
</div>
{signedIn && (
<button type="button" className="button-ghost" onClick={() => adminStore.signOut()}>
<LogOut size={16} aria-hidden="true" />
退出后台
</button>
)}
</div>
{signedIn
? (
<>
<SiteSettings />
<PersistentRooms onEntered={onEntered} />
<SourceAccounts />
<StoredMedia />
</>
)
: <SignIn />}
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import type { ErrorTexts } from '@/core/errors'
import { ApiError, deleteAdminToken, loadAdminToken, saveAdminToken } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
class AdminStore extends Store<{ signedIn: boolean }> {
constructor() {
super({ signedIn: false })
}
start(): void {
this.setState({ signedIn: loadAdminToken() !== null })
}
signIn(token: string): void {
saveAdminToken(token)
this.setState({ signedIn: true })
}
signOut(): void {
deleteAdminToken()
this.setState({ signedIn: false })
}
// 失败时显示错误并返回 null;管理 token 无效、后台没有启用或网址不被服务器接受时退出登录。
// 204 的接口成功时得到 undefined,调用方要与 null 比较,不能只看真假。
async call<T>(request: () => Promise<T>, texts?: ErrorTexts): Promise<T | null> {
try {
return await request()
}
catch (error) {
// 同时进行的请求会一起失败,退出登录后不再重复提示。
if (!this.state.signedIn)
return null
notificationStore.error(errorMessage(error, texts))
if (error instanceof ApiError && (error.code === 'UNAUTHORIZED' || error.code === 'FORBIDDEN'))
this.signOut()
return null
}
}
}
export const adminStore = new AdminStore()
+35
View File
@@ -0,0 +1,35 @@
.admin-page { container: admin / inline-size; max-width: 1040px; }
.admin-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.admin-sign-in { max-width: 420px; }
.admin-sign-in > .button-primary { align-self: flex-start; }
.admin-field { display: flex; flex-direction: column; gap: 6px; overflow-wrap: anywhere; }
.admin-field > .error-text { margin: 0; }
.admin-section + .admin-section { margin-top: 40px; }
.admin-list { list-style: none; margin: 0; padding: 0 20px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-surface); }
.admin-row { display: flex; align-items: center; gap: 8px; min-height: 68px; padding: 12px 0; }
.admin-row + .admin-row { border-top: 1px solid var(--color-border); }
.admin-row-copy { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
.admin-row-title { font-weight: 600; }
.admin-row-meta { display: flex; align-items: center; gap: 6px; min-width: 0; }
.admin-row-actions { display: flex; align-items: center; gap: 8px; }
.admin-brand-name { width: 240px; }
.admin-link { color: var(--color-foreground-subtle); }
.admin-link:hover { color: var(--color-brand); }
.admin-empty { padding: 8px 0; }
.admin-steps { display: flex; flex-direction: column; gap: 6px; margin: 0; padding-left: 1.25em; list-style: decimal; }
.admin-steps ul { display: flex; flex-direction: column; gap: 4px; margin: 4px 0 0; padding-left: 1.25em; list-style: disc; }
.admin-failed { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.admin-qr { display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; }
.admin-qr-image { width: 200px; height: 200px; padding: 8px; border-radius: 8px; background: #fff; image-rendering: pixelated; }
.admin-qr-switch { display: flex; gap: 4px; padding: 4px; border-radius: 999px; background: var(--color-input); }
.admin-qr-switch > button { min-height: 32px; padding: 6px 16px; border: 0; border-radius: 999px; background: transparent; color: var(--color-foreground-subtle); }
.admin-qr-switch > button:hover:not(:disabled) { color: var(--color-foreground); }
.admin-qr-switch > button[aria-pressed='true'], .admin-qr-switch > button[aria-pressed='true']:hover:not(:disabled) { background: var(--color-foreground); color: var(--color-popover); }
@media (max-width: 767px) {
.admin-list { padding: 0 16px; }
}
@container admin (max-width: 600px) {
.admin-row { flex-wrap: wrap; }
.admin-row-actions { flex-basis: 100%; }
.admin-brand-name { flex: 1; width: auto; }
}
+36
View File
@@ -0,0 +1,36 @@
import { useState } from 'react'
import { Modal } from '@/shared/ui'
export function ConfirmDialog({ title, children, confirmLabel, onConfirm, onClose }: {
title: string
children: string
confirmLabel: string
onConfirm: () => Promise<void>
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
return (
<Modal title={title} onClose={onClose}>
<p className="text-ui-base muted">{children}</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
<button
type="button"
className="button-destructive"
disabled={busy}
onClick={async () => {
setBusy(true)
try {
await onConfirm()
}
finally {
setBusy(false)
}
}}
>
{confirmLabel}
</button>
</div>
</Modal>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { AdminPage } from './admin-page'
export { adminStore } from './admin-store'
+129
View File
@@ -0,0 +1,129 @@
import type { RoomListing } from '@/core/realtime'
import { KeyRound, LockKeyhole, LogIn, Pencil, Plus, Share2, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { copyInviteLink, roomEntryStore } from '@/features/rooms'
import { IconButton, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
import { ConfirmDialog } from './confirm-dialog'
import { RoomApiKeyDialog } from './room-api-key-dialog'
import { RoomFormDialog } from './room-form-dialog'
export function PersistentRooms({ onEntered }: { onEntered: (ref: string) => void }) {
const [rooms, setRooms] = useState<RoomListing[] | null>(null)
const [failed, setFailed] = useState(false)
const [reload, setReload] = useState(0)
const [editing, setEditing] = useState<RoomListing | 'new' | null>(null)
const [deleting, setDeleting] = useState<RoomListing | null>(null)
const [keyOf, setKeyOf] = useState<RoomListing | null>(null)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminListRooms()).then((result) => {
if (!active)
return
if (result)
setRooms(result.items)
else
setFailed(true)
})
return () => {
active = false
}
}, [reload])
const refresh = () => {
setFailed(false)
setReload(n => n + 1)
}
const retry = () => {
setRooms(null)
refresh()
}
return (
<section className="admin-section" aria-labelledby="admin-rooms-title">
<div className="section-heading">
<h2 id="admin-rooms-title" className="text-ui-lg">常驻房间</h2>
<button type="button" className="button-secondary" onClick={() => setEditing('new')}>
<Plus size={16} aria-hidden="true" />
新建常驻房间
</button>
</div>
{failed
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取常驻房间。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
: !rooms
? <Skeleton rows={3} />
: !rooms.length
? <p className="admin-empty text-ui-caption muted">还没有常驻房间。</p>
: (
<ul className="admin-list">
{rooms.map(room => (
<li className="admin-row" key={room.id}>
<div className="admin-row-copy">
<strong className="text-ui-base truncate">{room.name}</strong>
<span className="admin-row-meta text-ui-sm muted">
<span className="truncate">{room.slug ? `/r/${room.slug}` : '没有别名'}</span>
{room.hasPassword && (
<>
<span aria-hidden="true">·</span>
<LockKeyhole size={12} aria-hidden="true" />
有密码
</>
)}
<span aria-hidden="true">·</span>
{`${room.onlineCount} 人在线`}
</span>
</div>
<div className="admin-row-actions">
<IconButton
icon={LogIn}
label={`打开「${room.name}」`}
onClick={() => {
const ref = room.slug ?? room.id
const entry = roomEntryStore.state
if (entry.phase !== 'joined' || entry.room?.id !== room.id)
void roomEntryStore.enter(ref, room)
onEntered(ref)
}}
/>
<IconButton icon={Share2} label={`复制「${room.name}」的邀请链接`} onClick={() => void copyInviteLink(room)} />
<IconButton icon={KeyRound} label={`「${room.name}」的 API Key`} onClick={() => setKeyOf(room)} />
<IconButton icon={Pencil} label={`编辑「${room.name}」`} onClick={() => setEditing(room)} />
<IconButton icon={Trash2} label={`删除「${room.name}」`} onClick={() => setDeleting(room)} />
</div>
</li>
))}
</ul>
)}
{editing && (
<RoomFormDialog
room={editing === 'new' ? null : editing}
onSaved={() => {
setEditing(null)
refresh()
}}
onClose={() => setEditing(null)}
/>
)}
{keyOf && <RoomApiKeyDialog room={keyOf} onClose={() => setKeyOf(null)} />}
{deleting && (
<ConfirmDialog
title="删除常驻房间"
confirmLabel="删除房间"
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteRoom(deleting.id)) !== null) {
setDeleting(null)
refresh()
}
}}
onClose={() => setDeleting(null)}
>
{`删除「${deleting.name}」后,房间里的所有人都会断开连接。`}
</ConfirmDialog>
)}
</section>
)
}
+133
View File
@@ -0,0 +1,133 @@
import type { QrLoginEvent, QrLoginState } from './qr-login'
import type { QRLogin, QRLoginResult, QRLoginStatus } from '@/core/realtime'
import { useEffect, useEffectEvent, useState } from 'react'
import { Modal } from '@/shared/ui'
import { adminStore } from './admin-store'
import { nextQrLogin, pollIntervalMs, remainingSeconds, startQrLogin } from './qr-login'
export interface QrLoginTarget {
// 有多种扫码方式时显示在切换按钮上。
label: string
start: () => Promise<QRLogin>
check: (key: string) => Promise<QRLoginResult>
}
const statusText: Record<QRLoginStatus, string> = {
waiting: '等待扫码',
scanned: '已扫码,请在手机上确认',
success: '登录成功',
expired: '二维码已过期',
failed: '登录失败',
}
export function QrLoginDialog({ title, targets, onSuccess, onClose }: {
title: string
targets: QrLoginTarget[]
onSuccess: () => void
onClose: () => void
}) {
const [selected, setSelected] = useState(0)
const [qr, setQr] = useState<QRLogin | 'loading' | 'unavailable'>('loading')
const [login, setLogin] = useState<QrLoginState | null>(null)
const [nowMs, setNowMs] = useState(Date.now)
const [attempt, setAttempt] = useState(0)
const succeeded = useEffectEvent(onSuccess)
const target = targets[selected]
useEffect(() => {
let active = true
let state: QrLoginState | null = null
let poll: ReturnType<typeof setTimeout> | undefined
let tick: ReturnType<typeof setInterval> | undefined
const apply = (event: QrLoginEvent): boolean => {
if (!active || !state)
return false
const step = nextQrLogin(state, event)
state = step.state
setLogin(state)
if (!step.polling) {
clearTimeout(poll)
clearInterval(tick)
if (state.status === 'success')
succeeded()
}
return step.polling
}
const check = async (key: string) => {
const result = await adminStore.call(() => target.check(key))
if (apply({ kind: 'checked', result }))
poll = setTimeout(() => void check(key), pollIntervalMs)
}
void adminStore.call(target.start).then((started) => {
if (!active)
return
if (!started) {
setQr('unavailable')
return
}
state = startQrLogin(started.expiresAtMs)
setQr(started)
setLogin(state)
setNowMs(Date.now())
tick = setInterval(() => {
const now = Date.now()
setNowMs(now)
apply({ kind: 'tick', nowMs: now })
}, 1000)
poll = setTimeout(() => void check(started.key), pollIntervalMs)
})
return () => {
active = false
clearTimeout(poll)
clearInterval(tick)
}
}, [target, attempt])
const restart = () => {
setQr('loading')
setLogin(null)
}
const retry = () => {
restart()
setAttempt(n => n + 1)
}
const scanning = login?.status === 'waiting' || login?.status === 'scanned'
return (
<Modal title={title} onClose={onClose}>
<div className="admin-qr">
{targets.length > 1 && (
<div className="admin-qr-switch" role="group" aria-label="扫码方式">
{targets.map((option, index) => (
<button
type="button"
key={option.label}
className="text-ui-caption"
aria-pressed={index === selected}
onClick={() => {
if (index === selected)
return
restart()
setSelected(index)
}}
>
{option.label}
</button>
))}
</div>
)}
{qr === 'loading' && <p className="text-ui-base muted">正在获取二维码…</p>}
{qr === 'unavailable' && <p className="text-ui-base muted">暂时无法获取二维码。</p>}
{typeof qr === 'object' && login && (
<>
{scanning && <img className="admin-qr-image" src={qr.qrImage} alt="登录二维码" referrerPolicy="no-referrer" />}
<p className="text-ui-base" role="status">
{login.status === 'failed' && login.message ? `${statusText.failed}:${login.message}` : statusText[login.status]}
</p>
{scanning && <span className="text-ui-sm muted">{`剩余 ${remainingSeconds(login, nowMs)} 秒`}</span>}
</>
)}
{(qr === 'unavailable' || login?.status === 'expired' || login?.status === 'failed') && (
<button type="button" className="button-primary" onClick={retry}>重新获取</button>
)}
</div>
</Modal>
)
}
+49
View File
@@ -0,0 +1,49 @@
import type { QRLoginResult, QRLoginStatus } from '@/core/realtime'
export const pollIntervalMs = 2000
export interface QrLoginState {
status: QRLoginStatus
message: string
expiresAtMs: number
}
// result 为 null 表示这次查询失败,adminStore 已经提示了原因。
export type QrLoginEvent
= | { kind: 'checked', result: QRLoginResult | null }
| { kind: 'tick', nowMs: number }
export interface QrLoginStep {
state: QrLoginState
polling: boolean
}
export function startQrLogin(expiresAtMs: number): QrLoginState {
return { status: 'waiting', message: '', expiresAtMs }
}
function pending(status: QRLoginStatus): boolean {
return status === 'waiting' || status === 'scanned'
}
export function nextQrLogin(state: QrLoginState, event: QrLoginEvent): QrLoginStep {
if (!pending(state.status)) {
// 本机判定过期时可能还有一次查询尚未返回:它返回 success 时服务端已经保存了登录状态,仍按成功处理。
if (state.status === 'expired' && event.kind === 'checked' && event.result?.status === 'success')
return { state: { ...state, status: 'success', message: event.result.message }, polling: false }
return { state, polling: false }
}
if (event.kind === 'tick') {
if (event.nowMs < state.expiresAtMs)
return { state, polling: true }
return { state: { ...state, status: 'expired', message: '' }, polling: false }
}
if (event.result === null)
return { state: { ...state, status: 'failed', message: '' }, polling: false }
const next = { ...state, status: event.result.status, message: event.result.message }
return { state: next, polling: pending(next.status) }
}
export function remainingSeconds(state: QrLoginState, nowMs: number): number {
return Math.max(0, Math.ceil((state.expiresAtMs - nowMs) / 1000))
}
@@ -0,0 +1,136 @@
import type { AdminGetRoomApiKeyResponse } from '@/core/api'
import type { RoomListing } from '@/core/realtime'
import { useEffect, useRef, useState } from 'react'
import { api } from '@/core/api'
import { formatDateTime } from '@/shared/lib/time'
import { Modal, notificationStore } from '@/shared/ui'
import { adminStore } from './admin-store'
import { ConfirmDialog } from './confirm-dialog'
// 非安全上下文(比如局域网的明文 HTTP)没有剪贴板接口,用户也可能拒绝授权:这时返回 false,由调用方让用户手动复制。
async function copyApiKey(key: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(key)
notificationStore.success('已复制 API Key')
return true
}
catch {
return false
}
}
export function RoomApiKeyDialog({ room, onClose }: { room: RoomListing, onClose: () => void }) {
// undefined 表示正在获取,null 表示获取失败。
const [status, setStatus] = useState<AdminGetRoomApiKeyResponse | null>()
const [attempt, setAttempt] = useState(0)
// 只有生成或重置的响应里有 Key,关掉弹窗就再也看不到了。
const [key, setKey] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [resetting, setResetting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminGetRoomApiKey(room.id)).then((result) => {
if (active)
setStatus(result)
})
return () => {
active = false
}
}, [room.id, attempt])
// 不用 autoFocus:确认弹窗卸载时会把焦点还给打开它之前聚焦的元素(比如这个弹窗的关闭按钮),晚于 autoFocus;这个 effect 在那之后运行。
useEffect(() => {
if (key !== null)
inputRef.current?.focus()
}, [key])
const refetch = () => {
setStatus(undefined)
setAttempt(n => n + 1)
}
// 失败时 Key 可能已经重置,只是没能通知各实例断开旧 Key 的事件流,所以重新获取状态。
const renew = async (): Promise<string | null> => {
const result = await adminStore.call(() => api.adminResetRoomApiKey(room.id))
if (!result)
refetch()
return result && result.key
}
const generate = async () => {
setGenerating(true)
const fresh = await renew()
setGenerating(false)
if (fresh) {
setKey(fresh)
notificationStore.success('已生成 API Key')
}
}
const copy = async (value: string) => {
if (await copyApiKey(value))
return
inputRef.current?.focus()
inputRef.current?.select()
notificationStore.info('没能写入剪贴板,请手动复制选中的 Key。', 8000)
}
// 确认弹窗与这个弹窗并列,不放进 Modal 里:React 沿组件树传递 dialog 的 cancel 事件,在确认弹窗里按 Esc 会把两个弹窗一起关掉。
return (
<>
<Modal title={`「${room.name}」的 API Key`} onClose={onClose}>
{key !== null
? (
<>
<div className="form-stack">
<p className="text-ui-base muted">关闭后不能再查看,请现在复制。</p>
<input ref={inputRef} readOnly value={key} aria-label="API Key" onFocus={event => event.currentTarget.select()} />
</div>
<div className="dialog-actions">
<button type="button" className="button-primary" onClick={() => void copy(key)}>复制</button>
</div>
</>
)
: (
<>
{status === undefined && <p className="text-ui-base muted">正在获取…</p>}
{status === null && (
<div className="admin-failed">
<p className="text-ui-caption muted">暂时无法获取 API Key 的状态。</p>
<button type="button" className="button-secondary" onClick={refetch}>重试</button>
</div>
)}
{status?.set === false && (
<>
<p className="text-ui-base muted">还没有 API Key。</p>
<div className="dialog-actions">
<button type="button" className="button-primary" disabled={generating} onClick={() => void generate()}>生成</button>
</div>
</>
)}
{status?.set && (
<>
<p className="text-ui-base muted">{`生成于 ${formatDateTime(status.createdAtMs)}。Key 只在生成时显示一次,丢失后只能重置。`}</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={() => setResetting(true)}>重置</button>
</div>
</>
)}
</>
)}
</Modal>
{resetting && (
<ConfirmDialog
title="重置 API Key"
confirmLabel="重置"
onConfirm={async () => {
const fresh = await renew()
if (!fresh)
return
setResetting(false)
setKey(fresh)
notificationStore.success('已重置 API Key')
}}
onClose={() => setResetting(false)}
>
重置后旧 Key 立即失效,正在用它的 bot 要换成新 Key。
</ConfirmDialog>
)}
</>
)
}
@@ -0,0 +1,93 @@
import type { AdminUpdateRoomRequest } from '@/core/api'
import type { RoomListing } from '@/core/realtime'
import { useId, useState } from 'react'
import { api } from '@/core/api'
import { Modal } from '@/shared/ui'
import { adminStore } from './admin-store'
type PasswordChange = 'keep' | 'set' | 'remove'
const slugPattern = /^[a-z0-9-]{1,32}$/
export function RoomFormDialog({ room, onSaved, onClose }: { room: RoomListing | null, onSaved: () => void, onClose: () => void }) {
const [name, setName] = useState(room?.name ?? '')
const [description, setDescription] = useState(room?.description ?? '')
const [slug, setSlug] = useState(room?.slug ?? '')
const [passwordChange, setPasswordChange] = useState<PasswordChange>(room ? 'keep' : 'set')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const slugHintId = useId()
const slugInvalid = slug !== '' && !slugPattern.test(slug)
const save = async () => {
setBusy(true)
const fields = { name: name.trim(), description: description.trim() }
try {
const saved = room
? await adminStore.call(() => {
const patch: AdminUpdateRoomRequest = { ...fields }
// slug 或 password 为空字符串表示取消别名或密码。
if (slug !== (room.slug ?? ''))
patch.slug = slug
if (passwordChange === 'set')
patch.password = password
if (passwordChange === 'remove')
patch.password = ''
return api.adminUpdateRoom(room.id, patch)
})
: await adminStore.call(() => api.adminCreateRoom({ ...fields, ...(slug ? { slug } : {}), ...(password ? { password } : {}) }))
if (saved !== null)
onSaved()
}
finally {
setBusy(false)
}
}
return (
<Modal title={room ? '编辑常驻房间' : '新建常驻房间'} onClose={onClose}>
<form
className="form-stack"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<label>
房间名称
<input data-autofocus value={name} onChange={event => setName(event.target.value)} required maxLength={80} />
</label>
<label>
房间描述
<textarea value={description} onChange={event => setDescription(event.target.value)} maxLength={500} rows={3} />
</label>
<div className="admin-field">
<label>
别名
<input value={slug} onChange={event => setSlug(event.target.value)} maxLength={32} placeholder="小写字母、数字和连字符,留空表示不设别名" aria-invalid={slugInvalid} aria-describedby={slugHintId} />
</label>
<p id={slugHintId} className={`text-ui-sm ${slugInvalid ? 'error-text' : 'muted'}`}>
{slugInvalid ? '只能使用小写字母、数字和连字符,最多 32 个字符。' : slug ? `邀请链接:${window.location.origin}/r/${slug}` : '没有别名时,邀请链接使用房间 ID。'}
</p>
</div>
{room && (
<label>
密码
<select value={passwordChange} onChange={event => setPasswordChange(event.target.value as PasswordChange)}>
<option value="keep">{room.hasPassword ? '保持现有密码' : '保持无密码'}</option>
<option value="set">{room.hasPassword ? '修改密码' : '设置密码'}</option>
{room.hasPassword && <option value="remove">取消密码</option>}
</select>
</label>
)}
{passwordChange === 'set' && (
<label>
{room ? '新密码' : '密码(可选)'}
<input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} required={room !== null} />
</label>
)}
<button type="submit" className="button-primary" disabled={busy || !name.trim() || slugInvalid}>
{busy ? '正在保存…' : '保存'}
</button>
</form>
</Modal>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { brandStore } from '@/core/brand'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
export function SiteSettings() {
const brandName = useStore(brandStore, s => s.name)
const [status, setStatus] = useState<'loading' | 'failed' | 'loaded'>('loading')
const [attempt, setAttempt] = useState(0)
const [draft, setDraft] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
brandStore.load().then(() => {
if (active)
setStatus('loaded')
}, () => {
if (active)
setStatus('failed')
})
return () => {
active = false
}
}, [attempt])
const retry = () => {
setStatus('loading')
setAttempt(n => n + 1)
}
const name = draft ?? brandName
const save = async () => {
setBusy(true)
try {
const saved = await adminStore.call(() => api.adminUpdateSite({ brandName: name }), { INVALID_ARGUMENT: '品牌名要有 1 到 20 个字,不能含换行、制表符等特殊字符。' })
if (saved === null)
return
// 显示服务端存下的名字:浏览器和服务端去掉首尾空白的规则不完全相同。
brandStore.setName(saved.brandName)
setDraft(null)
notificationStore.success('已保存品牌名')
}
finally {
setBusy(false)
}
}
return (
<section className="admin-section" aria-labelledby="admin-site-title">
<div className="section-heading">
<h2 id="admin-site-title" className="text-ui-lg">站点</h2>
</div>
{status === 'failed'
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取品牌名。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
: status === 'loading'
? <Skeleton rows={1} />
: (
<form
className="admin-list"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-brand-name" className="admin-row-title text-ui-base">品牌名</label>
<span id="admin-brand-name-description" className="text-ui-sm muted">显示在发现房间、后台页面的顶部和浏览器标签页上,最多 20 个字。已经打开的页面刷新后才会更新。</span>
</div>
<div className="admin-row-actions">
<input id="admin-brand-name" className="admin-brand-name" aria-describedby="admin-brand-name-description" autoComplete="off" value={name} onChange={event => setDraft(event.target.value)} />
<button type="submit" className="button-secondary" disabled={busy || name.trim() === ''}>{busy ? '正在保存…' : '保存'}</button>
</div>
</div>
</form>
)}
</section>
)
}
@@ -0,0 +1,31 @@
import type { AdminGetBilibiliCredentialResponse, AdminGetNeteaseCredentialResponse, AdminGetQQCredentialResponse } from '@/core/api'
export type CredentialState = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | AdminGetBilibiliCredentialResponse | null | undefined
// 接在中文后面的平台名以英文开头时,前面留一个空格,如「已登录 QQ 音乐」。
export function afterChinese(platform: string): string {
return /^[a-z0-9]/i.test(platform) ? ` ${platform}` : platform
}
export function sourceAccountView(state: CredentialState, platform: string): { status: string, details: string[] } {
if (state === undefined)
return { status: '正在查询…', details: [] }
if (state === null)
return { status: '查询失败', details: [] }
if (!state.set)
return { status: '未登录', details: [] }
if (state.account === null)
return { status: '已保存登录信息,未识别到账号', details: [] }
const { account } = state
const details: string[] = []
if (account.name !== null)
details.push(`昵称:${account.name}`)
details.push(`账号 ID:${account.id}`)
if (account.vip !== null)
details.push(`会员状态:${account.vip ? '会员' : '非会员'}`)
return {
status: account.verified ? '已登录' : `已保存登录信息,未经${afterChinese(platform)}核实`,
details,
}
}
@@ -0,0 +1,96 @@
import type { QrLoginTarget } from './qr-login-dialog'
import type { CredentialState } from './source-account-view'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { notificationStore } from '@/shared/ui'
import { adminStore } from './admin-store'
import { QrLoginDialog } from './qr-login-dialog'
import { afterChinese, sourceAccountView } from './source-account-view'
import { YouTubeCookies } from './youtube-cookies'
interface Source {
name: string
credential: () => Promise<NonNullable<CredentialState>>
targets: QrLoginTarget[]
}
const sources: Source[] = [
{
name: 'QQ 音乐',
credential: () => api.adminGetQqCredential(),
targets: [
{ label: 'QQ 扫码', start: () => api.adminStartQqLogin({ type: 'qq' }), check: key => api.adminCheckQqLogin({ type: 'qq', key }) },
{ label: '微信扫码', start: () => api.adminStartQqLogin({ type: 'wx' }), check: key => api.adminCheckQqLogin({ type: 'wx', key }) },
],
},
{
name: '网易云音乐',
credential: () => api.adminGetNeteaseCredential(),
targets: [
{ label: '网易云音乐扫码', start: () => api.adminStartNeteaseLogin(), check: key => api.adminCheckNeteaseLogin({ key }) },
],
},
{
name: '哔哩哔哩',
credential: () => api.adminGetBilibiliCredential(),
targets: [
{ label: '哔哩哔哩扫码', start: () => api.adminStartBilibiliLogin(), check: key => api.adminCheckBilibiliLogin({ key }) },
],
},
]
function SourceAccount({ source }: { source: Source }) {
const [credential, setCredential] = useState<CredentialState>()
const [attempt, setAttempt] = useState(0)
const [signingIn, setSigningIn] = useState(false)
useEffect(() => {
let active = true
void adminStore.call(source.credential).then((result) => {
if (active)
setCredential(result)
})
return () => {
active = false
}
}, [source, attempt])
const view = sourceAccountView(credential, source.name)
return (
<li className="admin-row">
<div className="admin-row-copy">
<strong className="text-ui-base">{source.name}</strong>
<span className="text-ui-sm muted" role="status">{view.status}</span>
{view.details.map(detail => <span key={detail} className="text-ui-sm muted">{detail}</span>)}
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" disabled={credential === undefined} onClick={() => setSigningIn(true)}>{credential?.set ? '重新登录' : '扫码登录'}</button>
</div>
{signingIn && (
<QrLoginDialog
title={`扫码登录${afterChinese(source.name)}`}
targets={source.targets}
onSuccess={() => {
setSigningIn(false)
notificationStore.success(`已登录${afterChinese(source.name)}`)
setCredential(undefined)
setAttempt(n => n + 1)
}}
onClose={() => setSigningIn(false)}
/>
)}
</li>
)
}
export function SourceAccounts() {
return (
<section className="admin-section" aria-labelledby="admin-accounts-title">
<div className="section-heading">
<h2 id="admin-accounts-title" className="text-ui-lg">音源账号</h2>
</div>
<ul className="admin-list">
{sources.map(source => <SourceAccount key={source.name} source={source} />)}
<YouTubeCookies />
</ul>
</section>
)
}
+91
View File
@@ -0,0 +1,91 @@
import type { StoredMedia as Media } from '@/core/realtime'
import { Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { bookTitle } from '@/shared/lib/book-title'
import { formatDateTime } from '@/shared/lib/time'
import { IconButton, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
import { ConfirmDialog } from './confirm-dialog'
function formatSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
}
export function StoredMedia() {
const [media, setMedia] = useState<Media[] | null>(null)
const [failed, setFailed] = useState(false)
const [reload, setReload] = useState(0)
const [deleting, setDeleting] = useState<Media | null>(null)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminListMedia()).then((result) => {
if (!active)
return
if (result)
setMedia(result.items)
else
setFailed(true)
})
return () => {
active = false
}
}, [reload])
const retry = () => {
setFailed(false)
setMedia(null)
setReload(n => n + 1)
}
const totalSize = media?.reduce((sum, item) => sum + item.size, 0) ?? 0
return (
<section className="admin-section" aria-labelledby="admin-media-title">
<div className="section-heading">
<h2 id="admin-media-title" className="text-ui-lg">已存储的媒体</h2>
{media && <span className="text-ui-sm muted">{media.length ? `${media.length} 首 · ${formatSize(totalSize)}` : '0 首'}</span>}
</div>
{failed
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取已存储的媒体。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
: !media
? <Skeleton rows={3} />
: !media.length
? <p className="admin-empty text-ui-caption muted">还没有存储任何歌曲。</p>
: (
<ul className="admin-list">
{media.map(item => (
<li className="admin-row" key={item.key}>
<div className="admin-row-copy">
<strong className="text-ui-base truncate">{item.title}</strong>
<a className="admin-link text-ui-sm truncate" href={item.link} target="_blank" rel="noopener noreferrer">{item.link}</a>
<span className="text-ui-xs muted">
{`${item.source === 'bilibili' ? '哔哩哔哩 · ' : ''}${formatSize(item.size)} · 存入于 ${formatDateTime(item.storedAtMs)} · ${item.lastPlayedAtMs === null ? '还没有播放过' : `最近播放于 ${formatDateTime(item.lastPlayedAtMs)}`}`}
</span>
</div>
<IconButton icon={Trash2} label={`删除${bookTitle(item.title)}`} onClick={() => setDeleting(item)} />
</li>
))}
</ul>
)}
{deleting && (
<ConfirmDialog
title="删除已存储的媒体"
confirmLabel="删除"
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteMedia(deleting.key)) !== null) {
setDeleting(null)
setFailed(false)
setReload(n => n + 1)
}
}}
onClose={() => setDeleting(null)}
>
{`删除${bookTitle(deleting.title)}的音频和封面后,再点这首歌会重新下载。`}
</ConfirmDialog>
)}
</section>
)
}
@@ -0,0 +1,37 @@
import type { AdminGetYoutubeCredentialResponse } from '@/core/api'
export type YoutubeCredentialState = AdminGetYoutubeCredentialResponse | null | undefined
export const youtubeCookiesText = {
plaintextWarning: '当前连接不是 HTTPS,填写的 cookies 会以明文在网络上传输,只在可信网络中填写。',
tooLarge: 'cookies 太长了,上限约 64 KiB,只导出 YouTube 的 cookies 再试。',
}
// 服务端限制的是整个请求体的字节数,请求体是 JSON 编码的 { cookies }。
const requestLimitBytes = 64 * 1024
export function youtubeCookiesTooLarge(cookies: string): boolean {
return new TextEncoder().encode(JSON.stringify({ cookies })).length > requestLimitBytes
}
export function canSubmitYoutubeCookies(cookies: string, busy: boolean): boolean {
return !busy && cookies.trim() !== '' && !youtubeCookiesTooLarge(cookies)
}
// 本机地址的流量不经过网络。
export function shouldWarnAboutPlaintext(location: Pick<Location, 'protocol' | 'hostname'>): boolean {
if (location.protocol === 'https:')
return false
const host = location.hostname.toLowerCase()
return !['localhost', '::1', '[::1]'].includes(host) && !host.endsWith('.localhost') && !/^127(?:\.\d{1,3}){3}$/.test(host)
}
export function youtubeCookiesView(state: YoutubeCredentialState): { status: string, updatedAtMs: number | null } {
if (state === undefined)
return { status: '正在查询…', updatedAtMs: null }
if (state === null)
return { status: '查询失败', updatedAtMs: null }
if (!state.set)
return { status: '未配置', updatedAtMs: null }
return { status: '已配置', updatedAtMs: state.updatedAtMs }
}
+128
View File
@@ -0,0 +1,128 @@
import type { YoutubeCredentialState } from './youtube-cookies-view'
import { useEffect, useId, useState } from 'react'
import { api } from '@/core/api'
import { formatDateTime } from '@/shared/lib/time'
import { Modal, notificationStore } from '@/shared/ui'
import { adminStore } from './admin-store'
import { ConfirmDialog } from './confirm-dialog'
import { canSubmitYoutubeCookies, shouldWarnAboutPlaintext, youtubeCookiesText, youtubeCookiesTooLarge, youtubeCookiesView } from './youtube-cookies-view'
function YouTubeCookiesDialog({ onSaved, onClose }: { onSaved: () => void, onClose: () => void }) {
const [cookies, setCookies] = useState('')
const [busy, setBusy] = useState(false)
const tooLarge = youtubeCookiesTooLarge(cookies)
const tooLargeId = useId()
const save = async () => {
if (!canSubmitYoutubeCookies(cookies, busy))
return
setBusy(true)
try {
// 空白和超过 64 KiB 的内容不会提交,INVALID_ARGUMENT 只剩认不出格式,这时的 message 是写给用户的。
const saved = await adminStore.call(() => api.adminSetYoutubeCredential({ cookies }), { INVALID_ARGUMENT: message => message })
if (saved !== null)
onSaved()
}
finally {
setBusy(false)
}
}
return (
<Modal title="填写 YouTube cookies" onClose={onClose}>
<form
className="form-stack"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
{shouldWarnAboutPlaintext(window.location) && <p className="error-text text-ui-sm" role="alert">{youtubeCookiesText.plaintextWarning}</p>}
<ol className="admin-steps text-ui-sm">
<li>打开浏览器的无痕窗口,登录用来点歌的 YouTube 账号。账号可能被 YouTube 封禁,建议用不常用的账号。</li>
<li>登录之后,在同一个标签页里打开 www.youtube.com/robots.txt,无痕窗口里只留这一个标签页。</li>
<li>
用下面任一种方式取得 cookies,粘贴到输入框:
<ul>
<li>用 Get cookies.txt LOCALLY 这类扩展导出当前网站(youtube.com)的 cookies.txt。无痕窗口里看不到扩展时,到扩展管理页打开“在无痕模式下启用”(Firefox 是“在隐私窗口中运行”)。</li>
<li>按 F12(macOS 按 ⌥⌘I)打开开发者工具,在“网络”面板里刷新页面,点开 robots.txt 请求,复制“请求标头”(Firefox 是“请求头”)里 cookie 的值。</li>
</ul>
</li>
<li>保存之后直接关掉这个无痕窗口,不要再在里面打开 YouTube 的页面:页面开着时 YouTube 会更换 cookies,刚保存的就会失效。</li>
</ol>
<p className="text-ui-sm muted">保存会覆盖已有 cookies;已保存的内容不会在此显示。</p>
<div className="admin-field">
<label>
YouTube cookies
<textarea data-autofocus value={cookies} onChange={event => setCookies(event.target.value)} required rows={12} spellCheck={false} aria-invalid={tooLarge} aria-describedby={tooLarge ? tooLargeId : undefined} placeholder="粘贴 cookies.txt 的内容,或请求头里 cookie 的值" />
</label>
{tooLarge && <p id={tooLargeId} className="error-text text-ui-sm" role="alert">{youtubeCookiesText.tooLarge}</p>}
</div>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
<button type="submit" className="button-primary" disabled={!canSubmitYoutubeCookies(cookies, busy)}>{busy ? '正在保存…' : '保存'}</button>
</div>
</form>
</Modal>
)
}
export function YouTubeCookies() {
const [credential, setCredential] = useState<YoutubeCredentialState>()
const [attempt, setAttempt] = useState(0)
const [editing, setEditing] = useState(false)
const [clearing, setClearing] = useState(false)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminGetYoutubeCredential()).then((result) => {
if (active)
setCredential(result)
})
return () => {
active = false
}
}, [attempt])
const view = youtubeCookiesView(credential)
const refetch = () => {
setCredential(undefined)
setAttempt(n => n + 1)
}
return (
<li className="admin-row">
<div className="admin-row-copy">
<strong className="text-ui-base">YouTube</strong>
<span className="text-ui-sm muted" role="status">{view.status}</span>
{view.updatedAtMs !== null && <span className="text-ui-sm muted">{`最近填写于 ${formatDateTime(view.updatedAtMs)}`}</span>}
{shouldWarnAboutPlaintext(window.location) && <span className="error-text text-ui-sm" role="alert">{youtubeCookiesText.plaintextWarning}</span>}
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" disabled={credential === undefined} onClick={() => setEditing(true)}>{credential?.set ? '覆盖 cookies' : '填写 cookies'}</button>
{credential?.set && <button type="button" className="button-secondary" onClick={() => setClearing(true)}>清除</button>}
</div>
{editing && (
<YouTubeCookiesDialog
onSaved={() => {
setEditing(false)
refetch()
notificationStore.success('已保存 YouTube cookies')
}}
onClose={() => setEditing(false)}
/>
)}
{clearing && (
<ConfirmDialog
title="清除 YouTube cookies"
confirmLabel="清除"
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteYoutubeCredential()) === null)
return
setClearing(false)
refetch()
notificationStore.success('已清除 YouTube cookies')
}}
onClose={() => setClearing(false)}
>
清除后,下载 YouTube 链接时不再携带 cookies。
</ConfirmDialog>
)}
</li>
)
}
+200
View File
@@ -0,0 +1,200 @@
import type { Member } from '@/core/realtime'
import { MessageCircle, Send, Users, X } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { errorMessage } from '@/core/errors'
import { roomStore } from '@/core/room'
import { formatClock } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
import { Avatar, EmptyState, IconButton, notificationStore } from '@/shared/ui'
import { chatStore } from './chat-store'
import './chat.css'
const batchSize = 80
const pinnedDistancePx = 60
const noMembers: Member[] = []
function OnlineMembers() {
const sessions = useStore(roomStore, s => s.room?.members.sessions ?? noMembers)
const onlineCount = useStore(roomStore, s => s.room?.members.onlineCount ?? 0)
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLDivElement>(null)
const popoverId = useId()
useEffect(() => {
if (!open)
return
const dismiss = (event: PointerEvent) => {
if (!anchorRef.current?.contains(event.target as Node))
setOpen(false)
}
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape')
setOpen(false)
}
document.addEventListener('pointerdown', dismiss)
document.addEventListener('keydown', escape)
return () => {
document.removeEventListener('pointerdown', dismiss)
document.removeEventListener('keydown', escape)
}
}, [open])
// 一个成员可以同时有多个会话,在线列表中每个会话占一项。
const occurrences = new Map<string, number>()
return (
<div className="member-popover-anchor" ref={anchorRef}>
<button
type="button"
className="button-ghost chat-members-toggle text-ui-sm"
aria-label={`在线成员,${onlineCount} 人`}
aria-controls={open ? popoverId : undefined}
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen(value => !value)}
>
<Users size={16} aria-hidden="true" />
{`${onlineCount} 人`}
</button>
{open && (
<div className="member-popover" id={popoverId} role="dialog" aria-label="在线成员">
<div className="member-popover-head">
<strong className="text-ui-sm">在线成员</strong>
<span className="text-ui-xs muted">{`${onlineCount} 人`}</span>
</div>
<div className="member-popover-list">
{sessions.map((member) => {
const occurrence = (occurrences.get(member.id) ?? 0) + 1
occurrences.set(member.id, occurrence)
return (
<div className="member text-ui-sm" key={`${member.id}:${occurrence}`}>
<Avatar hash={member.avatarHash} name={member.name} className="text-ui-xs" />
<span className="truncate">{member.name}</span>
</div>
)
})}
{!sessions.length && <p className="muted text-ui-sm">连上房间之后显示在线成员。</p>}
</div>
</div>
)}
</div>
)
}
export function ChatPanel({ compact = false, onClose }: { compact?: boolean, onClose?: () => void }) {
const items = useStore(chatStore, s => s.items)
const selfId = useStore(roomStore, s => s.member?.id)
const connected = useStore(roomStore, s => s.status === 'open')
const [draft, setDraft] = useState('')
const [sending, setSending] = useState(false)
const [visibleCount, setVisibleCount] = useState(batchSize)
const [readCount, setReadCount] = useState(items.length)
const [atBottom, setAtBottom] = useState(true)
const listRef = useRef<HTMLDivElement>(null)
const pinnedRef = useRef(true)
// state 要到下一次渲染才更新,连续按回车时由这个 ref 阻止重复发送。
const sendingRef = useRef(false)
useEffect(() => {
if (pinnedRef.current)
listRef.current?.scrollTo({ top: listRef.current.scrollHeight })
}, [items])
const first = Math.max(0, items.length - visibleCount)
const scrollToBottom = () => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight })
pinnedRef.current = true
setReadCount(chatStore.state.items.length)
setAtBottom(true)
}
const send = async () => {
const content = draft.trim()
if (!connected || !content || sendingRef.current)
return
sendingRef.current = true
setSending(true)
try {
await chatStore.send(content)
// 请求期间输入框仍可编辑:只去掉已发出的部分,期间继续输入的内容要保留。
setDraft(current => current.startsWith(draft) ? current.slice(draft.length) : current)
scrollToBottom()
}
catch (error) {
notificationStore.error(errorMessage(error))
}
finally {
sendingRef.current = false
setSending(false)
}
}
return (
<section className={`chat-panel${compact ? ' chat-compact' : ''}`} aria-label="房间聊天">
<header className="chat-heading">
<h2 className="text-ui-lg">房间聊天</h2>
<div className="chat-heading-actions">
<OnlineMembers />
{onClose && <IconButton icon={X} label="关闭侧栏" onClick={onClose} />}
</div>
</header>
<div
className="chat-messages"
ref={listRef}
onScroll={(event) => {
const list = event.currentTarget
pinnedRef.current = list.scrollHeight - list.scrollTop - list.clientHeight < pinnedDistancePx
setAtBottom(pinnedRef.current)
if (pinnedRef.current)
setReadCount(items.length)
}}
>
{first > 0 && (
<button type="button" className="button-ghost text-ui-sm" onClick={() => setVisibleCount(n => n + batchSize)}>
查看更早的消息
</button>
)}
{!items.length && (
<EmptyState icon={MessageCircle} title="好音乐,一起分享">
打个招呼,或分享这首歌带给你的心情。
</EmptyState>
)}
{items.slice(first).map(item => item.kind === 'message'
? (
<article className={`chat-message${item.member.id === selfId ? ' self' : ''}`} key={item.key}>
<Avatar hash={item.member.avatarHash} name={item.member.name} className="text-ui-sm" />
<div className="message-content">
<div className="message-meta text-ui-xs muted">
<span>{item.member.name}</span>
<time dateTime={new Date(item.atMs).toISOString()}>{formatClock(item.atMs)}</time>
</div>
<p className="message-bubble text-ui-base">{item.text}</p>
</div>
</article>
)
: (
<p className="chat-activity text-ui-caption muted" key={item.key}>
<span>{item.member ? `${item.member.name} ${item.text}` : item.text}</span>
<time dateTime={new Date(item.atMs).toISOString()}>{formatClock(item.atMs)}</time>
</p>
))}
</div>
{!atBottom && items.length > readCount && (
<button type="button" className="new-messages text-ui-sm" onClick={scrollToBottom}>
查看新消息
</button>
)}
<form
className="chat-composer"
onSubmit={(event) => {
event.preventDefault()
void send()
}}
>
<input
type="text"
aria-label="聊天消息"
placeholder={connected ? '聊聊这首歌…' : '连上房间之后开始聊天'}
value={draft}
onChange={event => setDraft(event.target.value)}
maxLength={500}
disabled={!connected}
/>
<IconButton icon={Send} label="发送消息" type="submit" className="chat-send" disabled={!connected || !draft.trim() || sending} />
</form>
</section>
)
}
+57
View File
@@ -0,0 +1,57 @@
import type { Member } from '@/core/realtime'
import type { RoomNotice } from '@/core/room'
import { clock } from '@/core/clock'
import { roomStore } from '@/core/room'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
type ChatItem
= | { kind: 'message', key: string, member: Member, text: string, atMs: number }
| { kind: 'activity', key: string, member: Member | null, text: string, atMs: number }
// 不保留聊天记录:只显示这次进入房间之后收到的消息和房间动态,换房间时清空。
class ChatStore extends Store<{ items: ChatItem[] }> {
#roomId: string | null = null
constructor() {
super({ items: [] })
}
start(): void {
roomStore.addEventListener('change', this.#onRoom)
roomStore.notices.on(this.#onNotice)
}
async send(content: string): Promise<void> {
await roomStore.requests.chatSend({ content })
}
#onRoom = (): void => {
const { roomId } = roomStore.state
if (roomId === this.#roomId)
return
this.#roomId = roomId
this.setState({ items: [] })
}
#onNotice = (notice: RoomNotice): void => {
const key = `${notice.term}:${notice.seq}`
if (notice.type === 'chat.message') {
const { member, content, sentAtMs } = notice.data
this.#append({ kind: 'message', key, member, text: content, atMs: sentAtMs })
return
}
const { kind, member, entry, message } = notice.data
// 房间动态不带时间,按收到的时间显示。
this.#append({ kind: 'activity', key, member: member ?? null, text: message, atMs: clock.serverNow() ?? Date.now() })
// 自己点的歌准备失败时,聊天可能没有打开,所以另外用通知提醒。
if (kind === 'failed' && entry?.by && entry.by.id === roomStore.state.member?.id)
notificationStore.error(message)
}
#append(item: ChatItem): void {
this.setState({ items: [...this.state.items, item] })
}
}
export const chatStore = new ChatStore()
+39
View File
@@ -0,0 +1,39 @@
.chat-panel { position: relative; display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; height: 100%; }
.chat-panel:not(.chat-compact) { width: 100%; max-width: 920px; margin: 0 auto; }
.chat-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-shrink: 0; min-height: 64px; padding: 14px 20px; border-bottom: 1px solid var(--color-border); }
.chat-heading-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.chat-members-toggle { color: var(--color-foreground-subtle); }
.chat-members-toggle:hover, .chat-members-toggle[aria-expanded='true'] { color: var(--color-brand); }
.chat-messages { display: flex; flex-direction: column; gap: 20px; flex: 1; min-height: 0; overflow-y: auto; padding: 20px; overscroll-behavior: contain; }
.chat-messages > .empty-state { min-height: 0; margin: auto 0; }
.chat-messages > .button-ghost { align-self: center; }
.chat-message { display: flex; align-items: flex-start; gap: 12px; flex-shrink: 0; }
.message-content { flex: 1; min-width: 0; }
.message-meta { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; }
.message-meta > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.message-meta time, .chat-activity time { flex-shrink: 0; font-variant-numeric: tabular-nums; }
.message-bubble { display: inline-block; max-width: 100%; padding: 8px 12px; border-radius: 8px; background: var(--color-surface); line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
.self .message-bubble { background: var(--color-accent); }
.chat-activity { display: flex; justify-content: center; align-items: baseline; gap: 8px; flex-shrink: 0; text-align: center; overflow-wrap: anywhere; }
.new-messages { align-self: center; margin-bottom: 8px; padding: 6px 12px; border: 0; border-radius: 8px; background: var(--color-primary); color: var(--color-primary-foreground); }
.chat-composer { display: flex; align-items: center; gap: 8px; flex-shrink: 0; margin: 0 20px 20px; padding: 6px; border: 1px solid var(--color-input-border); border-radius: 12px; background: var(--color-input); }
.chat-composer:focus-within { border-color: var(--color-brand); }
.chat-composer input[type='text'] { flex: 1; min-width: 0; padding: 8px; border: 0; background: transparent; }
.chat-composer input:focus { background: transparent; }
.chat-send { color: var(--color-brand); }
.chat-send:hover:not(:disabled) { background: var(--color-accent); }
.member-popover-anchor { position: relative; }
.member-popover { position: absolute; z-index: 5; top: calc(100% + 10px); right: 0; display: flex; flex-direction: column; width: min(272px, calc(100vw - 40px)); max-height: min(280px, 45vh); padding: 8px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); box-shadow: 0 16px 40px var(--color-shadow); transform-origin: top right; animation: popover-in 200ms cubic-bezier(.2, .8, .2, 1) both; }
.member-popover-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 8px 10px; }
.member-popover-list { display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 2px; }
.member-popover-list > p { padding: 8px; }
.member { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 6px 8px; border-radius: 8px; }
.member:hover { background: var(--color-hover); }
.member .avatar { width: 28px; }
.member > span { min-width: 0; }
:root[data-performance='low'] .member-popover { animation: fade-in 160ms ease both; }
@media (max-width: 767px) {
.chat-heading { padding: 12px 16px; }
.chat-messages { padding: 16px; }
.chat-composer { margin: 0 16px 16px; }
}
+4
View File
@@ -0,0 +1,4 @@
import { lazyPage } from '@/shared/ui'
export { chatStore } from './chat-store'
export const ChatPanel = lazyPage(() => import('./chat-panel').then(module => module.ChatPanel))
+137
View File
@@ -0,0 +1,137 @@
import type { PlayRecord } from './records'
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { roomStore } from '@/core/room'
import { enqueue, sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { formatClock } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, SongArtwork } from '@/shared/ui'
import { historyStore } from './history-store'
import './history.css'
const batchSize = 80
function matches(record: PlayRecord, query: string): boolean {
const keyword = query.trim().toLowerCase()
const { title, artist, album } = record.meta
return !keyword || `${title} ${artist} ${album}`.toLowerCase().includes(keyword)
}
function playedOn(ms: number): string {
return new Date(ms).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
function HistoryRow({ record, compact, connected }: { record: PlayRecord, compact: boolean, connected: boolean }) {
const { title, artist, album, coverUrl, pageUrl } = record.meta
const performer = artist || '未知艺术家'
const source = sourceName(record.track)
return (
<div className="song-row">
<SongArtwork coverUrl={coverUrl} title={title} href={pageUrl} sourceName={source} />
<div className="song-copy">
<strong className="text-ui-base truncate">{title}</strong>
<span className="text-ui-sm muted truncate">{[performer, source, album].filter(Boolean).join(' · ')}</span>
</div>
{!compact && <time className="history-time text-ui-sm muted" dateTime={new Date(record.playedAtMs).toISOString()}>{formatClock(record.playedAtMs)}</time>}
<AsyncIconButton
icon={CirclePlus}
className="song-action"
label={`点歌${bookTitle(title)}`}
disabled={!connected}
onAction={async () => {
await enqueue(record.track)
}}
/>
<IconButton
icon={Trash2}
className="song-action"
label={`删除${bookTitle(title)}的播放记录`}
onClick={() => {
historyStore.removePlay(record)
notificationStore.success('已删除播放记录')
}}
/>
</div>
)
}
export function HistoryPage({ compact = false, filter = '', onFilter }: { compact?: boolean, filter?: string, onFilter?: (filter: string) => void }) {
const plays = useStore(historyStore, s => s.plays)
const connected = useStore(roomStore, s => s.status === 'open')
const [limit, setLimit] = useState(batchSize)
const [confirming, setConfirming] = useState(false)
const visible = plays.filter(record => matches(record, filter))
const shown = visible.slice(0, limit)
return (
<section className={compact ? 'history-panel' : 'page-content history-page'} aria-label="播放历史">
{!compact && (
<>
<div className="page-title">
<h1 className="text-ui-xl">播放历史</h1>
<p className="text-ui-base muted">重温那些一起听过的旋律。</p>
</div>
<div className="history-toolbar">
<label className="search-field">
<Search size={18} aria-hidden="true" />
<input
value={filter}
onChange={(event) => {
onFilter?.(event.target.value)
setLimit(batchSize)
}}
placeholder="搜索播放历史"
aria-label="搜索播放历史"
/>
</label>
<IconButton icon={Download} label="导出播放历史" disabled={!plays.length} onClick={() => historyStore.exportPlays()} />
<IconButton icon={Trash2} label="清空播放历史" disabled={!plays.length} onClick={() => setConfirming(true)} />
</div>
<div className="section-heading">
<span className="text-ui-sm muted">{`${plays.length} 首歌曲 · 保存在这台设备上`}</span>
</div>
</>
)}
{shown.length
? (
<ol className="history-list">
{shown.map((record, index) => {
const day = playedOn(record.playedAtMs)
return (
<li key={`${record.track.source}:${record.track.id}`}>
{(index === 0 || playedOn(shown[index - 1].playedAtMs) !== day) && <h2 className="history-date text-ui-sm muted">{day}</h2>}
<HistoryRow record={record} compact={compact} connected={connected} />
</li>
)
})}
</ol>
)
: (
<EmptyState icon={History} title={filter ? '没有找到相关记录' : '你的音乐足迹,从这里开始'}>
{filter ? '换个关键词,再找找看。' : '房间播放过的歌曲会保存在这台设备上,方便再次点歌。'}
</EmptyState>
)}
{visible.length > limit && (
<button type="button" className="button-secondary load-more" onClick={() => setLimit(n => n + batchSize)}>显示更多</button>
)}
{confirming && (
<Modal title="清空播放历史" onClose={() => setConfirming(false)}>
<p className="text-ui-base muted">将清空这台设备上的全部播放记录。</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={() => setConfirming(false)}>取消</button>
<button
type="button"
className="button-destructive"
onClick={() => {
historyStore.clearPlays()
setConfirming(false)
}}
>
清空
</button>
</div>
</Modal>
)}
</section>
)
}
+127
View File
@@ -0,0 +1,127 @@
import type { PlayRecord, SearchCategory, SearchRecord, SearchSource } from './records'
import { roomStore } from '@/core/room'
import { Store } from '@/shared/lib/store'
import { parsePlays, parseSearches, recordPlay, recordSearch } from './records'
export const searchLimits = [5, 10, 20, 50]
const defaultSearchLimit = 10
const playsKey = 'alisten.play-history'
const playsEnabledKey = 'alisten.play-history.enabled'
const searchesKey = 'alisten.search-history'
const searchesEnabledKey = 'alisten.search-history.enabled'
const searchLimitKey = 'alisten.search-history.limit'
export interface HistorySettings {
playsEnabled: boolean
searchesEnabled: boolean
searchLimit: number
}
interface HistoryState extends HistorySettings {
plays: PlayRecord[]
searches: SearchRecord[]
}
function loadSearchLimit(): number {
const stored = Number(localStorage.getItem(searchLimitKey))
return searchLimits.includes(stored) ? stored : defaultSearchLimit
}
function localDate(date: Date): string {
return [date.getFullYear(), date.getMonth() + 1, date.getDate()].map(n => String(n).padStart(2, '0')).join('-')
}
class HistoryStore extends Store<HistoryState> {
#playing = ''
constructor() {
super({ plays: [], playsEnabled: true, searches: [], searchesEnabled: true, searchLimit: defaultSearchLimit })
}
start(): void {
const searchLimit = loadSearchLimit()
const storedSearches = localStorage.getItem(searchesKey)
const searches = parseSearches(storedSearches, searchLimit)
this.setState({
plays: parsePlays(localStorage.getItem(playsKey)),
playsEnabled: localStorage.getItem(playsEnabledKey) !== 'false',
searches,
searchesEnabled: localStorage.getItem(searchesEnabledKey) !== 'false',
searchLimit,
})
if (storedSearches !== null && JSON.stringify(searches) !== storedSearches)
this.#saveSearches(searches)
roomStore.addEventListener('change', this.#onRoom)
window.addEventListener('storage', this.#onStorage)
}
removePlay(record: PlayRecord): void {
this.#savePlays(this.state.plays.filter(play => play !== record))
}
clearPlays(): void {
this.#savePlays([])
}
exportPlays(): void {
const now = new Date()
const file = new Blob([JSON.stringify({ exportedAt: now.toISOString(), history: this.state.plays }, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(file)
const link = document.createElement('a')
link.href = url
link.download = `alisten-history-${localDate(now)}.json`
link.click()
// 下载在 click() 时已经开始,到下一个任务再释放对象 URL。
setTimeout(() => URL.revokeObjectURL(url))
}
// 只记录用户发起的搜索(提交、点最近搜索、换来源和类别);翻页、推荐、打开歌单,以及前进、后退还原的搜索都不计入。
recordSearch(query: string, source: SearchSource, category: SearchCategory): void {
if (this.state.searchesEnabled)
this.#saveSearches(recordSearch(this.state.searches, { query, source, category, searchedAtMs: Date.now() }, this.state.searchLimit))
}
clearSearches(): void {
this.#saveSearches([])
}
updateSettings({ playsEnabled, searchesEnabled, searchLimit }: HistorySettings): void {
localStorage.setItem(playsEnabledKey, String(playsEnabled))
localStorage.setItem(searchesEnabledKey, String(searchesEnabled))
localStorage.setItem(searchLimitKey, String(searchLimit))
this.setState({ playsEnabled, searchesEnabled, searchLimit })
if (this.state.searches.length > searchLimit)
this.#saveSearches(this.state.searches.slice(0, searchLimit))
}
#onRoom = (): void => {
const nowPlaying = roomStore.state.room?.nowPlaying ?? null
const playing = nowPlaying ? `${nowPlaying.entry.id}@${nowPlaying.startedAtMs}` : ''
if (playing === this.#playing)
return
this.#playing = playing
if (nowPlaying && this.state.playsEnabled)
this.#savePlays(recordPlay(this.state.plays, { track: nowPlaying.entry.track, meta: nowPlaying.entry.meta, playedAtMs: nowPlaying.startedAtMs }))
}
// 其他标签页写入历史时同步更新,本页之后的写入才不会覆盖它写入的记录。
#onStorage = (event: StorageEvent): void => {
if (event.key === playsKey)
this.setState({ plays: parsePlays(event.newValue) })
else if (event.key === searchesKey)
this.setState({ searches: parseSearches(event.newValue, this.state.searchLimit) })
}
#savePlays(plays: PlayRecord[]): void {
localStorage.setItem(playsKey, JSON.stringify(plays))
this.setState({ plays })
}
#saveSearches(searches: SearchRecord[]): void {
localStorage.setItem(searchesKey, JSON.stringify(searches))
this.setState({ searches })
}
}
export const historyStore = new HistoryStore()
+14
View File
@@ -0,0 +1,14 @@
.history-panel { flex: 1; min-height: 0; padding: 8px 16px 24px; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none; }
.history-panel::-webkit-scrollbar { display: none; }
.history-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
.history-list { list-style: none; margin: 0; padding: 0; }
.history-date { padding: 24px 8px 8px; }
.history-panel .history-date { padding-top: 16px; }
.history-time { flex-shrink: 0; font-variant-numeric: tabular-nums; }
.history-panel .song-row { gap: 8px; }
.history-panel .icon-button { width: 28px; height: 32px; }
.load-more { display: flex; margin: 24px auto 0; }
@media (max-width: 767px) {
.history-toolbar { gap: 4px; }
.history-toolbar .search-field { margin-right: 4px; }
}
+5
View File
@@ -0,0 +1,5 @@
import { lazyPage } from '@/shared/ui'
export const HistoryPage = lazyPage(() => import('./history-page').then(module => module.HistoryPage))
export { historyStore, searchLimits } from './history-store'
export type { SearchCategory, SearchRecord, SearchSource } from './records'
+82
View File
@@ -0,0 +1,82 @@
import type { Source, Track, TrackMeta } from '@/core/realtime'
export interface PlayRecord {
track: Track
meta: TrackMeta
playedAtMs: number
}
export type SearchSource = 'netease' | 'qq'
export type SearchCategory = 'all' | 'tracks' | 'playlists'
export interface SearchRecord {
query: string
source: SearchSource
category: SearchCategory
searchedAtMs: number
}
export const playHistoryLimit = 500
const trackSources: unknown[] = ['netease', 'qq', 'link', 'bilibili'] satisfies Source[]
const searchSources: unknown[] = ['netease', 'qq'] satisfies SearchSource[]
const searchCategories: unknown[] = ['all', 'tracks', 'playlists'] satisfies SearchCategory[]
const metaText = ['title', 'artist', 'album', 'coverUrl', 'pageUrl'] as const
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function readList(stored: string | null): unknown[] {
try {
const value: unknown = JSON.parse(stored ?? '[]')
return Array.isArray(value) ? value : []
}
catch {
return []
}
}
function isPlayRecord(value: unknown): value is PlayRecord {
if (!isObject(value) || !isObject(value.track) || !isObject(value.meta) || typeof value.playedAtMs !== 'number')
return false
const { track, meta } = value
return trackSources.includes(track.source) && typeof track.id === 'string'
&& metaText.every(key => typeof meta[key] === 'string') && typeof meta.durationMs === 'number'
}
function isSearchRecord(value: unknown): value is SearchRecord {
return isObject(value) && typeof value.query === 'string' && typeof value.searchedAtMs === 'number'
&& searchSources.includes(value.source) && searchCategories.includes(value.category)
}
export function parsePlays(stored: string | null): PlayRecord[] {
return readList(stored).filter(isPlayRecord).slice(0, playHistoryLimit)
}
export function recordPlay(plays: PlayRecord[], record: PlayRecord): PlayRecord[] {
const rest = plays.filter(play => play.track.source !== record.track.source || play.track.id !== record.track.id)
return [record, ...rest].slice(0, playHistoryLimit)
}
function queryKey(query: string): string {
return query.trim().toLowerCase()
}
export function recordSearch(searches: SearchRecord[], record: SearchRecord, limit: number): SearchRecord[] {
const key = queryKey(record.query)
return [{ ...record, query: record.query.trim() }, ...searches.filter(search => queryKey(search.query) !== key)].slice(0, limit)
}
export function parseSearches(stored: string | null, limit: number): SearchRecord[] {
const latest = new Map<string, SearchRecord>()
for (const item of readList(stored)) {
if (!isSearchRecord(item) || !queryKey(item.query))
continue
const key = queryKey(item.query)
const previous = latest.get(key)
if (!previous || item.searchedAtMs > previous.searchedAtMs)
latest.set(key, { query: item.query.trim(), source: item.source, category: item.category, searchedAtMs: item.searchedAtMs })
}
return [...latest.values()].sort((a, b) => b.searchedAtMs - a.searchedAtMs).slice(0, limit)
}
+2
View File
@@ -0,0 +1,2 @@
export { ListenPage } from './lyrics'
export { lyricsStore } from './lyrics-store'
+38
View File
@@ -0,0 +1,38 @@
export interface LyricLine {
timeMs: number
text: string
}
const timeTag = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g
export function parseLrc(lrc: string): LyricLine[] {
const lines: LyricLine[] = []
for (const row of lrc.split('\n')) {
const tags = [...row.matchAll(timeTag)]
if (!tags.length)
continue
const text = row.replace(timeTag, '').trim()
for (const [, minutes, seconds, fraction = ''] of tags)
lines.push({ timeMs: Number(minutes) * 60_000 + Number(seconds) * 1000 + Number(fraction.padEnd(3, '0')), text })
}
lines.sort((a, b) => a.timeMs - b.timeMs)
// 空白行只用于同步,连续的空白行合并为一行。
return lines.filter((line, i) => line.text || i === 0 || lines[i - 1].text)
}
export function lineIndexAt(lines: LyricLine[], positionMs: number): number {
let low = 0
let high = lines.length - 1
let found = -1
while (low <= high) {
const mid = (low + high) >> 1
if (lines[mid].timeMs <= positionMs) {
found = mid
low = mid + 1
}
else {
high = mid - 1
}
}
return found
}
+35
View File
@@ -0,0 +1,35 @@
import type { LyricLine } from './lrc'
import { roomStore } from '@/core/room'
import { playerStore } from '@/features/playback'
import { Store } from '@/shared/lib/store'
import { lineIndexAt, parseLrc } from './lrc'
class LyricsStore extends Store<{ lines: LyricLine[], index: number }> {
#track = ''
constructor() {
super({ lines: [], index: -1 })
}
start(): void {
roomStore.addEventListener('change', this.#onRoom)
playerStore.frames.on(positionMs => this.#sync(positionMs))
}
#onRoom = (): void => {
const nowPlaying = roomStore.state.room?.nowPlaying ?? null
const track = nowPlaying ? `${nowPlaying.entry.id}@${nowPlaying.startedAtMs}` : ''
if (track === this.#track)
return
this.#track = track
const lines = parseLrc(nowPlaying?.lyric ?? '')
// 换歌时新歌尚未开始播放,播放器给出的仍是上一首的位置:先按歌曲开头计算,开始播放后再由每帧的位置更新。
this.setState({ lines, index: lineIndexAt(lines, 0) })
}
#sync(positionMs: number): void {
this.setState({ index: lineIndexAt(this.state.lines, positionMs) })
}
}
export const lyricsStore = new LyricsStore()
+16
View File
@@ -0,0 +1,16 @@
.listen-page { display: flex; height: 100%; min-height: 0; padding: 24px 32px; }
.lyrics-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.lyrics-scroll { flex: 1; min-height: 0; overflow-y: auto; position: relative; scrollbar-width: none; overscroll-behavior: contain; mask-image: linear-gradient(transparent, #000 12%, #000 88%, transparent); }
.lyrics-scroll::-webkit-scrollbar { display: none; }
/* 使首行和末行也能滚动到容器中央。 */
.lyrics-scroll:not(:has(.empty-state))::before, .lyrics-scroll:not(:has(.empty-state))::after { content: ''; display: block; height: 50%; }
/* 用 transform 而不是 font-size 区分层级,切换时不触发重排。 */
.lyric-line { max-width: 880px; margin: 0 auto; padding: 8px 32px; line-height: 1.5; text-align: center; color: var(--color-foreground-subtle); opacity: .78; font-weight: 600; overflow-wrap: anywhere; transform: translateY(4px) scale(.7); transform-origin: center; transition: transform 420ms cubic-bezier(.2, .8, .2, 1), opacity 300ms ease, color 300ms ease; }
.lyric-line.near-lyric { opacity: .9; transform: translateY(2px) scale(.8); }
.lyric-line.active-lyric { color: var(--color-foreground); opacity: 1; transform: translateY(0) scale(1.18); }
.lyrics-scroll:has(.empty-state) { display: grid; place-items: center; }
:root[data-performance='low'] .lyric-line { transition: opacity 160ms ease; }
:root[data-performance='off'] .lyrics-scroll { mask-image: none; }
@media (max-width: 767px) {
.listen-page { padding: 16px; }
}
+111
View File
@@ -0,0 +1,111 @@
import { AlignLeft } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { performanceStore } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState } from '@/shared/ui'
import { lyricsStore } from './lyrics-store'
import './lyrics.css'
// 空白行不渲染节点:当前行为空白行时,滚动到前后两行的中间。
function centerLine(container: HTMLElement, nodes: Map<number, HTMLElement>, index: number, smooth: boolean): void {
let before: HTMLElement | undefined
let after: HTMLElement | undefined
for (const [lineIndex, node] of nodes) {
if (lineIndex <= index)
before = node
if (lineIndex >= index) {
after = node
break
}
}
before ??= after
after ??= before
if (!before || !after)
return
const center = (before.offsetTop + before.clientHeight / 2 + after.offsetTop + after.clientHeight / 2) / 2
container.scrollTo({ top: Math.max(0, center - container.clientHeight / 2), behavior: smooth ? 'smooth' : 'instant' })
}
export function Lyrics() {
const lines = useStore(lyricsStore, s => s.lines)
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = scrollRef.current!
const nodes = new Map(Array.from(container.querySelectorAll<HTMLElement>('[data-lyric-index]'), node => [Number(node.dataset.lyricIndex), node]))
// React 会复用 key 相同的节点,需要先清除上一首歌留下的高亮。
for (const node of nodes.values()) {
node.classList.remove('active-lyric', 'near-lyric')
node.removeAttribute('aria-current')
}
let active = -2
let near: Element[] = []
let frame: number | null = null
const highlight = () => {
const index = lyricsStore.state.index
if (index === active)
return
const previous = nodes.get(active)
for (const node of near)
node.classList.remove('near-lyric')
near = []
previous?.classList.remove('active-lyric')
previous?.removeAttribute('aria-current')
const current = nodes.get(index)
if (current) {
current.classList.add('active-lyric')
current.setAttribute('aria-current', 'true')
near = [current.previousElementSibling, current.nextElementSibling].filter((node): node is Element => node !== null)
}
else {
let before: HTMLElement | undefined
for (const [lineIndex, node] of nodes) {
if (lineIndex < index) {
before = node
}
else {
near.push(node)
break
}
}
if (before)
near.push(before)
}
for (const node of near)
node.classList.add('near-lyric')
active = index
}
const onChange = () => {
highlight()
frame ??= requestAnimationFrame(() => {
frame = null
centerLine(container, nodes, lyricsStore.state.index, performanceStore.smoothMotion())
})
}
highlight()
centerLine(container, nodes, lyricsStore.state.index, false)
const resizeObserver = new ResizeObserver(() => centerLine(container, nodes, lyricsStore.state.index, false))
resizeObserver.observe(container)
lyricsStore.addEventListener('change', onChange)
return () => {
resizeObserver.disconnect()
lyricsStore.removeEventListener('change', onChange)
if (frame !== null)
cancelAnimationFrame(frame)
}
}, [lines])
return (
<section className="lyrics-panel" aria-label="同步歌词">
<div className="lyrics-scroll" ref={scrollRef}>
{lines.some(line => line.text)
? lines.map((line, index) => line.text && (
<p className="lyric-line text-ui-xl" data-lyric-index={index} key={`${line.timeMs}:${line.text}`}>{line.text}</p>
))
: <EmptyState icon={AlignLeft} title="歌词随音乐浮现">播放带有歌词的歌曲时,会在这里同步显示。</EmptyState>}
</div>
</section>
)
}
export function ListenPage() {
return <div className="listen-page"><Lyrics /></div>
}
@@ -0,0 +1,12 @@
import { useEffect, useEffectEvent, useRef } from 'react'
import { playerStore } from './player-store'
export function AudioEngine({ onNextTrack }: { onNextTrack: () => void }) {
const audioRef = useRef<HTMLAudioElement>(null)
const nextTrack = useEffectEvent(onNextTrack)
useEffect(() => {
playerStore.attach(audioRef.current!, () => nextTrack())
return () => playerStore.detach()
}, [])
return <audio ref={audioRef} preload="auto" />
}
@@ -0,0 +1,7 @@
import type { TrackMeta } from '@/core/realtime'
export function documentTitle(brandName: string, playing: Pick<TrackMeta, 'title' | 'artist'> | null): string {
if (!playing)
return brandName
return `${[playing.title, playing.artist].filter(Boolean).join(' - ')} | ${brandName}`
}
+6
View File
@@ -0,0 +1,6 @@
export { AudioEngine } from './audio-engine'
export { ManualStartPrompt } from './manual-start-prompt'
export { PlayerBar } from './player-bar'
export { playerStore } from './player-store'
export { reminderStore, requestNotificationPermission, systemNotificationsSupported } from './reminder-store'
export { volumeStore } from './volume-store'
@@ -0,0 +1,19 @@
import { useStore } from '@/shared/lib/use-store'
import { Modal } from '@/shared/ui'
import { playerStore } from './player-store'
export function ManualStartPrompt() {
const needsGesture = useStore(playerStore, s => s.needsGesture)
if (!needsGesture)
return null
const later = () => playerStore.dismissGesture()
return (
<Modal title="音乐已就绪" onClose={later}>
<p className="text-ui-base muted">浏览器需要你点击一次,才能开始播放音乐。</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={later}>稍后播放</button>
<button type="button" className="button-primary" onClick={() => playerStore.resume()}>点击开始播放</button>
</div>
</Modal>
)
}
@@ -0,0 +1,34 @@
import type { NowPlaying } from '@/core/realtime'
export interface MediaSessionHandlers {
play: () => void
pause: () => void
nexttrack: () => void
}
const actions = ['play', 'pause', 'nexttrack'] as const
export function showMediaSession(nowPlaying: NowPlaying | null): void {
if (!('mediaSession' in navigator))
return
if (!nowPlaying) {
navigator.mediaSession.metadata = null
navigator.mediaSession.playbackState = 'none'
return
}
const { title, artist, album, coverUrl } = nowPlaying.entry.meta
navigator.mediaSession.metadata = new MediaMetadata({ title, artist, album, artwork: coverUrl ? [{ src: coverUrl }] : [] })
}
export function setMediaSessionHandlers(handlers: MediaSessionHandlers | null): void {
if (!('mediaSession' in navigator))
return
for (const action of actions) {
try {
navigator.mediaSession.setActionHandler(action, handlers ? handlers[action] : null)
}
catch {
// 浏览器不支持某个操作时会抛出异常,其余操作照常设置。
}
}
}
+38
View File
@@ -0,0 +1,38 @@
.player-bar { --color-hover: var(--color-frame-hover); position: relative; display: flex; align-items: center; gap: 16px; padding: 12px 16px; margin: 0; border: 0; border-radius: 0; z-index: 10; background: var(--color-frame); color: var(--color-foreground); }
.player-artwork { flex-shrink: 0; border-radius: 12px; }
.player-artwork .artwork { width: 64px; border-radius: 12px; background: var(--color-frame-surface); }
.player-artwork .artwork > svg { width: 32px; }
.player-track { flex: 1; min-width: 0; position: relative; }
.player-song { display: flex; min-width: 0; width: 100%; padding: 0 112px 0 0; text-align: left; color: inherit; border: 0; border-radius: 8px; background: transparent; }
.player-source { color: inherit; }
.player-source:hover { color: var(--color-brand); }
.player-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-left: 12px; }
.player-center { display: flex; align-items: center; }
.playback-progress { margin-top: 10px; color: var(--color-foreground-subtle); font-variant-numeric: tabular-nums; }
.player-time { position: absolute; right: 0; top: 34px; text-align: right; white-space: nowrap; }
.progress-track { height: 3px; flex: 1; background: var(--color-border-hover); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--color-foreground); transform: scaleX(0); transform-origin: left; }
.player-tools { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
.player-tools .volume-slider { width: 90px; margin-right: 16px; accent-color: var(--color-foreground); }
.player-panel-toggle { color: var(--color-foreground-subtle); }
.player-panel-toggle:hover:not(:disabled), .player-panel-toggle:focus-visible:not(:disabled), .player-panel-toggle[aria-pressed='true'] { color: var(--color-brand); background: transparent; }
.player-panel-toggle:active:not(:disabled) { color: var(--color-brand); background: transparent; transform: none; }
@media (max-width: 1100px) {
.player-bar { gap: 12px; }
.player-tools .volume-slider { width: 60px; margin-right: 4px; }
}
@media (max-width: 767px) {
/* 窄屏的播放器栏由内容撑开高度;内容比封面矮时(比如出错提示),仍保持封面加上下内边距的高度。 */
.player-bar { --player-artwork: 44px; --player-padding-y: 6px; display: flex; position: relative; gap: 10px; padding: var(--player-padding-y) 10px; min-height: calc(var(--player-artwork) + 2 * var(--player-padding-y)); border: 0; border-radius: 8px; background: var(--color-frame-surface); }
.player-artwork, .player-artwork .artwork { border-radius: 6px; }
.player-artwork .artwork { width: var(--player-artwork); }
.player-song { padding-right: 76px; border: 0; background: transparent; }
.player-song strong { font-size: var(--text-ui-base); }
.player-right { padding-left: 8px; gap: 0; }
.playback-progress { position: absolute; left: 0; right: 0; bottom: 0; margin-top: 0; }
.player-center { flex-shrink: 0; }
.player-track { position: static; }
.player-time { top: 30px; right: 54px; }
.progress-track { height: 2px; border-radius: 0 0 8px 8px; }
.player-tools { display: none; }
}
+118
View File
@@ -0,0 +1,118 @@
import { ListMusic, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { roomStore } from '@/core/room'
import { sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { formatDuration } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, IconButton } from '@/shared/ui'
import { playerStore } from './player-store'
import { volumeStore } from './volume-store'
import './playback.css'
const progressIntervalMs = 100
function PlaybackProgress({ durationMs }: { durationMs: number }) {
const fillRef = useRef<HTMLDivElement>(null)
const timeRef = useRef<HTMLSpanElement>(null)
const meterRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let lastSecond = -1
let lastUpdate = Number.NEGATIVE_INFINITY
const update = (positionMs: number) => {
const clamped = Math.max(0, Math.min(durationMs, positionMs))
if (fillRef.current)
fillRef.current.style.transform = `scaleX(${durationMs ? clamped / durationMs : 0})`
const second = Math.floor(clamped / 1000)
if (second === lastSecond)
return
lastSecond = second
if (timeRef.current)
timeRef.current.textContent = `${formatDuration(clamped)} / ${formatDuration(durationMs)}`
meterRef.current?.setAttribute('aria-valuenow', String(second))
}
update(playerStore.positionMs())
return playerStore.frames.on((positionMs) => {
const now = performance.now()
if (now - lastUpdate < progressIntervalMs)
return
lastUpdate = now
update(positionMs)
})
}, [durationMs])
return (
<>
<span ref={timeRef} className="player-time text-ui-xs">0:00 / 0:00</span>
<div className="playback-progress">
<div ref={meterRef} className="progress-track" role="progressbar" aria-label="播放进度" aria-valuemin={0} aria-valuemax={Math.floor(durationMs / 1000)} aria-valuenow={0}>
<div ref={fillRef} className="progress-fill" />
</div>
</div>
</>
)
}
export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOpen, canOpenPanels }: {
onSkip: () => Promise<void>
onLyrics: () => void
onQueue: () => void
onChat: () => void
queueOpen: boolean
chatOpen: boolean
canOpenPanels: boolean
}) {
const nowPlaying = useStore(roomStore, s => s.room?.nowPlaying ?? null)
const connected = useStore(roomStore, s => s.status === 'open')
const volume = useStore(volumeStore, s => s.volume)
const muted = useStore(volumeStore, s => s.muted)
const meta = nowPlaying?.entry.meta
const source = nowPlaying && sourceName(nowPlaying.entry.track)
return (
<section className="player-bar" aria-label="播放器">
<button type="button" className="player-artwork" onClick={onLyrics} aria-label="查看歌词">
<Artwork src={meta?.coverUrl} title={meta?.title ?? '暂无歌曲'} />
</button>
<div className="player-track">
<div className="player-song">
<span className="song-copy">
<strong className="text-ui-lg truncate">{meta?.title ?? '暂无歌曲'}</strong>
<span className="text-ui-caption muted truncate">
{meta?.artist || '未知艺术家'}
{source && meta?.pageUrl && (
<>
{' · '}
<a className="player-source" href={meta.pageUrl} target="_blank" rel="noopener noreferrer" aria-label={`在${source}打开${bookTitle(meta.title)}(新标签页)`} title={`在${source}打开(新标签页)`}>{source}</a>
</>
)}
</span>
</span>
</div>
<PlaybackProgress key={nowPlaying?.entry.id} durationMs={nowPlaying?.durationMs ?? 0} />
</div>
<div className="player-right">
<div className="player-center">
<AsyncIconButton icon={SkipForward} label="投票切歌" disabled={!nowPlaying || !connected} onAction={onSkip} />
</div>
<div className="player-tools">
<IconButton icon={muted ? VolumeX : Volume2} label={muted ? '取消静音' : '静音'} onClick={() => volumeStore.setMuted(!muted)} />
<input
className="volume-slider"
type="range"
min="0"
max="100"
value={muted ? 0 : volume}
aria-label="音量"
onChange={(event) => {
const next = Number(event.target.value)
volumeStore.setVolume(next)
if (muted && next > 0)
volumeStore.setMuted(false)
}}
/>
<IconButton icon={MessageCircle} label="房间聊天" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={chatOpen} onClick={onChat} />
<IconButton icon={ListMusic} label="播放列表" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={queueOpen} onClick={onQueue} />
</div>
</div>
</section>
)
}
+255
View File
@@ -0,0 +1,255 @@
import type { NowPlaying } from '@/core/realtime'
import { brandStore } from '@/core/brand'
import { clock } from '@/core/clock'
import { roomStore } from '@/core/room'
import { Emitter } from '@/shared/lib/emitter'
import { Store } from '@/shared/lib/store'
import { documentTitle } from './document-title'
import { setMediaSessionHandlers, showMediaSession } from './media-session'
import { driftSeekPosition, startPlan } from './timeline'
import { volumeStore } from './volume-store'
const driftCheckIntervalMs = 2000
const maxGain = 1.5
function sameTrack(a: NowPlaying, b: NowPlaying): boolean {
return a.entry.id === b.entry.id && a.startedAtMs === b.startedAtMs
}
class PlayerStore extends Store<{ needsGesture: boolean }> {
// 播放期间每帧发出一次当前位置(毫秒)。
readonly frames = new Emitter<number>()
#audio: HTMLAudioElement | null = null
#current: NowPlaying | null = null
#load = 0
#reloaded = false
#awaitingClock = false
#startTimer: ReturnType<typeof setTimeout> | undefined
#driftTimer: ReturnType<typeof setInterval> | undefined
#frame: number | undefined
constructor() {
super({ needsGesture: false })
}
start(): void {
brandStore.addEventListener('change', this.#showTitle)
this.#showTitle()
}
attach(audio: HTMLAudioElement, onNextTrack: () => void): void {
this.#audio = audio
try {
audio.addEventListener('playing', this.#onPlaying)
audio.addEventListener('pause', this.#onPause)
audio.addEventListener('error', this.#onError)
roomStore.addEventListener('change', this.#onRoom)
clock.addEventListener('change', this.#onClock)
volumeStore.addEventListener('change', this.#applyVolume)
setMediaSessionHandlers({ play: () => this.resume(), pause: () => audio.pause(), nexttrack: onNextTrack })
this.#onRoom()
}
catch (error) {
// 挂载中途失败时必须清理:否则房间的订阅仍会在已移除的 <audio> 上加载和播放,重新挂载的 <audio> 也不会再加载这首歌。
this.detach()
throw error
}
}
detach(): void {
const audio = this.#audio
if (!audio)
return
this.#stop()
// pause 事件要到下一个任务才派发,那时监听已经移除,定时器和 rAF 只能在这里取消。
this.#onPause()
audio.removeEventListener('playing', this.#onPlaying)
audio.removeEventListener('pause', this.#onPause)
audio.removeEventListener('error', this.#onError)
roomStore.removeEventListener('change', this.#onRoom)
clock.removeEventListener('change', this.#onClock)
volumeStore.removeEventListener('change', this.#applyVolume)
setMediaSessionHandlers(null)
showMediaSession(null)
this.#current = null
this.#audio = null
}
positionMs(): number {
const audio = this.#audio
const current = this.#current
if (!audio || !current)
return 0
return Math.min(audio.currentTime * 1000, current.durationMs)
}
resume(): void {
const current = this.#current
if (!current)
return
this.setState({ needsGesture: false })
this.#start()
// 恢复播放时重新获取快照;即使快照没有变化,也按应在位置重新定位一次。
void roomStore.sync().then(() => {
if (this.#current && sameTrack(this.#current, current))
this.#start()
})
}
dismissGesture(): void {
this.setState({ needsGesture: false })
}
#onRoom = (): void => {
const next = roomStore.state.room?.nowPlaying ?? null
const previous = this.#current
if (next === previous)
return
this.#current = next
this.#applyVolume()
showMediaSession(next)
this.#showTitle()
if (!next) {
this.#stop()
return
}
if (previous && sameTrack(previous, next))
return
clearTimeout(this.#startTimer)
this.#reloaded = false
this.#loadSource(next.mediaUrl, ++this.#load)
}
#showTitle = (): void => {
document.title = documentTitle(brandStore.state.name, this.#current?.entry.meta ?? null)
}
#loadSource(url: string, load: number): void {
const audio = this.#audio
if (!audio)
return
// 定位之后还会再次触发 canplay,每次加载只处理第一次。
audio.addEventListener('canplay', () => {
if (load === this.#load)
this.#start()
}, { once: true })
audio.src = url
audio.load()
}
#start(): void {
clearTimeout(this.#startTimer)
const audio = this.#audio
const current = this.#current
if (!audio || !current)
return
const now = clock.serverNow()
if (now === null) {
this.#awaitingClock = true
return
}
this.#awaitingClock = false
const plan = startPlan(now - current.startedAtMs, current.durationMs)
switch (plan.kind) {
case 'wait':
audio.currentTime = 0
this.#startTimer = setTimeout(() => this.#start(), plan.delayMs)
return
case 'ended':
audio.pause()
return
case 'play':
audio.currentTime = plan.positionMs / 1000
audio.playbackRate = 1
audio.play().catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'NotAllowedError')
this.setState({ needsGesture: true })
})
}
}
#onClock = (): void => {
if (this.#awaitingClock && clock.offsetMs !== null)
this.#start()
}
#onError = (): void => {
const current = this.#current
if (!current || this.#reloaded)
return
this.#reloaded = true
// /media 在同一实例内复用 1 分钟内取到的播放地址,所以只有取到 1 分钟以后才失效的地址,这次重试才能换到新地址。
this.#loadSource(current.mediaUrl, ++this.#load)
}
#onPlaying = (): void => {
this.setState({ needsGesture: false })
cancelAnimationFrame(this.#frame ?? 0)
this.#frame = requestAnimationFrame(this.#tick)
clearInterval(this.#driftTimer)
this.#driftTimer = setInterval(this.#checkDrift, driftCheckIntervalMs)
}
#onPause = (): void => {
cancelAnimationFrame(this.#frame ?? 0)
this.#frame = undefined
clearInterval(this.#driftTimer)
}
#tick = (): void => {
const audio = this.#audio
const current = this.#current
if (!audio || !current || audio.paused) {
this.#frame = undefined
return
}
const position = audio.currentTime * 1000
if (position >= current.durationMs)
audio.pause()
this.frames.emit(Math.min(position, current.durationMs))
this.#frame = requestAnimationFrame(this.#tick)
}
#checkDrift = (): void => {
const audio = this.#audio
const current = this.#current
const now = clock.serverNow()
if (!audio || !current || now === null)
return
const expected = now - current.startedAtMs
if (expected >= current.durationMs)
return
const positionMs = driftSeekPosition({
positionMs: audio.currentTime * 1000,
paused: audio.paused,
seeking: audio.seeking,
readyState: audio.readyState,
}, expected)
if (positionMs !== null)
audio.currentTime = positionMs / 1000
}
#applyVolume = (): void => {
const audio = this.#audio
if (!audio)
return
const gain = Math.min(maxGain, Math.max(0, this.#current?.gain ?? 1))
audio.volume = Math.min(1, volumeStore.state.volume / 100 * gain)
audio.muted = volumeStore.state.muted
}
#stop(): void {
++this.#load
clearTimeout(this.#startTimer)
this.#awaitingClock = false
this.setState({ needsGesture: false })
const audio = this.#audio
if (!audio)
return
audio.pause()
audio.removeAttribute('src')
audio.load()
}
}
export const playerStore = new PlayerStore()
@@ -0,0 +1,93 @@
import type { Entry } from '@/core/realtime'
import type { RoomState } from '@/core/room'
import { clock } from '@/core/clock'
import { roomStore } from '@/core/room'
import { bookTitle } from '@/shared/lib/book-title'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
import { shouldRemind } from './reminder'
const enabledKey = 'alisten.reminder.enabled'
const systemNotificationsKey = 'alisten.reminder.system-notifications'
interface ReminderSettings {
enabled: boolean
systemNotifications: boolean
}
export function systemNotificationsSupported(): boolean {
return 'Notification' in window && 'serviceWorker' in navigator
}
// 只能在用户操作的事件里调用:Safari 和 Firefox 不接受其他时候发起的权限申请。
export async function requestNotificationPermission(): Promise<boolean> {
return await Notification.requestPermission() === 'granted'
}
class ReminderStore extends Store<ReminderSettings> {
#room: RoomState | null = null
#firstSeen = new Map<string, number>()
constructor() {
super({ enabled: true, systemNotifications: false })
}
start(): void {
this.setState({
enabled: localStorage.getItem(enabledKey) !== 'false',
systemNotifications: localStorage.getItem(systemNotificationsKey) === 'true',
})
roomStore.addEventListener('change', this.#onRoom)
}
updateSettings({ enabled, systemNotifications }: ReminderSettings): void {
localStorage.setItem(enabledKey, String(enabled))
localStorage.setItem(systemNotificationsKey, String(systemNotifications))
this.setState({ enabled, systemNotifications })
}
#onRoom = (): void => {
const { room, live, member } = roomStore.state
if (room === this.#room)
return
const previous = this.#room?.nowPlaying ?? null
this.#room = room
if (!room || !member) {
this.#firstSeen.clear()
return
}
const now = clock.now()
const next = room.nowPlaying
if (next) {
const firstSeen = this.#firstSeen.get(next.entry.id)
if (shouldRemind(previous, next, member.id, live, this.state.enabled, firstSeen === undefined ? null : now - firstSeen))
this.#remind(room.room.id, next.entry)
}
const own = new Set([...room.items, room.pending, next?.entry].flatMap(entry => entry?.by?.id === member.id ? [entry.id] : []))
// 开播时服务端先用 queue.updated 把条目移出队列,再用 playback.updated 开始播放,两条事件之间的状态里没有这个条目,
// 所以只在快照和正在播放的歌变化时删掉不在房间状态里的记录。
if (!live || next !== previous) {
for (const id of this.#firstSeen.keys()) {
if (!own.has(id))
this.#firstSeen.delete(id)
}
}
for (const id of own) {
if (!this.#firstSeen.has(id))
this.#firstSeen.set(id, now)
}
}
#remind(roomId: string, { id, meta }: Entry): void {
notificationStore.info(`轮到你点的${bookTitle(meta.title)}了`)
if (!this.state.systemNotifications || document.visibilityState !== 'hidden' || !systemNotificationsSupported() || Notification.permission !== 'granted')
return
// Android 上的 Chrome 不允许页面直接 new Notification()。
void navigator.serviceWorker.ready.then(registration => registration.showNotification('轮到你点的歌了', {
body: meta.artist ? `${meta.title} - ${meta.artist}` : meta.title,
tag: `${roomId}:${id}`,
}))
}
}
export const reminderStore = new ReminderStore()
+9
View File
@@ -0,0 +1,9 @@
import type { NowPlaying } from '@/core/realtime'
// 点完很快就开播的歌不提醒,否则「已点歌」和「轮到你点的」会一起出现。
const minimumWaitMs = 30_000
export function shouldRemind(previous: NowPlaying | null, next: NowPlaying, memberId: string, live: boolean, enabled: boolean, waitedMs: number | null): boolean {
return enabled && live && next.entry.by?.id === memberId && next.entry.id !== previous?.entry.id
&& waitedMs !== null && waitedMs >= minimumWaitMs
}
+30
View File
@@ -0,0 +1,30 @@
const seekThresholdMs = 1000
const haveFutureData = 3
export type StartPlan
= | { kind: 'wait', delayMs: number }
| { kind: 'play', positionMs: number }
| { kind: 'ended' }
export function startPlan(expectedMs: number, durationMs: number): StartPlan {
if (expectedMs < 0)
return { kind: 'wait', delayMs: -expectedMs }
if (expectedMs >= durationMs)
return { kind: 'ended' }
return { kind: 'play', positionMs: expectedMs }
}
export interface PlaybackSnapshot {
positionMs: number
paused: boolean
seeking: boolean
readyState: number
}
export function driftSeekPosition(snapshot: PlaybackSnapshot, expectedMs: number): number | null {
if (snapshot.paused || snapshot.seeking || snapshot.readyState < haveFutureData)
return null
if (Math.abs(snapshot.positionMs - expectedMs) <= seekThresholdMs)
return null
return expectedMs
}
+33
View File
@@ -0,0 +1,33 @@
import { Store } from '@/shared/lib/store'
const volumeKey = 'alisten.volume'
const mutedKey = 'alisten.muted'
const defaultVolume = 75
function loadVolume(): number {
const stored = Number(localStorage.getItem(volumeKey) ?? defaultVolume)
return Number.isFinite(stored) ? Math.min(100, Math.max(0, stored)) : defaultVolume
}
class VolumeStore extends Store<{ volume: number, muted: boolean }> {
constructor() {
super({ volume: defaultVolume, muted: false })
}
start(): void {
this.setState({ volume: loadVolume(), muted: localStorage.getItem(mutedKey) === 'true' })
}
setVolume(volume: number): void {
const clamped = Math.min(100, Math.max(0, volume))
localStorage.setItem(volumeKey, String(clamped))
this.setState({ volume: clamped })
}
setMuted(muted: boolean): void {
localStorage.setItem(mutedKey, String(muted))
this.setState({ muted })
}
}
export const volumeStore = new VolumeStore()
+59
View File
@@ -0,0 +1,59 @@
import type { PlayMode, Track } from '@/core/realtime'
import { errorMessage } from '@/core/errors'
import { RequestError } from '@/core/realtime'
import { roomStore } from '@/core/room'
import { bookTitle } from '@/shared/lib/book-title'
import { notificationStore } from '@/shared/ui'
async function run(action: () => Promise<string | null>): Promise<void> {
try {
const message = await action()
if (message)
notificationStore.success(message)
}
catch (error) {
notificationStore.error(errorMessage(error))
}
}
// 返回 false 时,失败原因已经通过通知显示。
export async function enqueue(track: Track): Promise<boolean> {
try {
const { entry } = await roomStore.requests.queueAdd({ track })
notificationStore.success(`已点歌${bookTitle(entry.meta.title)}`)
return true
}
catch (error) {
// 点了认得出却不能点的 B 站链接时,INVALID_ARGUMENT 的 message 就是写给点歌人的原因。
notificationStore.error(track.source === 'link' && error instanceof RequestError && error.code === 'INVALID_ARGUMENT' ? error.message : errorMessage(error))
return false
}
}
export function voteSkip(): Promise<void> {
return run(async () => {
const { votes, required, skipped } = await roomStore.requests.playbackVoteSkip()
return skipped ? '已切歌' : `已投票切歌(${votes}/${required})`
})
}
export function likeEntry(entryId: string, title: string): Promise<void> {
return run(async () => {
await roomStore.requests.queueLike({ entryId })
return `已点赞${bookTitle(title)}`
})
}
export function removeEntry(entryId: string, title: string): Promise<void> {
return run(async () => {
await roomStore.requests.queueRemove({ entryId })
return `已移除${bookTitle(title)}`
})
}
export function setPlayMode(playMode: PlayMode): Promise<void> {
return run(async () => {
await roomStore.requests.settingsSetPlayMode({ playMode })
return null
})
}
+5
View File
@@ -0,0 +1,5 @@
import { lazyPage } from '@/shared/ui'
export { enqueue, voteSkip } from './actions'
export { sourceName } from './source'
export const QueuePage = lazyPage(() => import('./queue-page').then(module => module.QueuePage))
+81
View File
@@ -0,0 +1,81 @@
import type { Entry, PlayMode } from '@/core/realtime'
import type { SongItem } from '@/shared/ui'
import { Heart, ListMusic, Trash2 } from 'lucide-react'
import { roomStore } from '@/core/room'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, SongArtwork, SongList } from '@/shared/ui'
import { likeEntry, removeEntry, setPlayMode } from './actions'
import { sourceName } from './source'
import './queue.css'
const noEntries: Entry[] = []
function songItem(entry: Entry): SongItem {
return {
key: entry.id,
title: entry.meta.title,
artist: entry.meta.artist || '未知艺术家',
album: entry.meta.album || '未知专辑',
coverUrl: entry.meta.coverUrl,
durationMs: entry.meta.durationMs,
sourceUrl: entry.meta.pageUrl,
sourceName: sourceName(entry.track),
note: entry.by ? `${entry.by.name} 点的歌` : undefined,
badge: entry.status === 'preparing' ? '准备中' : undefined,
}
}
function QueueCard({ label, entry, className = '' }: { label: string, entry: Entry, className?: string }) {
const song = songItem(entry)
return (
<div className={`queue-current ${className}`}>
<SongArtwork coverUrl={song.coverUrl} title={song.title} href={song.sourceUrl} sourceName={song.sourceName} />
<div className="song-copy">
<span className="text-ui-xs accent">{song.badge ? `${label} · ${song.badge}` : label}</span>
<strong className="text-ui-base truncate">{song.title}</strong>
<span className="text-ui-sm muted truncate">{song.sourceName ? `${song.artist} · ${song.sourceName}` : song.artist}</span>
</div>
</div>
)
}
export function QueuePage({ compact = false }: { compact?: boolean }) {
const nowPlaying = useStore(roomStore, s => s.room?.nowPlaying ?? null)
const pending = useStore(roomStore, s => s.room?.pending ?? null)
const items = useStore(roomStore, s => s.room?.items ?? noEntries)
const playMode = useStore(roomStore, s => s.room?.playMode ?? 'sequential')
const connected = useStore(roomStore, s => s.status === 'open')
return (
<section className={compact ? 'queue-panel' : 'page-content queue-page'} aria-label="播放列表">
<div className="section-heading">
<h1 className={compact ? 'text-ui-base' : 'text-ui-xl'}>
播放列表
<span className="text-ui-sm muted">{items.length}</span>
</h1>
<select className="queue-mode" aria-label="播放顺序" value={playMode} disabled={!connected} onChange={event => void setPlayMode(event.target.value as PlayMode)}>
<option value="sequential">顺序播放</option>
<option value="rotation" title="按点歌人轮流播放">轮流播放</option>
<option value="random">随机播放</option>
</select>
</div>
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} />}
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" />}
{items.length
? (
<SongList
songs={items.map(songItem)}
actions={song => [
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, run: () => likeEntry(song.key, song.title) },
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, run: () => removeEntry(song.key, song.title) },
]}
/>
)
: !pending && (
<EmptyState icon={ListMusic} title={nowPlaying ? '后面还没有歌' : '播放列表还是空的'}>
每个人都可以点歌,一起决定下一首。
</EmptyState>
)}
</section>
)
}
+9
View File
@@ -0,0 +1,9 @@
.queue-panel { padding: 24px 16px; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; flex: 1; min-height: 0; scrollbar-width: none; }
.queue-panel::-webkit-scrollbar { display: none; }
.queue-mode { width: auto; flex-shrink: 0; padding: 6px 10px; }
.queue-current { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; padding: 12px; background: var(--color-accent); border-radius: 12px; }
.queue-current .artwork { width: 48px; }
.queue-pending { background: var(--color-surface); }
.queue-panel .song-index, .queue-panel .song-album, .queue-panel .song-duration { display: none; }
.queue-panel .song-row { gap: 8px; }
.queue-panel .icon-button { width: 28px; height: 32px; }
+5
View File
@@ -0,0 +1,5 @@
import type { Track } from '@/core/realtime'
export function sourceName(track: Track): string | undefined {
return track.source === 'bilibili' ? '哔哩哔哩' : undefined
}
@@ -0,0 +1,71 @@
import { useState } from 'react'
import { api, saveTicket } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { Modal, notificationStore } from '@/shared/ui'
import { joinErrorMessage, roomEntryStore } from './room-entry'
import './rooms.css'
export function CreateRoomDialog({ onCreated, onClose }: { onCreated: (id: string) => void, onClose: () => void }) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [locked, setLocked] = useState(false)
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const ready = name.trim() !== '' && (!locked || password !== '')
const submit = async () => {
setBusy(true)
const secret = locked ? { password } : {}
let id: string
try {
id = (await api.createRoom({ name: name.trim(), description: description.trim(), ...secret })).id
}
catch (error) {
notificationStore.error(errorMessage(error))
setBusy(false)
return
}
notificationStore.success('已创建房间')
try {
saveTicket(id, (await api.createTicket(id, secret)).ticket)
}
catch (error) {
// 房间已经创建:进入后由加入弹窗重新换取票据,留在此处重试会再创建一个房间。
notificationStore.error(joinErrorMessage(error))
}
void roomEntryStore.enter(id)
onCreated(id)
}
return (
<Modal title="创建房间" onClose={onClose}>
<form
className="form-stack join-form"
onSubmit={(event) => {
event.preventDefault()
void submit()
}}
>
<label>
房间名称
<input data-autofocus value={name} onChange={event => setName(event.target.value)} required maxLength={80} placeholder="给这个房间起个名字" />
</label>
<label>
房间描述
<textarea value={description} onChange={event => setDescription(event.target.value)} maxLength={500} rows={3} placeholder="你想和大家听什么?" />
</label>
<label className="checkbox-label">
<input type="checkbox" checked={locked} onChange={event => setLocked(event.target.checked)} />
设置房间密码
</label>
{locked && (
<label>
房间密码
<input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} required />
</label>
)}
<button type="submit" className="button-primary" disabled={busy || !ready}>
{busy ? '正在创建…' : '创建并加入'}
</button>
</form>
</Modal>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { useStore } from '@/shared/lib/use-store'
import { JoinDialog } from './join-dialog'
import { roomEntryStore } from './room-entry'
export function EntryDialog({ onCancel }: { onCancel: () => void }) {
const phase = useStore(roomEntryStore, s => s.phase)
const room = useStore(roomEntryStore, s => s.room)
if (phase !== 'needs-ticket' || !room)
return null
return (
<JoinDialog
room={room}
onJoined={() => roomEntryStore.retry()}
onClose={() => {
// onCancel 要在放弃进入之前:离开这个房间的地址时,要靠进房的信息认出它。
onCancel()
roomEntryStore.reset()
}}
/>
)
}
+5
View File
@@ -0,0 +1,5 @@
export { EntryDialog } from './entry-dialog'
export { copyInviteLink } from './invite'
export { Lobby } from './lobby'
export { roomEntryStore } from './room-entry'
export { RoomGate } from './room-gate'
+15
View File
@@ -0,0 +1,15 @@
import type { RoomListing } from '@/core/realtime'
import { notificationStore } from '@/shared/ui'
export async function copyInviteLink(room: Pick<RoomListing, 'id' | 'slug'>): Promise<void> {
// 链接里不带密码,打开后由用户输入。
const url = `${window.location.origin}/r/${room.slug ?? room.id}`
try {
await navigator.clipboard.writeText(url)
notificationStore.success('已复制邀请链接')
}
catch {
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权。
notificationStore.info(`请手动复制邀请链接:${url}`, 8000)
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { RoomListing } from '@/core/realtime'
import { Headphones, LockKeyhole, Users } from 'lucide-react'
import { useState } from 'react'
import { api, loadTicket, saveTicket } from '@/core/api'
import { Modal, notificationStore } from '@/shared/ui'
import { joinErrorMessage } from './room-entry'
import './rooms.css'
export function JoinDialog({ room, onJoined, onClose }: { room: RoomListing, onJoined: () => void, onClose: () => void }) {
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const needsPassword = room.hasPassword && !loadTicket(room.id)
const submit = async () => {
setBusy(true)
try {
if (!loadTicket(room.id)) {
const { ticket } = await api.createTicket(room.id, room.hasPassword ? { password } : {})
saveTicket(room.id, ticket)
}
onJoined()
}
catch (error) {
notificationStore.error(joinErrorMessage(error))
setBusy(false)
}
}
return (
<Modal title="加入房间" onClose={onClose}>
<form
className="form-stack join-form"
onSubmit={(event) => {
event.preventDefault()
void submit()
}}
>
<div className="join-summary">
<div className="join-art">
<Headphones size={36} strokeWidth={1.2} aria-hidden="true" />
</div>
<div className="join-detail">
<h3 className="text-ui-lg">{room.name}</h3>
<p className="text-ui-caption muted">{room.description || '来这里,听见好音乐。'}</p>
<span className="join-meta text-ui-sm muted">
<Users size={14} aria-hidden="true" />
{room.onlineCount}
{' '}
人正在听
{room.hasPassword && (
<>
<span aria-hidden="true">·</span>
<LockKeyhole size={14} aria-hidden="true" />
需要密码
</>
)}
</span>
</div>
</div>
{needsPassword && (
<label>
房间密码
<input type="password" autoComplete="current-password" data-autofocus required value={password} onChange={event => setPassword(event.target.value)} placeholder="输入房间密码" />
</label>
)}
<p className="text-ui-caption muted">加入后,与房间内的所有人同步播放、点歌和聊天。</p>
<button type="submit" className="button-primary" disabled={busy || (needsPassword && !password)}>
{busy ? '正在加入…' : '加入房间'}
</button>
</form>
</Modal>
)
}
+13
View File
@@ -0,0 +1,13 @@
const lastRoomKey = 'alisten.last-room'
export function loadLastRoom(): string | null {
return localStorage.getItem(lastRoomKey)
}
export function saveLastRoom(roomId: string): void {
localStorage.setItem(lastRoomKey, roomId)
}
export function clearLastRoom(): void {
localStorage.removeItem(lastRoomKey)
}
+169
View File
@@ -0,0 +1,169 @@
import type { RoomListing } from '@/core/realtime'
import { ArrowRight, Headphones, LockKeyhole, Plus, Search, Users } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { brandStore } from '@/core/brand'
import { errorMessage } from '@/core/errors'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, notificationStore } from '@/shared/ui'
import { CreateRoomDialog } from './create-room-dialog'
import { JoinDialog } from './join-dialog'
import { roomEntryStore } from './room-entry'
import './rooms.css'
function matches(room: RoomListing, query: string): boolean {
const keyword = query.trim().toLowerCase()
return !keyword || `${room.name} ${room.description}`.toLowerCase().includes(keyword)
}
export function Lobby({ filter, onFilter, onEntered }: { filter: string, onFilter: (filter: string) => void, onEntered: (ref: string) => void }) {
const [rooms, setRooms] = useState<RoomListing[] | null>(null)
const [failed, setFailed] = useState(false)
const [reload, setReload] = useState(0)
const [selected, setSelected] = useState<RoomListing | null>(null)
const [creating, setCreating] = useState(false)
const brandName = useStore(brandStore, s => s.name)
useEffect(() => {
let active = true
api.listRooms().then(({ items }) => {
if (active)
setRooms(items)
}, (error) => {
if (!active)
return
notificationStore.error(errorMessage(error))
setFailed(true)
})
return () => {
active = false
}
}, [reload])
const visible = rooms?.filter(room => matches(room, filter)) ?? []
return (
<div className="page-content room-page">
<div className="page-title">
<span className="text-ui-sm accent">{brandName}</span>
<h1 className="text-ui-xl">好音乐,值得一起听</h1>
<p className="text-ui-base muted">找到你的听歌房,与同频的人分享此刻。</p>
</div>
<div className="room-toolbar">
<label className="search-field">
<Search size={18} aria-hidden="true" />
<input aria-label="搜索房间" value={filter} onChange={event => onFilter(event.target.value)} placeholder="搜索房间名称或描述" />
</label>
<button type="button" className="button-primary" onClick={() => setCreating(true)}>
<Plus size={18} />
创建房间
</button>
</div>
<div className="section-heading">
<h2 className="text-ui-lg">正在发生的音乐</h2>
<span className="muted text-ui-sm">{rooms ? `${visible.length} 个房间` : '正在寻找房间…'}</span>
</div>
{failed
? (
<EmptyState
title="暂时无法获取房间"
action={(
<button
type="button"
className="button-secondary"
onClick={() => {
setFailed(false)
setRooms(null)
setReload(n => n + 1)
}}
>
重试
</button>
)}
>
请稍后重试。
</EmptyState>
)
: !rooms
? <RoomSkeleton />
: !visible.length
? (
<EmptyState
icon={Headphones}
title={filter ? '没有找到这个房间' : '成为第一个创建房间的人'}
action={<button type="button" className="button-secondary" onClick={() => setCreating(true)}>创建房间</button>}
>
{filter ? '换个关键词,再找找看。' : '创建一个房间,邀请朋友一起听歌。'}
</EmptyState>
)
: (
<div className="room-grid">
{visible.map(room => (
<button type="button" className="room-card" key={room.id} onClick={() => setSelected(room)}>
<div className="room-card-art">
<div className="room-art">
<Headphones size={44} strokeWidth={1.2} aria-hidden="true" />
</div>
{room.hasPassword && (
<span className="room-card-badge text-ui-xs">
<LockKeyhole size={12} aria-hidden="true" />
密码
</span>
)}
<span className="room-card-join" aria-hidden="true">
<ArrowRight size={20} strokeWidth={2} />
</span>
</div>
<div className="room-card-copy">
<h3 className="text-ui-base truncate" title={room.name}>{room.name}</h3>
<p className="text-ui-caption muted">{room.description || '来这里,听见好音乐。'}</p>
<span className="text-ui-sm muted">
<Users size={14} aria-hidden="true" />
{room.onlineCount}
{' '}
人正在听
</span>
</div>
</button>
))}
</div>
)}
{selected && (
<JoinDialog
room={selected}
onJoined={() => {
setSelected(null)
const ref = selected.slug ?? selected.id
void roomEntryStore.enter(ref, selected)
onEntered(ref)
}}
onClose={() => setSelected(null)}
/>
)}
{creating && (
<CreateRoomDialog
onCreated={(id) => {
setCreating(false)
onEntered(id)
}}
onClose={() => setCreating(false)}
/>
)}
</div>
)
}
function RoomSkeleton() {
return (
<div className="room-grid" role="status" aria-label="正在加载房间">
{Array.from({ length: 8 }, (_, i) => (
<div className="room-card room-skeleton" key={i} aria-hidden="true">
<div className="room-card-art">
<div className="room-art" />
</div>
<div className="room-card-copy">
<span className="skeleton-line" />
<span className="skeleton-line" />
</div>
</div>
))}
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
import type { RoomListing } from '@/core/realtime'
import { api, ApiError, deleteTicket, IdentityRenewedError, loadTicket } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { CloseCode } from '@/core/realtime'
import { roomStore } from '@/core/room'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
import { clearLastRoom, loadLastRoom, saveLastRoom } from './last-room'
export type EntryPhase = 'idle' | 'resolving' | 'needs-ticket' | 'joining' | 'joined' | 'not-found' | 'failed'
interface EntryState {
phase: EntryPhase
// 房间的 UUID 或别名。
ref: string | null
room: RoomListing | null
}
export function joinErrorMessage(error: unknown): string {
return error instanceof IdentityRenewedError ? '没有加入房间,再加入一次。' : errorMessage(error)
}
class RoomEntryStore extends Store<EntryState> {
#attempt = 0
constructor() {
super({ phase: 'idle', ref: null, room: null })
}
start(): void {
roomStore.addEventListener('change', this.#onSession)
}
async enter(ref: string, known?: RoomListing): Promise<void> {
const attempt = ++this.#attempt
roomStore.leave()
this.setState({ phase: 'resolving', ref, room: known ?? null })
let room = known
if (!room) {
try {
room = await api.getRoom(ref)
}
catch (error) {
if (attempt !== this.#attempt)
return
if (error instanceof ApiError && error.code === 'NOT_FOUND') {
this.#notFound()
return
}
notificationStore.error(errorMessage(error))
this.setState({ phase: 'failed' })
return
}
if (attempt !== this.#attempt)
return
}
const ticket = loadTicket(room.id)
if (!ticket) {
this.setState({ phase: 'needs-ticket', room })
return
}
this.setState({ phase: 'joining', room })
roomStore.join(room.id, ticket)
}
resume(): string | null {
const roomId = loadLastRoom()
if (roomId)
void this.enter(roomId)
return roomId
}
retry(): void {
if (this.state.ref)
void this.enter(this.state.ref)
}
reset(): void {
++this.#attempt
roomStore.leave()
this.setState({ phase: 'idle', ref: null, room: null })
}
#onSession = (): void => {
const { phase, room } = this.state
const session = roomStore.state
if (!room || session.roomId !== room.id)
return
switch (session.stopCode) {
case CloseCode.unauthorized:
// 票据无效,或房间启用、更改了密码:重新查询房间,再让用户输入密码。
deleteTicket(room.id)
this.retry()
return
case CloseCode.notFound:
this.#notFound()
return
case CloseCode.invalidArgument:
roomStore.leave()
this.setState({ phase: 'failed' })
return
}
if (phase === 'joining' && session.room) {
saveLastRoom(room.id)
this.setState({ phase: 'joined' })
}
}
#notFound(): void {
roomStore.leave()
const lastRoom = loadLastRoom()
if (lastRoom !== null && (lastRoom === this.state.ref || lastRoom === this.state.room?.id))
clearLastRoom()
this.setState({ phase: 'not-found' })
}
}
export const roomEntryStore = new RoomEntryStore()
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, Skeleton } from '@/shared/ui'
import { roomEntryStore } from './room-entry'
export function RoomGate({ children, onBrowse }: { children: ReactNode, onBrowse: () => void }) {
const phase = useStore(roomEntryStore, s => s.phase)
const browse = <button type="button" className="button-primary" onClick={onBrowse}>发现房间</button>
switch (phase) {
case 'joined':
return children
case 'not-found':
return <EmptyState title="房间不存在或已关闭" action={browse}>换一个房间,继续听歌。</EmptyState>
case 'failed':
return (
<EmptyState title="暂时无法加入房间" action={<button type="button" className="button-primary" onClick={() => roomEntryStore.retry()}>重试</button>}>
请稍后重试。
</EmptyState>
)
default:
return <div className="page-content"><Skeleton rows={6} /></div>
}
}
+43
View File
@@ -0,0 +1,43 @@
.room-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 28px; }
.room-toolbar .search-field { height: 48px; padding: 0 20px; gap: 12px; border-radius: 999px; }
.room-toolbar .button-primary { padding: 10px 20px; border-radius: 999px; }
.join-form > .button-primary { width: 100%; border-radius: 999px; }
.room-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); gap: 24px 20px; }
.room-card { position: relative; min-width: 0; display: flex; flex-direction: column; gap: 12px; padding: 12px; text-align: left; border: 0; border-radius: 12px; background: var(--color-card); color: inherit; transition: background-color 150ms ease; }
.room-card:hover:not(:disabled), .room-card:focus-visible { background: var(--color-card-hover); }
.room-card-art { position: relative; width: 100%; }
.room-art { width: 100%; aspect-ratio: 1.6; display: grid; place-items: center; border-radius: 8px; background: var(--color-card-hover); color: var(--color-brand); }
.room-card-badge { position: absolute; top: 8px; left: 8px; display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 999px; background: var(--color-content); color: var(--color-foreground-subtle); }
.room-card-join { position: absolute; right: 8px; bottom: 8px; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--color-primary); color: var(--color-primary-foreground); box-shadow: 0 8px 16px var(--color-shadow); opacity: 0; transform: translateY(8px); transition: opacity 150ms ease, transform 150ms ease; }
.room-card:hover:not(:disabled) .room-card-join, .room-card:focus-visible .room-card-join { opacity: 1; transform: translateY(0); }
@media (hover: none) {
.room-card-join { opacity: 1; transform: none; }
}
:root[data-performance='low'] .room-card { transition: none; }
:root[data-performance='low'] .room-card-join { box-shadow: none; transform: none; transition: opacity 160ms ease; }
.room-card-copy { display: flex; flex-direction: column; gap: 4px; width: 100%; min-width: 0; }
.room-card-copy > p { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 3em; }
.room-card-copy > span { display: flex; align-items: center; gap: 6px; }
.room-skeleton { pointer-events: none; }
.room-skeleton .room-art { background: var(--color-surface-hover); }
.room-skeleton .room-card-copy { min-height: 86px; }
.room-skeleton .skeleton-line { display: block; height: 14px; width: 70%; border-radius: 4px; background: var(--color-surface-hover); }
.room-skeleton .skeleton-line + .skeleton-line { height: 12px; width: 45%; margin-top: 10px; background: var(--color-card-hover); }
.join-summary { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 16px; align-items: center; }
.join-art { width: 88px; aspect-ratio: 1; display: grid; place-items: center; border-radius: 12px; background: var(--color-surface); color: var(--color-brand); }
.join-detail { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.join-detail h3 { overflow-wrap: anywhere; }
.join-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
@media (max-width: 767px) {
.room-toolbar { gap: 8px; margin-bottom: 20px; }
.room-toolbar .search-field { height: 44px; padding: 0 16px; }
.room-toolbar .button-primary { padding: 10px 16px; }
.room-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.room-card { padding: 10px; gap: 10px; }
.room-art { aspect-ratio: 1; }
.room-art > svg { width: 36px; height: 36px; }
.room-card-join { right: 6px; bottom: 6px; width: 34px; height: 34px; }
.room-skeleton .room-card-copy { min-height: 78px; }
.join-summary { gap: 12px; }
.join-art { width: 72px; }
}
@@ -0,0 +1,6 @@
import type { SearchQuery } from './search-store'
import { SearchBox } from './search-box'
export function HeaderSearch({ onSearch }: { onSearch: (query: SearchQuery) => void }) {
return <SearchBox className="header-search" recent onSearch={onSearch} />
}
+7
View File
@@ -0,0 +1,7 @@
import { lazyPage } from '@/shared/ui'
export { HeaderSearch } from './header-search'
export { canOpenPlaylists, defaultSearchSource, keywordSearch, searchStore } from './search-store'
export type { SearchQuery } from './search-store'
export const SearchPage = lazyPage(() => import('./search-page').then(module => module.SearchPage))
export const PlaylistPage = lazyPage(() => import('./search-page').then(module => module.PlaylistPage))
+20
View File
@@ -0,0 +1,20 @@
import type { RoomKind } from '@/core/realtime'
import { isLink } from '@/core/realtime'
export function linkOf(input: string): string | null {
const link = input.trim()
return isLink(link) ? link : null
}
// 临时房间只能点 B 站的链接:服务端把它们识别成 B 站音源的歌,其他链接是只能在常驻房间点的链接曲目。kind 为 undefined 时不在房间里。
export function pickableLink(input: string, kind: RoomKind | undefined): string | null {
const link = linkOf(input)
if (link === null || kind === undefined)
return null
return kind === 'persistent' || isBilibiliLink(link) ? link : null
}
export function isBilibiliLink(link: string): boolean {
const host = new URL(link).hostname
return host === 'b23.tv' || host === 'bilibili.com' || host.endsWith('.bilibili.com')
}
+142
View File
@@ -0,0 +1,142 @@
import type { KeywordQuery, SearchQuery } from './search-store'
import { Clock, Link, Search, X } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { roomStore } from '@/core/room'
import { historyStore } from '@/features/history'
import { enqueue } from '@/features/queue'
import { useStore } from '@/shared/lib/use-store'
import { IconButton } from '@/shared/ui'
import { pickableLink } from './link'
import { keywordSearch, recordSearch, searchStore } from './search-store'
import './search.css'
const recentLimit = 4
export function SearchBox({ className, recent, onSearch }: { className: string, recent: boolean, onSearch: (query: SearchQuery) => void }) {
const text = useStore(searchStore, s => s.text)
const connected = useStore(roomStore, s => s.status === 'open')
const kind = useStore(roomStore, s => s.room?.room.kind)
const searches = useStore(historyStore, s => s.searches)
const searchesEnabled = useStore(historyStore, s => s.searchesEnabled)
const [open, setOpen] = useState(false)
const [linking, setLinking] = useState(false)
const boxRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const popoverId = useId()
const link = pickableLink(text, kind)
const recentSearches = recent && searchesEnabled ? searches.slice(0, recentLimit) : []
const popover = open && (link !== null || recentSearches.length > 0)
useEffect(() => {
if (!open)
return
const dismiss = (event: PointerEvent) => {
if (!boxRef.current?.contains(event.target as Node))
setOpen(false)
}
document.addEventListener('pointerdown', dismiss)
return () => document.removeEventListener('pointerdown', dismiss)
}, [open])
const search = (query: KeywordQuery) => {
// 不在房间里时不会搜索,也就不记入搜索历史。
if (roomStore.state.roomId !== null)
recordSearch(query)
setOpen(false)
onSearch(query)
}
return (
<div
className={`search-box ${className}`}
ref={boxRef}
onKeyDown={(event) => {
if (event.key === 'Escape')
setOpen(false)
}}
>
<form
className={`search-box-field${popover ? ' search-open' : ''}`}
role="search"
onSubmit={(event) => {
event.preventDefault()
const keyword = text.trim()
if (keyword)
search(searchStore.withKeyword(keyword))
}}
>
<Search size={22} strokeWidth={1.7} aria-hidden="true" />
<input
ref={inputRef}
aria-label="搜索音乐"
aria-haspopup="dialog"
aria-expanded={popover}
aria-controls={popover ? popoverId : undefined}
placeholder="想播放什么?"
maxLength={500}
value={text}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onChange={(event) => {
searchStore.setText(event.target.value)
// 回车或 Esc 关闭浮窗后焦点仍在输入框里,不会再触发 focus 和 click,所以输入时重新打开。
setOpen(true)
}}
/>
{text && (
<IconButton
icon={X}
label="清空搜索"
onClick={() => {
searchStore.setText('')
onSearch(searchStore.withKeyword(''))
// 清空按钮随文字一起卸载,需要把焦点移回输入框。
inputRef.current?.focus()
}}
/>
)}
</form>
{popover && (
<div className="search-popover" role="dialog" aria-label="搜索选项" id={popoverId}>
{link && (
<button
type="button"
className="search-popover-item search-link text-ui-caption"
disabled={!connected || linking}
aria-busy={linking}
onClick={async () => {
setLinking(true)
const added = await enqueue({ source: 'link', id: link })
setLinking(false)
// 请求期间输入框仍可编辑:文字未变时才清空,期间输入的内容要保留。
if (added && searchStore.state.text === text) {
searchStore.setText('')
setOpen(false)
}
}}
>
<Link size={16} aria-hidden="true" />
<span className="search-popover-copy">
<strong className="text-ui-base">{linking ? '正在点歌…' : '点这个链接'}</strong>
<span className="muted truncate">{link}</span>
</span>
</button>
)}
{recentSearches.length > 0 && (
<div className="search-recent-list">
{recentSearches.map(record => (
<button
type="button"
className="search-popover-item text-ui-caption"
key={record.query}
disabled={!connected}
onClick={() => search(keywordSearch(record.query, record.source, record.category))}
>
<Clock size={16} aria-hidden="true" />
<span className="truncate">{record.query}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)
}
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more