docs: reword comments and protocol descriptions for readability

This commit is contained in:
DiheChen committed 2026-11-11 00:25:12 +08:00
1 parent d72b4203cb
commit b986abf937
324 files changed
+1134 -1125

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
import antfu from '@antfu/eslint-config'
// 一个文件匹配到多个设置 no-restricted-imports 的配置对象时,只有最后一个生效:features 因此要带上这一条;core 和 shared 禁止引用 features 的任何路径,已经包含它。
// 一个文件匹配到多个设置了 no-restricted-imports 的配置对象时,只有最后一个生效。所以 features 的配置要再带上 featureInternals。core 和 shared 的配置禁止引用 features 下的任何路径,已经包含了 featureInternals。
const featureInternals = {
pattern: /^(?:@\/|\.\.?\/(?:.*\/)?)features\/[^/]+\/[^/]/i,
message: '功能之间只能通过对方的 index.ts 引用。',
@@ -12,7 +12,7 @@ const appImports = {
}
// no-restricted-imports 不检查 import(),动态导入由 no-restricted-syntax 用同一个正则检查。
// no-restricted-syntax 同样只有最后一个配置对象生效,所以要带上 @antfu/eslint-config 设置的两项,升级 antfu 时要与它保持一致。
// no-restricted-syntax 同样只有最后一个配置对象生效,所以要带上 @antfu/eslint-config 设置的两项。升级 antfu 时,这两项要和它保持一致。
function restrictImports(...restrictions) {
return {
'no-restricted-imports': ['error', { patterns: restrictions.map(({ pattern, message }) => ({ regex: pattern.source, message })) }],
@@ -1,18 +1,18 @@
# 主机名放在变量中,nginx 才会在每个请求时解析:serve 有两个副本且重启后地址会变,启动时上游也不必已经存在。
# serve 有两个副本,重启后地址会变,所以把主机名放在变量里,nginx 才会在每个请求时解析。这样启动时上游也不必已经存在。
resolver 127.0.0.11 valid=5s;
set $serve serve:8080;
set $rustfs rustfs:9000;
proxy_http_version 1.1;
absolute_redirect off;
# default.conf 中有匹配以 / 结尾的路径的正则 location,只有带 ^~ 的前缀 location 才能优先于它。
# default.conf 里有一个正则 location 匹配以 / 结尾的路径。前缀 location 只有带上 ^~ 才能优先于这个正则 location。
location ^~ /api/ {
# 不小于 serve 的请求体上限,请求体就不会写入临时文件,也就不会因此输出纯文本的 warn 日志。
# 缓冲区不小于 serve 的请求体上限,请求体就不会写进临时文件,nginx 也就不会因此输出纯文本的 warn 日志。
client_body_buffer_size 64k;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://$serve;
# 取值必须不小于 serve 对批量歌单接口的请求体上限 maxBatchBodyBytes(2304000 字节),否则大请求会被 nginx 以 HTML 的 413 拒绝,或写入临时文件。
# 这两项取值不能小于 serve 给批量歌单接口定的请求体上限 maxBatchBodyBytes(2304000 字节)。否则大请求会被 nginx 用 HTML 的 413 拒绝,或者写进临时文件。
location ~ ^/api/me/playlists/[^/]+/(removals|copies|moves)$ {
client_max_body_size 2250k;
client_body_buffer_size 2250k;
@@ -20,7 +20,7 @@ location ^~ /api/ {
proxy_pass http://$serve;
}
# 取值必须不小于 serve 对导入文件接口的请求体上限 maxImportBodyBytes(1 MiB),原因同上。
# 这两项取值不能小于 serve 给导入文件接口定的请求体上限 maxImportBodyBytes(1 MiB),原因同上。
location = /api/me/playlists/imports {
client_max_body_size 1m;
client_body_buffer_size 1m;
@@ -39,8 +39,8 @@ location = /ws {
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
# 只转发 link/ 下的读取请求:RustFS 的其他接口也在同一端口上。对象来自用户提交的链接且与页面同源,依靠这两个响应头阻止其中的脚本运行。
# 用 return 而不用 limit_except:拒绝时不写纯文本的错误日志。关闭缓冲:缓冲时超出内存缓冲区的响应会写入 /tmp 的 tmpfs。
# 只转发 link/ 下的读取请求,因为 RustFS 的其他接口也在同一个端口上。这些对象来自用户提交的链接,又和页面同源,所以靠末尾的两个响应头阻止其中的脚本运行。
# 用 return 而不用 limit_except,这样拒绝时不会写纯文本的错误日志。关闭缓冲,因为开着缓冲时,超出内存缓冲区的响应会写进 /tmp 的 tmpfs。
location ^~ /storage/${STORAGE_BUCKET_NAME}/link/ {
if ($request_method !~ ^(GET|HEAD)$) {
return 405;
+5 -5
View File
@@ -22,7 +22,7 @@ import { pwaStore } from './pwa'
import { UpdatePrompt } from './update-prompt'
import { panelDirection, withViewTransition } from './view-transition'
// adminStore 不在 main.tsx 中启动:静态导入会把后台页打包进入口 chunk。
// adminStore 不在 main.tsx 里启动,因为静态导入会把后台页打包进入口 chunk。
const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
module.adminStore.start()
return module.AdminPage
@@ -181,7 +181,7 @@ function Header({ page }: { page: Page }) {
)
}
// 页面自己订阅地址里的参数:筛选框每按一次键都改写地址,应用壳不必跟着重新渲染。
// 页面自己订阅地址里的参数,这样筛选框每按一次键改写地址时,应用壳不必跟着重新渲染。
function RoutedLobby({ onEntered }: { onEntered: (ref: string) => void }) {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'lobby')
@@ -287,7 +287,7 @@ export function App() {
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 内,打开的聊天面板会无法关闭。
// 加入房间前不显示面板,因为这时底部的切换按钮不可用,聊天视图的关闭按钮又在 RoomGate 里面,聊天面板一旦打开就关不掉。
const shownPanel = joined && panel !== page ? panel : null
const switchPanel = (next: Panel | null) => {
if (shownPanel && next && next !== shownPanel && next !== page)
@@ -309,7 +309,7 @@ export function App() {
}
return (
<>
{/* 播放引擎在房间为空时就已挂载,出错只可能源于运行环境,手动重试很可能同样失败,因此不提供重试按钮。 */}
{/* 不提供重试按钮,因为播放引擎在房间为空时就已经挂载,出错只可能源于运行环境,手动重试多半也会失败。 */}
<ErrorBoundary fallback={null} resetKey={songStartedAtMs} onError={() => notificationStore.error('音乐无法播放。下一首歌开始时会自动再试,也可以刷新页面再试。', 8000)}>
<AudioEngine onNextTrack={() => void voteSkip()} />
</ErrorBoundary>
@@ -394,7 +394,7 @@ export function App() {
fallback={null}
resetOn={roomEntryStore}
onError={() => {
// 弹窗已不再显示,进房流程却仍停在等待票据,页面会一直显示骨架屏:与关闭弹窗一样,放弃这次进入。
// 弹窗已经不显示了,进房流程却还停在等待票据,页面会一直显示骨架屏。所以和关闭弹窗一样,放弃这次进入。
navigationStore.leaveRoom()
roomEntryStore.reset()
notificationStore.error('加入房间的窗口出错了,没有加入房间,再选一次房间。')
+1 -1
View File
@@ -23,7 +23,7 @@ export function watchConnection(): void {
notificationStore.error('与房间的连接中断了,正在自动重连。', 5000)
}
})
// 握手时票据被拒绝会以 4401 断开,进房流程随后重新打开加入弹窗,不另外弹通知。
// 握手时票据被拒绝,连接会以 4401 断开,随后进房流程会重新打开加入弹窗,所以不另外弹通知。
roomStore.errors.on((error) => {
if (error.code === 'UNAUTHORIZED')
return
+1 -1
View File
@@ -19,7 +19,7 @@ export function DetailPanel({ view, children }: { view: Panel | null, children:
if (open || !shown || !panelRef.current)
return
const duration = Number.parseFloat(getComputedStyle(panelRef.current).animationDuration) * 1000
// 动画被取消或面板被断点隐藏时不会触发 animationend,由定时器兜底卸载。
// 动画被取消,或者面板被断点隐藏时,不会触发 animationend,所以由定时器兜底卸载。
const timer = setTimeout(setShown, duration ? duration + 50 : 0, null)
return () => clearTimeout(timer)
}, [open, shown])
+3 -3
View File
@@ -4,7 +4,7 @@ import { EmptyState } from '@/shared/ui'
interface ErrorBoundaryProps {
children: ReactNode
// fallback 为函数时,须把 retryButton 挂到"重试"按钮上:出错时若焦点在区域内,边界通过它把焦点移到按钮上。
// fallback 为函数时,要把 retryButton 挂到"重试"按钮上。出错时如果焦点在区域内,边界要通过 retryButton 把焦点移到按钮上。
fallback?: ReactNode | ((retry: () => void, retryButton: RefObject<HTMLButtonElement | null>) => ReactNode)
resetOn?: EventTarget
// 只在值变化时重试一次。resetOn 在 Store 每次变化时都会重试,Store 变化频繁时,持续出错的区域会反复失败。
@@ -14,7 +14,7 @@ interface ErrorBoundaryProps {
announce?: string
}
// 出错状态保存在实例上。主内容和面板内容的边界位于以页面或视图为 key 的元素内,切换时随之重新挂载,出错状态即被清除。
// 出错状态保存在实例上。主内容和面板内容的边界放在以页面或视图为 key 的元素里,切换页面或视图时边界会重新挂载,出错状态也就清除了。
export class ErrorBoundary extends Component<ErrorBoundaryProps, { failed: boolean }, Element | null> {
state = { failed: false }
retryButton = createRef<HTMLButtonElement>()
@@ -28,7 +28,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, { failed: boole
this.props.resetOn?.addEventListener('change', this.reset)
}
// 出错的内容卸载时,其中的焦点会落到 body 上:卸载前先记下当前焦点。
// 出错的内容卸载时,其中的焦点会落到 body 上,所以卸载前先记下当前焦点。
getSnapshotBeforeUpdate() {
return document.activeElement
}
+2 -2
View File
@@ -20,8 +20,8 @@ const root = document.getElementById('root')
if (!root)
throw new Error('index.html 缺少 #root 节点')
// 在首次渲染和进入房间之前启动:外观、性能等级、音量和品牌名要在第一帧生效,订阅房间的 Store 不能错过进入房间。
// 聊天面板按需加载,歌词页也不常驻,但聊天、搜索和歌词的 Store 要从进入房间起跟随房间变化,不能等到页面挂载。
// 这些 Store 要在首次渲染和进入房间之前启动,因为外观、性能等级、音量和品牌名要在第一帧生效,订阅房间的 Store 也不能错过进入房间。
// 聊天面板按需加载,歌词页也不常驻,但聊天、搜索和歌词的 Store 要从进入房间起就跟着房间变化,不能等到页面挂载再启动。
appearanceStore.start()
performanceStore.start()
volumeStore.start()
+7 -7
View File
@@ -21,7 +21,7 @@ function isCurrentRoom(ref: string): boolean {
return ref === entered || ref === room?.id || ref === room?.slug
}
// 显示什么由地址决定;后退和前进就是浏览器历史记录的后退和前进。
// 显示什么由地址决定。后退和前进就是浏览器历史记录的后退和前进。
class NavigationStore extends Store<NavigationState> {
#narrow: MediaQueryList | null = null
// 取消进入房间之后正在离开的房间:进房时的 ref、UUID 和别名。
@@ -37,10 +37,10 @@ class NavigationStore extends Store<NavigationState> {
this.#narrow.addEventListener('change', this.#onResize)
const parsed = parseRoute(window.location)
let route = this.#fit(parsed ?? lobby)
// 上次的房间记在所有标签页共用的 localStorage 里,后台又常在另一个标签页打开:后台不恢复房间,免得两个标签页同时放歌。
// 打开后台时不恢复上次的房间。上次的房间记在所有标签页共用的 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 }
}
@@ -74,7 +74,7 @@ class NavigationStore extends Store<NavigationState> {
this.replace(list)
}
// 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。
// 不在房间里时,房间的页面没有地址,所以改去发现房间。换页面时侧栏保持原样。
open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void {
const { panel } = this.state.route
if (page === 'lobby' || page === 'history')
@@ -98,7 +98,7 @@ class NavigationStore extends Store<NavigationState> {
this.navigate({ ...this.state.route, panel })
}
// 取消进入房间:要在 roomEntryStore.reset() 之前调用,离开时靠进房的信息认出这个房间的地址。
// 取消进入房间。leaveRoom 要在 roomEntryStore.reset() 之前调用,因为离开时要靠进房的信息认出这个房间的地址。
leaveRoom(): void {
const { ref, room } = roomEntryStore.state
this.#leaving = new Set([ref, room?.id, room?.slug].filter(value => typeof value === 'string'))
@@ -135,13 +135,13 @@ class NavigationStore extends Store<NavigationState> {
}
}
// 窄屏没有侧栏:打开带侧栏的地址时去掉它。
// 窄屏没有侧栏,所以打开带侧栏的地址时去掉 panel。
#fit(route: Route): Route {
return this.#narrow?.matches && route.panel ? { ...route, panel: null } : route
}
#show(next: Route): void {
// 关闭弹窗回到的记录地址不变:沿用原来的对象,订阅 route 的组件不必重新渲染。
// 关闭弹窗后回到的记录地址没有变,所以沿用原来的 route 对象,订阅 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)
+10 -10
View File
@@ -11,7 +11,7 @@ export type Panel = 'queue' | 'history' | 'chat'
// panel 是宽屏右侧的侧栏,和当前页面相同时不显示。
export type Route
= | { name: 'lobby', filter: string, panel: Panel | null }
// section 为 null 时地址是 /admin 或 /settings:宽屏显示第一个分类,窄屏显示分类列表。
// section 为 null 时地址是 /admin 或 /settings,这时宽屏显示第一个分类,窄屏显示分类列表。
// q 和 page 只用于后台的房间和媒体,sort 只用于媒体。
| { name: 'admin', section: AdminSection | null, panel: Panel | null, q: string, page: number, sort: MediaSort }
| { name: 'settings', section: SettingsSection | null, panel: Panel | null }
@@ -22,7 +22,7 @@ export type Route
| { name: 'search', ref: string, panel: Panel | null, query: SearchQuery }
| { name: 'playlist', ref: string, panel: Panel | null, source: Source, id: string, page: number }
| { name: 'shared-playlist', shareId: string, panel: Panel | null }
// 配对码在片段里,不进入请求和 Referer;写回地址时去掉,不留在历史记录中。
// 配对码放在片段里,这样不会进入请求和 Referer。写回地址时去掉片段,配对码就不会留在历史记录里。
| { name: 'pair', code: string, panel: Panel | null }
// GitHub 授权之后的落地页,片段里是 code=<兑换码> 或 error=<错误码>,同样不留在地址中。
| { name: 'github-login', fragment: string, panel: Panel | null }
@@ -32,7 +32,7 @@ export type Page = Route['name']
const settingsSections: Record<SettingsSection, true> = { account: true, appearance: true, playback: true, performance: true, privacy: true }
const adminSections: Record<AdminSection, true> = { site: true, registration: true, users: true, rooms: true, sources: true, media: true }
const mediaSorts: Record<MediaSort, true> = { stored: true, idle: true, size: true }
// 与接口对媒体搜索词的长度限制一致,按码点计:手改或截断的链接里更长的搜索词会让每次读取都失败。
// 和接口对媒体搜索词的长度限制一致,按码点计。手改或截断的链接里如果搜索词更长,每次读取都会失败。
const mediaQueryMaxLength = 100
function isOneOf<T extends string>(values: Record<T, true>, value: string): value is T {
@@ -52,7 +52,7 @@ function parsePanel(params: URLSearchParams): Panel | null {
return panel === 'queue' || panel === 'history' || panel === 'chat' ? panel : null
}
// 超出安全整数的页码写回地址时会变成另一个数,接口也不接受:当作第 1 页。
// 超出安全整数的页码当作第 1 页,因为这样的页码写回地址时会变成另一个数,接口也不接受。
function parsePage(params: URLSearchParams): number {
const page = Number(params.get('page'))
return Number.isSafeInteger(page) && page > 1 ? page : 1
@@ -79,7 +79,7 @@ function writeQuery(query: SearchQuery, params: URLSearchParams): void {
}
if (query.keyword)
params.set('q', query.keyword)
// 有关键词时总是写出来源:以后默认来源再变,旧链接的含义也不变。
// 有关键词时总是写出来源,这样以后默认来源再变,旧链接的含义也不变。
if (query.keyword || query.source !== defaultSearchSource)
params.set('source', query.source)
if (query.view !== 'all')
@@ -127,7 +127,7 @@ export function parseRoute({ pathname, search, hash }: Pick<Location, 'pathname'
const shared = /^\/s\/([^/]+)$/.exec(pathname)
if (shared)
return { name: 'shared-playlist', shareId: shared[1], panel }
// 不做 decodeURIComponent:UUID、别名和歌单 ID 不含需要转义的字符,而它遇到不合法的转义序列会抛出异常。
// 不做 decodeURIComponent,因为 UUID、别名和歌单 ID 不含需要转义的字符,而 decodeURIComponent 遇到不合法的转义序列会抛出异常。
const room = /^\/r\/([^/]+)(\/.*)?$/.exec(pathname)
if (!room)
return null
@@ -198,8 +198,8 @@ export function formatRoute(route: Route): string {
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
@@ -216,12 +216,12 @@ export function isNewEntry(from: Route, to: Route): boolean {
return false
}
// 从设置或后台的分类回到分类列表:上一条记录就是列表时后退,否则把当前记录换成列表。两种都不新增记录,返回键不会再回到刚离开的分类。
// 从设置或后台的分类回到分类列表。上一条记录就是列表时后退,否则把当前记录换成列表。两种做法都不新增记录,所以返回键不会再回到刚离开的分类。
export function upStep(list: Route, from: string | undefined, canBack: boolean): 'back' | 'replace' {
return canBack && from === formatRoute(list) ? 'back' : 'replace'
}
// 取消进入房间时离开这个房间的地址:在它的页面上就后退,一直退到进入房间之前的页面;没有可以后退的记录(直接打开的房间链接)时换成发现房间;不在它的页面上就留在原处。
// 取消进入房间时,离开这个房间的地址。当前在这个房间的页面上就后退,一直退到进入房间之前的页面。没有可以后退的记录时(直接打开的房间链接)换成发现房间。当前不在这个房间的页面上就留在原处。
export function leaveStep(route: Route, leaving: ReadonlySet<string>, canBack: boolean): 'back' | 'lobby' | 'stay' {
if (!('ref' in route) || !leaving.has(route.ref))
return 'stay'
+4 -4
View File
@@ -1,5 +1,5 @@
@import 'tailwindcss';
/* @theme 和 @utility 只在 Tailwind 入口样式的导入链中生效:改从 JS 导入,text-ui-*、muted 和 accent 就不会生成,构建也不报错。 */
/* @theme 和 @utility 只在 Tailwind 入口样式的导入链里生效。改从 JS 导入 theme.css 的话,text-ui-*、muted 和 accent 就不会生成,构建也不会报错。 */
@import '@/shared/ui/theme.css';
@layer base {
@@ -82,7 +82,7 @@
:root[data-view-direction='sink'] { --view-in: translateY(-8%); --view-out: translateY(100%); --view-swap: translateY(-10px); }
.detail-panel { 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; }
/* 只换关键帧里的位移:换 animation-name 会让面板在切换内容时重播打开动画。 */
/* 只换关键帧里的位移,因为换 animation-name 会让面板在切换内容时重播打开动画。 */
.detail-panel { --detail-panel-offset: translateX(16px); }
.detail-panel-sheet { --detail-panel-offset: translateY(100%); }
.detail-panel-closing { pointer-events: none; animation-name: detail-panel-close; }
@@ -94,7 +94,7 @@
from { width: var(--detail-panel-width); margin-left: 0; opacity: 1; transform: translateX(0); }
to { width: 0; margin-left: -8px; opacity: 0; transform: var(--detail-panel-offset); }
}
/* 视图过渡只移动面板内容:它的过渡分组裁剪在面板内,根快照不做动画,顶栏、主内容和播放器保持不动。 */
/* 视图过渡只移动面板内容。面板内容的过渡分组裁剪在面板内,根快照不做动画,所以顶栏、主内容和播放器保持不动。 */
::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; }
@@ -103,7 +103,7 @@
::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; }
/* 聊天在升起和沉下时都要压在列表上面,而新视图默认才在上层。 */
/* 聊天在升起和沉下时都要压在列表上面,而默认只有新视图在上层。 */
:root[data-view-direction='rise']::view-transition-new(detail-panel-content),
:root[data-view-direction='sink']::view-transition-old(detail-panel-content) { z-index: 1; border-radius: 12px 12px 0 0; box-shadow: 0 -8px 24px var(--color-shadow); }
@keyframes view-recede {
+1 -1
View File
@@ -4,7 +4,7 @@ import { performanceStore } from '@/features/settings'
type ViewDirection = 'forward' | 'backward' | 'rise' | 'sink'
// 播放列表和播放历史是左右并排的 tab;聊天从播放栏升起,盖在它们上面。
// 播放列表和播放历史是左右并排的 tab。聊天从播放栏升起,盖在这两个 tab 上面。
export function panelDirection(from: Panel, to: Panel): ViewDirection {
if (to === 'chat')
return 'rise'
+7 -7
View File
@@ -16,7 +16,7 @@ export class ApiError extends Error {
}
}
// 被拒绝的请求不会重发,操作没有生效:调用方的提示应说明是哪项操作。
// 被拒绝的请求不会重发,操作没有生效,所以调用方的提示要说明是哪项操作。
export class IdentityRenewedError extends ApiError {
constructor() {
super('刚才的操作没有生效,再试一次。', 'UNAUTHORIZED', 401)
@@ -24,7 +24,7 @@ export class IdentityRenewedError extends ApiError {
}
}
// rejected:被拒绝的 token 已删除,新身份尚未取回;renewalFailed:新身份没有取回,之后需要成员身份的请求在没有 token 时会先获取一个。
// rejected 表示被拒绝的 token 已经删除,新身份还没取回。renewalFailed 表示新身份没有取回,之后需要成员身份的请求在没有 token 时会先获取一个。
export type IdentityEvent
= | { kind: 'rejected' }
| { kind: 'renewed', member: Member }
@@ -86,9 +86,9 @@ function isUnauthorized(error: unknown): boolean {
return error instanceof ApiError && error.code === 'UNAUTHORIZED'
}
// 被拒绝的请求不重发:它以原来的身份发出,是否以新身份重发应由用户决定。
// 被拒绝的请求不重发,因为这个请求是以原来的身份发出的,要不要以新身份重发应由用户决定。
async function send<T>(request: HttpRequest): Promise<T> {
// 没有保存的 token 时,请求使用的是刚获取的 token,它被拒绝并不说明原来的身份已经失效。
// 没有保存的 token 时,请求用的是刚获取的 token,这个 token 被拒绝并不说明原来的身份已经失效。
const stored = request.auth === 'member' ? loadMemberToken() : null
try {
return await sendOnce<T>(request)
@@ -152,7 +152,7 @@ function memberToken(): Promise<string> {
return creatingMember
}
// 同一个 token 只更换一次身份:同时被拒绝的请求共用这一次,更换完成后才到达的拒绝也不再更换。
// 同一个 token 只更换一次身份。同时被拒绝的请求共用这一次更换,更换完成后才到达的拒绝也不再触发更换。
let renewal: { rejected: string, done: Promise<void> } | null = null
function renewIdentity(rejected: string): Promise<void> {
@@ -161,7 +161,7 @@ function renewIdentity(rejected: string): Promise<void> {
return renewal.done
}
// 更换过程中用 unrenewed 读取成员:新 token 也被拒绝时不再更换,避免循环。
// 更换过程中用 unrenewed 读取成员,这样新 token 也被拒绝时不会再次更换,避免循环。
async function replaceIdentity(rejected: string): Promise<void> {
// 其他标签页可能已经更换了身份,只删除被拒绝的那个 token。票据属于被拒绝的身份,一并删除。
if (loadMemberToken() === rejected) {
@@ -181,7 +181,7 @@ async function replaceIdentity(rejected: string): Promise<void> {
}
// 只有本标签页已经用过 token、并且保存的 token 换成了另一个时才算身份变了。
// 两个标签页同时创建游客时,对方先写入的 token 会被后写入的覆盖,此时本标签页还没有用过 token,或用的就是最终保存的 token,都不触发,避免互相刷新。
// 两个标签页同时创建游客时,对方先写入的 token 会被后写入的覆盖。这时本标签页要么还没有用过 token,要么用的就是最终保存的 token,两种情况都不触发,避免互相刷新。
export function followMemberTokenChanges(onChange: () => void): void {
window.addEventListener('storage', (event) => {
if (event.key !== memberTokenKey)
+1 -1
View File
@@ -26,7 +26,7 @@ export function deleteTicket(roomId: string): void {
localStorage.removeItem(ticketKeyPrefix + roomId)
}
// 票据属于签发时的成员,换了身份之后都不能再用。倒序遍历:删除会让后面的键前移。
// 票据属于签发时的成员,换了身份之后都不能再用。倒序遍历,因为删除会让后面的键前移。
export function deleteAllTickets(): void {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i)
+2 -2
View File
@@ -5,14 +5,14 @@ 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(() => {})
+2 -2
View File
@@ -4,7 +4,7 @@ import { RequestError } from '@/core/realtime'
export type ErrorTexts = Partial<Record<ErrorCode, string | ((message: string) => string)>>
// 这两个错误码的 message 写给接口调用方,带字段路径或协议版本,界面上换成用户能照着做的说法;其余错误码的 message 本身就是给用户看的。
// 这两个错误码的 message 是写给接口调用方的,带着字段路径或协议版本,所以界面上换成用户能照着做的说法。其余错误码的 message 本身就是给用户看的。
const texts: ErrorTexts = {
INVALID_ARGUMENT: '提交的内容不符合要求,请检查后再试。',
CLIENT_OUTDATED: '应用已经更新,稍后会自动刷新页面。',
@@ -15,7 +15,7 @@ export function errorText(code: ErrorCode, message: string, overrides: ErrorText
return typeof text === 'function' ? text(message) : text ?? message
}
// 没有错误码的 ApiError 和 RequestError 由前端构造,message 本来就是给用户看的;其他错误来自浏览器或程序缺陷,它们的 message 不给用户看。
// 没有错误码的 ApiError 和 RequestError 由前端构造,message 本来就是给用户看的。其他错误来自浏览器或程序缺陷,它们的 message 不给用户看。
export function errorMessage(error: unknown, overrides?: ErrorTexts): string {
if (!(error instanceof ApiError || error instanceof RequestError))
return '出错了,请稍后再试。'
+1 -1
View File
@@ -34,7 +34,7 @@ class FakeSocket extends EventTarget {
this.sent.push(JSON.parse(message) as Sent)
}
// 和浏览器一样,close 事件要到之后的任务才派发,这里不派发:测试用 lose() 模拟它。
// 浏览器要到之后的任务才派发 close 事件,所以 close() 不派发。测试用 lose() 模拟这个 close 事件。
close(): void {
this.readyState = FakeSocket.CLOSED
}
+2 -2
View File
@@ -4,8 +4,8 @@ import { reconnectDelay } from './backoff'
import { CloseCode, PROTOCOL_VERSION } from './protocol.gen'
const requestTimeoutMs = 60_000
// 服务端从接受连接起 10 秒内准备不好握手响应就会以 4408 断开,但响应带着完整的房间快照,传给客户端的时间不受这个限制。
// 这里留足余量,只用于服务端的关闭到不了客户端的情况。
// 服务端从接受连接起 10 秒内准备不好握手响应,就会以 4408 断开。但握手响应带着完整的房间快照,传给客户端的时间不受这个限制。
// helloTimeoutMs 留足了余量,只用于服务端的关闭到不了客户端的情况。
const helloTimeoutMs = 30_000
const pingIntervalMs = 5000
// 半开的连接上写入不会出错,要等 TCP 重传超时才会关闭,所以 ping 收不到回包就当作连接已失效。
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = 'ce462456c63356c6'
export const PROTOCOL_VERSION = 'd68525cd68ef691f'
export interface APIKey {
key: string
+1 -1
View File
@@ -48,7 +48,7 @@ export class RoomReplica {
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 }
}
+1 -1
View File
@@ -49,7 +49,7 @@ export function Accounts() {
const [username, setUsername] = useState('')
const [lookup, setLookup] = useState<Lookup>({ phase: 'idle' })
const [confirming, setConfirming] = useState(false)
// 查找进行中又按了回车时,以最后一次为准:先发出的查找晚回来也不覆盖,结果和输入框里的用户名一致。
// 查找进行中又按了回车时,以最后一次为准。先发出的查找晚回来也不会覆盖,这样显示的结果总对应最后一次查找的用户名。
const generationRef = useRef(0)
const find = async () => {
if (username === '')
+1 -1
View File
@@ -18,7 +18,7 @@ import './admin.css'
export type AdminSection = 'site' | 'registration' | 'users' | 'rooms' | 'sources' | 'media'
// section 为 null 表示窄屏回到分类列表;没有给出的 q、page、sort 取默认值。
// section 为 null 表示窄屏回到分类列表。没有给出的 q、page、sort 取默认值。
interface AdminView {
section: AdminSection | null
q?: string
+2 -2
View File
@@ -28,7 +28,7 @@ class AdminStore extends Store<{ signedIn: boolean }> {
this.setState({ signedIn: false })
}
// 失败时显示错误并返回 null;管理 token 被拒绝时退出登录。
// 失败时显示错误并返回 null。管理 token 被拒绝时退出登录。
// 204 的接口成功时得到 undefined,调用方要与 null 比较,不能只看真假。
async call<T>(request: () => Promise<T>, texts?: ErrorTexts): Promise<T | null> {
try {
@@ -41,7 +41,7 @@ class AdminStore extends Store<{ signedIn: boolean }> {
}
// 失败时抛出 Error,message 是给用户看的原因,由调用方就地显示,不弹通知。
// 管理 token 被拒绝时例外:退出登录后这一页就不显示了,所以照常提示。
// 管理 token 被拒绝时例外,照常提示,因为退出登录后这一页就不显示了。
async attempt<T>(request: () => Promise<T>, texts?: ErrorTexts): Promise<T> {
try {
return await request()
+6 -6
View File
@@ -22,7 +22,7 @@
.admin-hint { padding: 4px; color: var(--color-foreground-subtle); }
.admin-row-error { color: var(--color-destructive); font-size: var(--text-ui-caption); }
/* 中性底色不用 surface:浅色主题里它和 card 同色,放在卡片上会看不见。 */
/* 中性底色不用 surface,因为浅色主题里 surface 和 card 同色,放在卡片上会看不见。 */
.admin-status { display: inline-flex; flex-shrink: 0; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; background: var(--color-card-hover); color: var(--color-foreground-subtle); font-size: var(--text-ui-sm); font-weight: 500; white-space: nowrap; }
.admin-status[data-tone='success'] { background: var(--color-accent); color: var(--color-success); }
.admin-status[data-tone='danger'] { background: color-mix(in srgb, var(--color-destructive) 14%, transparent); color: var(--color-destructive); }
@@ -74,13 +74,13 @@
.media-sort { flex-shrink: 0; }
.media-sort > .select-trigger { min-height: 36px; padding: 6px 12px; border-color: transparent; border-radius: 10px; background: var(--color-card-hover); font-size: var(--text-ui-caption); }
.media-total { margin-left: auto; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* 操作条不占位置,盖在筛选上面:勾选之后表格不会移动,Shift 连选时不会点错行。滚动时停在顶部,删除和取消一直够得着。 */
/* 操作条不占位置,盖在筛选上面。这样勾选之后表格不会移动,Shift 连选时不会点错行。滚动时操作条停在顶部,删除和取消一直够得着。 */
.media-selection-anchor { position: sticky; top: 0; z-index: 3; height: 0; }
.media-selection { position: absolute; top: 0; left: 50%; display: flex; align-items: center; gap: 6px; width: max-content; max-width: 100%; padding: 2px 2px 2px 16px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-toast); box-shadow: 0 10px 30px var(--color-shadow); transform: translateX(-50%); animation: fade-in 120ms ease both; }
.media-selection-count { margin-right: 8px; font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; white-space: nowrap; }
.media-selection > button { min-height: 30px; padding: 4px 14px; border-radius: 999px; font-size: var(--text-ui-caption); }
/* 选中的行有底色,圆角要裁掉它。菜单在顶层显示,不会被裁。 */
/* 选中的行有底色,超出圆角的部分要裁掉。菜单在顶层显示,不会被裁。 */
.media-table { overflow: clip; transition: opacity 150ms; }
.media-table[aria-busy='true'] { opacity: .6; }
.media-table > ul { margin: 0; padding: 0; list-style: none; }
@@ -88,7 +88,7 @@
.media-head { padding-block: 10px 8px; color: var(--color-foreground-subtlest); font-size: var(--text-ui-sm); }
.media-row { min-height: 60px; border-top: 1px solid var(--color-card-hover); }
.media-row[data-selected] { background: var(--color-accent); }
/* 整格都能点:复选框本身只有 16px。 */
/* 整格都能点,因为复选框本身只有 16px。 */
.media-check { display: flex; align-items: center; align-self: stretch; margin: -8px -12px -8px -16px; padding: 0 12px 0 16px; cursor: pointer; }
.media-song { display: flex; align-items: center; gap: 10px; min-width: 0; }
.media-song > .artwork { width: 36px; border-radius: 6px; }
@@ -111,11 +111,11 @@
.admin-room-access { grid-area: access; }
.admin-room > .admin-menu-button { grid-area: menu; }
.media-toolbar > .admin-search { flex: 1 1 100%; width: auto; }
/* 搜索框独占一行,操作条只盖住这一行:排序和总数照常显示,操作条下面不会空出一行。 */
/* 搜索框独占一行,操作条只盖住这一行。这样排序和总数照常显示,操作条下面也不会空出一行。 */
.media-toolbar[data-hidden] > :not(.admin-search) { visibility: visible; }
.media-head, .media-row { grid-template-columns: 16px minmax(0, 1fr) 36px; }
.media-cell { display: none; }
/* 窄的时候封面让出位置:标题下面两行要放下大小、存储时间和最近播放。 */
/* 窄的时候封面让出位置,因为标题下面的两行要放下大小、存储时间和最近播放。 */
.media-song > .artwork { display: none; }
.media-song-copy .media-narrow { display: inline; }
.media-song-copy > .media-narrow { display: block; }
+1 -1
View File
@@ -116,7 +116,7 @@ export function GithubApp() {
<button type="submit" className="button-primary" disabled={busy || draft === null}>{busy ? '正在保存…' : app.clientId === null ? '开启' : '保存'}</button>
</div>
</form>
{/* 关闭之后重新读取配置,整个表单换成骨架屏:焦点落到分类标题。 */}
{/* 关闭之后重新读取配置,整个表单换成骨架屏,所以焦点落到分类标题。 */}
{closing && (
<ConfirmDialog
title="关闭 GitHub 登录"
+1 -1
View File
@@ -72,7 +72,7 @@ const keys = (store: MediaStore) => store.state.list?.items.map(item => item.key
const selected = (store: MediaStore) => [...store.state.selected].sort()
const messages = () => notificationStore.state.notifications.map(n => n.message)
// 等作废的读取走完:它不改变任何状态,没有可以等待的信号。
// 等作废的读取走完。作废的读取不改变任何状态,所以没有可以等待的信号。
const drain = () => new Promise(resolve => setTimeout(resolve, 20))
function releaseAll(): void {
+4 -4
View File
@@ -30,10 +30,10 @@ export function fallbackPage(list: AdminListMediaResponse): number | null {
return Math.max(1, Math.ceil(list.total / list.pageSize))
}
// 每打开一次媒体页新建一个:离开再回来时重新读取。
// 每打开一次媒体页就新建一个 MediaStore,这样离开再回来时会重新读取。
export class MediaStore extends Store<MediaState> {
#query: MediaQuery | null = null
// 只有最后发起的读取能写入:换了参数、重试或删除之后,此前的读取作废。
// 只有最后发起的读取能写入。换了参数、重试或删除之后,此前的读取作废。
#generation = 0
#anchor: string | null = null
@@ -41,7 +41,7 @@ export class MediaStore extends Store<MediaState> {
super({ list: null, loadedAt: 0, loading: false, error: null, selected: new Set() })
}
// 地址里的参数变化时调用;参数和上一次相同时不重新读取。换了参数就清空选择。
// 地址里的参数变化时调用。参数和上一次相同时不重新读取,换了参数就清空选择。
show(query: MediaQuery): void {
const current = this.#query
if (current && current.q === query.q && current.sort === query.sort && current.page === query.page)
@@ -74,7 +74,7 @@ export class MediaStore extends Store<MediaState> {
this.setState({ selected: new Set() })
}
// 失败时原因已经通过通知显示,返回 null。无论成败都重新读取当前页,读完才返回:界面不会再显示已经删掉的歌,中途出错时出错之前删掉的也一样。
// 失败时原因已经通过通知显示,返回 null。无论成败都重新读取当前页,读完才返回。这样界面不会再显示已经删掉的歌,中途出错时,出错之前删掉的歌也不会再显示。
async remove(keys: string[]): Promise<AdminRemoveMediaResponse | null> {
const result = await adminStore.call(() => api.adminRemoveMedia({ media: keys.map(key => ({ key })) }))
await this.#load()
+2 -2
View File
@@ -7,7 +7,7 @@ const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
// 地址里的搜索词变了之后输入框显示什么:它就是输入框自己提交的值(去掉了首尾空白)时保留原样的输入,比如末尾刚打的空格;被别处改掉时(清除搜索、后退)换成地址里的。
// 地址里的搜索词变了之后,决定输入框显示什么。新的搜索词就是输入框自己提交的值(去掉了首尾空白)时,保留原样的输入,比如末尾刚打的空格。搜索词被别处改掉时(清除搜索、后退),换成地址里的搜索词。
export function searchDraft(draft: string, q: string): string {
return draft.trim() === q ? draft : q
}
@@ -63,7 +63,7 @@ export function mediaByline(item: StoredMedia): string {
return item.artist ? `${item.artist} · ${origin}` : origin
}
// 不存在的歌(比如在别的标签页里已经删掉了)也算删除:对管理员来说结果一样。
// 不存在的歌(比如在别的标签页里已经删掉了)也算删除,因为对管理员来说结果一样。
export function removalMessage(requested: number, inUse: number): string {
const busy = `${inUse} 首正在房间里播放,没有删除`
if (inUse === requested)
+3 -3
View File
@@ -20,7 +20,7 @@ function enterRoom(room: RoomListing, onEntered: (ref: string) => void): void {
onEntered(ref)
}
// q 和 page 来自地址:改搜索词时回到第 1 页。
// q 和 page 来自地址。改搜索词时回到第 1 页。
export function PersistentRooms({ q, page, onFilter, onEntered }: {
q: string
page: number
@@ -33,7 +33,7 @@ export function PersistentRooms({ q, page, onFilter, onEntered }: {
const [editing, setEditing] = useState<RoomListing | 'new' | null>(null)
const [deleting, setDeleting] = useState<RoomListing | null>(null)
const [keyOf, setKeyOf] = useState<RoomListing | null>(null)
// 删掉的房间之后焦点落到哪一行:null 表示落到标题。
// 删掉房间之后焦点要落到的那一行,null 表示落到标题。
const nextFocusRef = useRef<string | null>(null)
useEffect(() => {
let active = true
@@ -166,7 +166,7 @@ export function PersistentRooms({ q, page, onFilter, onEntered }: {
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteRoom(deleting.id)) === null)
return
// 先在本地去掉这个房间,对话框关闭时那一行已经不在了,焦点才能落到下一行;之后再重新读取。
// 先在本地去掉这个房间,这样对话框关闭时那一行已经不在了,焦点才能落到下一行。之后再重新读取。
const remaining = rooms?.filter(room => room.id !== deleting.id) ?? []
const next = filterRooms(remaining, q, page)
// 删空了这一页时显示的是上一页,那里没有“下一行”。
+1 -1
View File
@@ -28,7 +28,7 @@ function pending(status: QRLoginStatus): boolean {
export function nextQrLogin(state: QrLoginState, event: QrLoginEvent): QrLoginStep {
if (!pending(state.status)) {
// 本机判定过期时可能还有一次查询尚未返回:它返回 success 时服务端已经保存了登录状态,仍按成功处理。
// 本机判定过期时,可能还有一次查询没有返回。这次查询返回 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 }
+1 -1
View File
@@ -6,7 +6,7 @@ import { adminStore } from './admin-store'
import { GithubApp } from './github-app'
import { LoadFailed, SectionHead } from './section-head'
// 保存期间不禁用开关:禁用会让它失去焦点,键盘用户就找不到它了。
// 保存期间不禁用开关,因为禁用会让开关失去焦点,键盘用户就找不到它了。
function OpenRegistrationRow({ open, onSaved }: { open: boolean, onSaved: (open: boolean) => void }) {
const switchId = useId()
const descriptionId = useId()
+1 -1
View File
@@ -1,4 +1,4 @@
// 行是带 data-key 的 li,焦点落到它的第一个控件上;没有这一行时落到分类标题。
// 行是带 data-key 的 li,焦点落到这一行的第一个控件上。没有这一行时落到分类标题。
export function rowOrHeading(headingId: string, key: string | null): HTMLElement | null {
const heading = document.getElementById(headingId)
const row = key === null ? null : heading?.closest('section')?.querySelector<HTMLElement>(`li[data-key="${CSS.escape(key)}"] :is(input, button)`)
@@ -6,7 +6,7 @@ import { formatDateTime } from '@/shared/lib/time'
import { ConfirmDialog, Modal, notificationStore } from '@/shared/ui'
import { adminStore } from './admin-store'
// 非安全上下文(比如局域网的明文 HTTP)没有剪贴板接口,用户也可能拒绝授权:这时返回 false,由调用方让用户手动复制。
// 非安全上下文(比如局域网的明文 HTTP)没有剪贴板接口,用户也可能拒绝授权。这时返回 false,由调用方让用户手动复制。
async function copyApiKey(key: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(key)
@@ -37,7 +37,7 @@ export function RoomApiKeyDialog({ room, onClose }: { room: RoomListing, onClose
active = false
}
}, [room.id, attempt])
// 不用 autoFocus:确认弹窗卸载时会把焦点还给打开它之前聚焦的元素(比如这个弹窗的关闭按钮),晚于 autoFocus;这个 effect 在那之后运行。
// 不用 autoFocus,因为确认弹窗卸载时会把焦点还给打开它之前聚焦的元素(比如这个弹窗的关闭按钮),时间晚于 autoFocus。这个 effect 在还回焦点之后才运行。
useEffect(() => {
if (key !== null)
inputRef.current?.focus()
@@ -69,7 +69,7 @@ export function RoomApiKeyDialog({ room, onClose }: { room: RoomListing, onClose
inputRef.current?.select()
notificationStore.info('没能写入剪贴板,请手动复制选中的 Key。', 8000)
}
// 确认弹窗与这个弹窗并列,不放进 Modal 里:React 沿组件树传递 dialog 的 cancel 事件,在确认弹窗里按 Esc 会把两个弹窗一起关掉。
// 确认弹窗与这个弹窗并列,不放进 Modal 里。React 沿组件树传递 dialog 的 cancel 事件,放进去的话,在确认弹窗里按 Esc 会把两个弹窗一起关掉。
return (
<>
<Modal title={`「${room.name}」的 API Key`} onClose={onClose}>
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { CloudOff } from 'lucide-react'
// 分类的标题要和内容一起立即渲染,加载中也不例外:从分类栏切换过来之后,焦点要落在它上面。
// 分类的标题要和内容一起立即渲染,加载中也不例外,因为从分类栏切换过来之后,焦点要落在标题上。
export function SectionHead({ id, title, description, children }: { id: string, title: string, description?: string, children?: ReactNode }) {
return (
<header className="admin-head">
+1 -1
View File
@@ -9,7 +9,7 @@ import { LoadFailed, SectionHead } from './section-head'
async function saveBrandName(name: string): Promise<void> {
const saved = await adminStore.attempt(() => api.adminUpdateSite({ brandName: name }), { INVALID_ARGUMENT: '品牌名要有 1 到 20 个字,不能含换行、制表符等特殊字符。' })
// 显示服务端存下的名字:浏览器和服务端去掉首尾空白的规则不完全相同。
// 显示服务端存下的名字,因为浏览器和服务端去掉首尾空白的规则不完全相同。
brandStore.setName(saved.brandName)
}
@@ -35,7 +35,7 @@ describe('sourceAccountView', () => {
}
})
// 后台分类栏的“音源”是否显示红点:查询中和查询失败时不知道状态,不显示。
// 后台分类栏的“音源”是否显示红点。查询中和查询失败时不知道状态,所以不显示。
describe('needsSignIn', () => {
for (const tc of cases) {
it(tc.name, () => {
+3 -3
View File
@@ -22,7 +22,7 @@ const sortOptions: (SelectOption & { value: MediaSort })[] = [
{ value: 'size', label: '占用最大' },
]
// 输入停顿之后才改写地址,输入法组字期间不算停顿:不拿拼音字母去搜索。
// 输入停顿之后才改写地址。输入法组字期间不算停顿,这样不会拿拼音字母去搜索。
function MediaSearch({ q, onSearch }: { q: string, onSearch: (q: string) => void }) {
const [draft, setDraft] = useState(q)
const [seenQ, setSeenQ] = useState(q)
@@ -64,7 +64,7 @@ export function MediaPage({ q, sort, page, onNavigate }: MediaQuery & { onNaviga
const error = useStore(store, s => s.error)
const selected = useStore(store, s => s.selected)
const [deleting, setDeleting] = useState<StoredMedia[] | null>(null)
// 删掉的歌之后焦点落到哪一行:null 表示落到标题。
// 删掉歌之后焦点要落到的那一行,null 表示落到标题。
const nextFocusRef = useRef<string | null>(null)
useEffect(() => store.show({ q, sort, page }), [store, q, sort, page])
@@ -76,7 +76,7 @@ export function MediaPage({ q, sort, page, onNavigate }: MediaQuery & { onNaviga
navigate({ q, sort, page: fallback })
}, [fallback, q, sort])
// 页码在表格下面,换页之后回到顶部。用 nearest:start 会把外层不该滚动的页面框架也一起滚上去。
// 页码条在表格下方,换页后把标题滚回视野里。用 nearest 而不是 start,因为 start 会把外层本不该滚动的页面框架也一起滚上去。
const goToPage = (next: number) => {
onNavigate({ q, sort, page: next })
document.getElementById('admin-media-title')?.scrollIntoView({ block: 'nearest' })
+2 -2
View File
@@ -17,7 +17,7 @@ function YouTubeCookiesDialog({ onSaved, onClose }: { onSaved: () => void, onClo
return
setBusy(true)
try {
// 空白和超过 64 KiB 的内容不会提交,INVALID_ARGUMENT 只剩认不出格式,这时的 message 是写给用户的。
// 空白和超过 64 KiB 的内容不会提交,所以 INVALID_ARGUMENT 只剩认不出格式这一种原因,这时的 message 是写给用户的。
const saved = await adminStore.call(() => api.adminSetYoutubeCredential({ cookies }), { INVALID_ARGUMENT: message => message })
if (saved !== null)
onSaved()
@@ -118,7 +118,7 @@ export function YouTubeCookies() {
onClose={() => setEditing(false)}
/>
)}
{/* 清除之后“清除”按钮不再显示,重新读取期间“填写 cookies”也不能点:焦点落到分类标题。 */}
{/* 清除之后“清除”按钮不再显示,重新读取期间“填写 cookies”也不能点,所以焦点落到分类标题。 */}
{clearing && (
<ConfirmDialog
title="清除 YouTube cookies"
+1 -1
View File
@@ -110,7 +110,7 @@ export function ChatPanel({ compact = false, onClose }: { compact?: boolean, onC
setSending(true)
try {
await chatStore.send(content)
// 请求期间输入框仍可编辑:只去掉已发出的部分,期间继续输入的内容要保留。
// 请求期间输入框仍可编辑,所以只去掉已发出的部分,期间继续输入的内容要保留。
setDraft(current => current.startsWith(draft) ? current.slice(draft.length) : current)
scrollToBottom()
}
+1 -1
View File
@@ -9,7 +9,7 @@ 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
+8 -8
View File
@@ -21,7 +21,7 @@ export interface SearchSettings {
}
interface HistoryState extends SearchSettings {
// 播放历史在服务端;还没有读到时为 null。
// 播放历史保存在服务端,还没有读到时为 null。
listens: MyListen[] | null
// 最近一次读取失败的原因,读取成功后为 null。
error: string | null
@@ -49,7 +49,7 @@ function sameTrack(a: Track, b: Track): boolean {
class HistoryStore extends Store<HistoryState> {
#playing = ''
#pages = 0
// 只有最后发起的读取能写入:之后又读过一次,或者删除、清空、换了身份之后,此前的读取作废。
// 只有最后发起的读取能写入。之后又读过一次,或者删除、清空、换了身份之后,此前的读取作废。
#generation = 0
#reading = 0
@@ -74,7 +74,7 @@ class HistoryStore extends Store<HistoryState> {
identityEvents.on(this.#onIdentity)
}
// 每打开一个历史页读取一次;页面开着时,服务端在开播时写入历史,所以正在播放的歌一变就重新读取。返回的函数在页面关闭时调用。
// 每打开一个历史页读取一次。服务端在开播时写入历史,所以页面开着时,正在播放的歌一变就重新读取。返回的函数在页面关闭时调用。
open(): () => void {
this.#pages++
void this.load()
@@ -132,7 +132,7 @@ class HistoryStore extends Store<HistoryState> {
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))
@@ -161,7 +161,7 @@ class HistoryStore extends Store<HistoryState> {
}
}
// 改动之前发起的读取可能带着改动前的历史返回,作废它们;有读取被作废时再读一次,免得错过其间开播的歌。
// 改动之前发起的读取可能带着改动前的历史返回,所以要作废这些读取。有读取被作废时再读一次,免得错过其间开播的歌。
#changed(listens: MyListen[]): void {
this.#generation++
this.setState({ listens })
@@ -178,8 +178,8 @@ class HistoryStore extends Store<HistoryState> {
void this.load()
}
// 成员 token 被拒绝时页面不重新加载:旧身份的历史不能留在页面上,新的游客还没有播放历史。新身份也可能是其他标签页
// 换来的、已经有历史的游客,所以换好之后页面开着就重新读取。
// 成员 token 被拒绝时页面不会重新加载。旧身份的历史不能留在页面上,新的游客又还没有播放历史,所以直接清空。
// 新身份也可能是其他标签页换来的游客,这样的游客可能已经有播放历史,所以换好之后,页面开着就重新读取。
#onIdentity = (event: IdentityEvent): void => {
if (event.kind === 'rejected') {
this.#generation++
@@ -190,7 +190,7 @@ class HistoryStore extends Store<HistoryState> {
}
}
// 其他标签页写入搜索记录时同步更新,本页之后的写入才不会覆盖它写入的记录。
// 其他标签页写入搜索记录时,本页跟着更新,这样本页之后的写入才不会覆盖那个标签页写入的记录。
#onStorage = (event: StorageEvent): void => {
if (event.key === searchesKey)
this.setState({ searches: parseSearches(event.newValue, this.state.searchLimit) })
+5 -5
View File
@@ -15,7 +15,7 @@ export interface LyricLine {
const timeTag = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g
const yrcLineTag = /^\[(\d+),(\d+)\]/
const yrcWordTag = /\((\d+),(\d+),\d+\)/g
// 逐字歌词没有空白行,要靠行尾和下一行的间隔判断间奏。间隔太短就算作换气:清除高亮再点亮会闪烁。
// 逐字歌词没有空白行,要靠行尾和下一行的间隔判断间奏。间隔太短就算作换气,否则清除高亮再点亮会闪烁。
const interludeGapMs = 3000
const maxTranslationGapMs = 500
const minInterludeMs = 7000
@@ -50,7 +50,7 @@ export function parseYrc(yrc: string): LyricLine[] {
const text = row.slice(tag.index + tag[0].length, tags[i + 1]?.index)
if (text.trim())
words.push({ startMs: Number(tag[1]), durationMs: Number(tag[2]), text })
// 字独立绘制:只含空格的字单独成字时,它两侧的空格不会合并,词间距会变成两倍。
// 字是独立绘制的。只含空格的字如果单独成字,它两侧的空格不会合并,词间距会变成两倍。
else if (words.length)
words[words.length - 1].text += text
}
@@ -70,13 +70,13 @@ export function wordProgress({ startMs, durationMs }: LyricWord, positionMs: num
return Math.min(Math.max((positionMs - startMs) / Math.max(durationMs, 1), 0), 1)
}
// 唱得久的短词才强调:短音节一闪而过看不出来,长词放大会挤到相邻的字。
// 唱得久的短词才强调,因为短音节一闪而过看不出来,长词放大会挤到相邻的字。
export function wordEmphasis({ durationMs, text }: LyricWord): number {
const { length } = text.trim()
return durationMs > 1000 && length > 0 && length < 8 ? Math.min(durationMs, 2000) / 1000 - 1 : 0
}
// 由翻译找行,而不是由行找翻译:否则没有翻译的行会借用相邻行的翻译。
// 由翻译找行,而不是由行找翻译,否则没有翻译的行会借用相邻行的翻译。
export function withTranslations(lines: LyricLine[], translations: LyricLine[]): LyricLine[] {
const sung = lines.filter(line => line.text)
const gap = (a: LyricLine, b: LyricLine) => Math.abs(a.timeMs - b.timeMs)
@@ -96,7 +96,7 @@ export function withTranslations(lines: LyricLine[], translations: LyricLine[]):
})
}
// 长间奏用点倒计时:每个点是一个不带文字的字,绘制时和逐字歌词走同一条路径。
// 长间奏用点倒计时。每个点是一个不带文字的字,绘制时和逐字歌词走同一条路径。
export function withInterludes(lines: LyricLine[]): LyricLine[] {
const first = lines.find(line => line.text)
const withIntro = first && lines[0].timeMs > 0 && first.timeMs >= minInterludeMs ? [{ timeMs: 0, text: '' }, ...lines] : lines
+1 -1
View File
@@ -23,7 +23,7 @@ class LyricsStore extends Store<{ lines: LyricLine[], index: number }> {
return
this.#track = track
const lines = nowPlaying ? buildLines(nowPlaying.lyric, nowPlaying.lyricWords, nowPlaying.lyricTrans) : []
// 换歌时新歌尚未开始播放,播放器给出的仍是上一首的位置:先按歌曲开头计算,开始播放后再由每帧的位置更新。
// 换歌时新歌还没开始播放,播放器给出的仍是上一首的位置。所以先按歌曲开头计算,开始播放后再由每帧的位置更新。
this.setState({ lines, index: lineIndexAt(lines, 0) })
}
+1 -1
View File
@@ -6,7 +6,7 @@
.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; }
/* 分数缩放下,相邻行进出合成层时文字会抖动。当前行不能这样固定:Chrome 不会按放大后的比例重新栅格化,文字会变糊。 */
/* 分数缩放下,相邻行进出合成层时文字会抖动。当前行不能这样固定,因为 Chrome 不会按放大后的比例重新栅格化,文字会变糊。 */
.lyric-line.near-lyric { opacity: .9; transform: translateY(2px) scale(.8); will-change: transform; }
.lyric-line.active-lyric { color: var(--color-foreground); opacity: 1; transform: translateY(0) scale(1.18); }
/* inline-block 才能位移和缩放,只有放大的字需要滤镜。 */
+3 -3
View File
@@ -31,7 +31,7 @@ function neighborLine(node: Element, side: 'previousElementSibling' | 'nextEleme
return neighbor?.classList.contains('lyric-interlude') ? neighbor[side] : neighbor
}
// 没有点的空白行不渲染节点:当前行为这种空白行时,滚动到前后两行的中间。
// 没有点的空白行不渲染节点。当前行是这种空白行时,滚动到前后两行的中间。
function centerLine(container: HTMLElement, nodes: Map<number, HTMLElement>, index: number, smooth: boolean): void {
const scrolledFrom = container.scrollTop
let before: HTMLElement | undefined
@@ -54,7 +54,7 @@ function centerLine(container: HTMLElement, nodes: Map<number, HTMLElement>, ind
rippleLines(container, nodes, container.scrollTop - scrolledFrom)
}
// 容器已经滚到位,每行再从原来的位置依次移过来。动画是叠加的:上一次还没走完时换行,不会跳回起点。
// 容器已经滚到位,每行再从原来的位置依次移过来。动画是叠加的,所以上一次还没走完时换行,也不会跳回起点。
function rippleLines(container: HTMLElement, nodes: Map<number, HTMLElement>, distance: number): void {
const reach = Math.abs(distance)
// 一次跨过整屏时(比如刚进房间)没有“下一行”可言,直接到位。
@@ -80,7 +80,7 @@ export function Lyrics() {
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 相同的节点,需要先清除上一首歌留下的高亮。
// React 会复用 key 相同的节点,所以要先清除上一首歌留下的高亮。
for (const node of nodes.values()) {
node.classList.remove('active-lyric', 'near-lyric')
node.removeAttribute('aria-current')
+1 -1
View File
@@ -33,7 +33,7 @@
.player-tools .volume-slider { width: 60px; }
}
@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); }
+1 -1
View File
@@ -71,7 +71,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
const artist = meta?.artist || '未知艺术家'
const source = nowPlaying ? sourceName(nowPlaying.entry.track) : undefined
const note = nowPlaying?.entry.by ? `${nowPlaying.entry.by.name} 点的歌` : undefined
// 记下打开时的歌:对话框开着时换了歌,或者这首播完了,勾选仍然作用于打开时的那首。
// 记下打开时的歌。这样对话框开着时换了歌,或者这首播完了,勾选仍然作用于打开时的那首。
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
return (
<section className="player-bar" aria-label="播放器">
+4 -4
View File
@@ -56,7 +56,7 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
this.#onRoom()
}
catch (error) {
// 挂载中途失败时必须清理:否则房间的订阅仍会在已移除的 <audio> 上加载和播放,重新挂载的 <audio> 也不会再加载这首歌。
// 挂载中途失败时必须清理,否则房间的订阅仍会在已移除的 <audio> 上加载和播放,重新挂载的 <audio> 也不会再加载这首歌。
this.detach()
throw error
}
@@ -67,7 +67,7 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
if (!audio)
return
this.#stop()
// pause 事件要到下一个任务才派发,那时监听已经移除,定时器和 rAF 只能在这里取消。
// pause 事件要到下一个任务才派发,那时监听已经移除,所以定时器和 rAF 只能在这里取消。
this.#onPause()
audio.removeEventListener('playing', this.#onPlaying)
audio.removeEventListener('pause', this.#onPause)
@@ -99,7 +99,7 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
this.#reload(current)
else
this.#start()
// 恢复播放时重新获取快照;即使快照没有变化,也按应在位置重新定位一次。
// 恢复播放时重新获取快照。即使快照没有变化,也按应在位置重新定位一次。
void roomStore.sync().then(() => {
if (this.#current && sameTrack(this.#current, current))
this.#start()
@@ -124,7 +124,7 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
return
}
if (previous && sameTrack(previous, next)) {
// 同一首歌的新快照来自重连的握手或 room.sync:连接恢复了,不等下一次重试;重试用完之后也靠它恢复。
// 同一首歌的新快照来自重连的握手或 room.sync,说明连接恢复了,所以不等下一次重试。重试用完之后也靠这样的快照恢复。
if (this.#audio?.error)
this.#reload(next)
return
+2 -2
View File
@@ -59,8 +59,8 @@ class ReminderStore extends Store<ReminderSettings> {
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 开始播放,两条事件之间的状态里没有这个条目,
// 所以只在快照和正在播放的歌变化时删掉不在房间状态里的记录。
// 开播时服务端先用 queue.updated 把条目移出队列,再用 playback.updated 开始播放。两条事件之间的房间状态里没有这个条目,
// 所以只在收到快照,或者正在播放的歌变化时,才删掉不在房间状态里的记录。
if (!live || next !== previous) {
for (const id of this.#firstSeen.keys()) {
if (!own.has(id))
@@ -1,4 +1,4 @@
// 游客在保存歌曲或歌单的对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。
// 游客在保存歌曲或歌单的对话框里点“去账号设置”时,打开账号设置。设置页在哪里、怎样打开由应用壳决定,应用壳在启动时把打开设置页的函数传进来。
let open = (): void => {}
export function openAccountSettings(): void {
@@ -12,7 +12,7 @@ export function CreatePlaylistForm({ onCreated, onCancel }: { onCreated: (playli
onKeyDown={(event) => {
if (event.key !== 'Escape' || isImeKey(event.nativeEvent))
return
// 拦下对话框的 Esc:在“保存到歌单”里,Esc 只回到列表。
// 拦下对话框的 Esc,因为在“保存到歌单”里,Esc 只回到列表。
event.preventDefault()
onCancel()
}}
@@ -30,7 +30,7 @@ export function CreatePlaylistForm({ onCreated, onCancel }: { onCreated: (playli
>
<label>
歌单名
{/* 刚打开的 Modal 按 data-autofocus 聚焦;在已经打开的对话框里切换过来时,由 autoFocus 聚焦。 */}
{/* 刚打开的 Modal 按 data-autofocus 聚焦。在已经打开的对话框里切换过来时,由 autoFocus 聚焦。 */}
<input data-autofocus autoFocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
</label>
<div className="dialog-actions">
@@ -29,7 +29,7 @@ function keysOf(items: readonly MyPlaylistItem[]): string[] {
return items.map(item => trackKey(item.track))
}
// 导出服务端的曲目:页面上的曲目可能还没读到,或者带着没同步完的移动和移出。
// 导出服务端的曲目,因为页面上的曲目可能还没读到,或者带着没同步完的移动和移出。
async function exportToFile(playlist: MyPlaylist): Promise<void> {
try {
const { items } = await api.listMyPlaylistItems(playlist.id)
@@ -56,16 +56,16 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
const [failed, setFailed] = useState<string | null>(null)
const [attempt, setAttempt] = useState(0)
const [selection, setSelection] = useState(noSelection)
// 点了“选择”:没有选中项时也显示勾选框。有选中项时总是处于多选模式。
// 点了“选择”之后,没有选中项时也显示勾选框。有选中项时总是处于多选模式。
const [picking, setPicking] = useState(false)
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
const [savingSelected, setSavingSelected] = useState(false)
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
// 移出之后焦点要去的那一首(null 表示歌单名):移出的行、随选择清空而消失的选择栏和确认框都带走了焦点。每次换一个新对象,同一个目标也能再次触发。
// 移出之后焦点要去的那一首(null 表示歌单名)。移出的行、随选择清空而消失的选择栏和确认框都带走了焦点,所以要重新指定焦点。每次换一个新对象,这样同一个目标也能再次触发。
const [refocus, setRefocus] = useState<{ key: string | null } | null>(null)
const titleRef = useRef<HTMLHeadingElement>(null)
const [edits] = useState(() => new TrackEdits(() => setAttempt(n => n + 1)))
// 歌单的更新时间变了就重新读取:加入、移出、移动和复制都会推进它,歌曲数不变时顺序也可能变了。
// 歌单的更新时间变了就重新读取。加入、移出、移动和复制都会推进更新时间,歌曲数不变时,顺序也可能变了。
useEffect(() => {
let active = true
const usable = edits.read()
@@ -89,7 +89,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
return () => window.removeEventListener('focus', refresh)
}, [])
const selecting = picking || selection.keys.size > 0
// 多选时落到勾选框上,平时落到行上,和列表里的方向键一样。
// 焦点在多选时落到勾选框上,平时落到行上,和列表里的方向键一样。
useEffect(() => {
if (!refocus)
return
@@ -111,9 +111,9 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
return () => document.removeEventListener('keydown', onKeyDown)
}, [selecting])
const now = useStore(playlistsStore, s => s.loadedAt)
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
// 读到了自己的歌单列表,里面却没有这个歌单,说明它在别处删掉了,或者现在登录的是另一个账号。
const gone = listed && !playlist
// 读不到时不再显示之前的曲目:它们可能已经不在了,也可能属于之前的账号。
// 读不到时不再显示之前的曲目,因为这些曲目可能已经不在了,也可能属于之前的账号。
const shown = gone || failed ? null : items
const exitSelection = () => {
@@ -260,7 +260,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
</div>
</header>
{body}
{/* 选中数的播报常驻在页面上:选择栏要等选中第一首才出现,跟着它出现的播报区域读屏软件不会播报。 */}
{/* 选中数的播报常驻在页面上,因为选择栏要等选中第一首才出现,而跟着选择栏一起出现的播报区域,读屏软件不会播报。 */}
<p className="sr-only" role="status">{shown && selected.length > 0 ? `已选 ${selected.length} 首` : ''}</p>
{!connected && shown?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
{playlist && shown && selected.length > 0 && (
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { formatPlaylistDate } from './playlist-date'
// 用本地时间构造时刻:断言与运行测试的时区无关。
// 用本地时间构造时刻,这样断言与运行测试的时区无关。
function at(year: number, month: number, day: number, hour = 0, minute = 0): number {
return new Date(year, month - 1, day, hour, minute).getTime()
}
+1 -1
View File
@@ -40,7 +40,7 @@ function text(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
// 文件可以随意改写:元数据的类型不对时当作没有,由服务端决定用不用;音源和 ID 不对就认不出是哪首歌,整个文件不导入。
// 文件可以随意改写。元数据的类型不对时当作没有,由服务端决定用不用。音源和 ID 不对就认不出是哪首歌,整个文件都不导入。
function importedTrack(entry: unknown): ImportedTrack | null {
if (!isRecord(entry) || typeof entry.source !== 'string' || !sources.has(entry.source) || typeof entry.id !== 'string' || entry.id === '')
return null
+1 -1
View File
@@ -12,7 +12,7 @@ async function copyShareLink(shareId: string, copied: string): Promise<void> {
notificationStore.success(copied)
}
catch {
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权;有的浏览器在请求返回之后已不算用户操作,也会拒绝写入。
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权。有的浏览器在请求返回之后已经不算用户操作,也会拒绝写入。
notificationStore.info(`请手动复制分享链接:${url}`, 8000)
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ function compareIds(a: MyPlaylist, b: MyPlaylist): number {
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
}
// 方向作用于整个排序键,反向得到的正好是倒过来的列表;“我喜欢”不参与,始终在最前。
// 方向作用于整个排序键,所以反向得到的正好是倒过来的列表。“我喜欢”不参与排序,始终在最前。
export function sortPlaylists(list: readonly MyPlaylist[], { key, order }: PlaylistSort): MyPlaylist[] {
const sign = order === 'asc' ? 1 : -1
return [...list].sort((a, b) => Number(b.favorites) - Number(a.favorites)
@@ -20,7 +20,7 @@ function rowOf(list: HTMLElement | null, key: string): Element | null {
return list?.querySelector(`[data-key="${CSS.escape(key)}"]`) ?? null
}
// 多选时焦点落在勾选框上,读屏软件才读得出这一行是否选中;平时落在行上。
// 多选时焦点落在勾选框上,这样读屏软件才读得出这一行是否选中。平时焦点落在行上。
function focusRow(row: Element | null | undefined, selecting: boolean): void {
const target = selecting ? row?.querySelector('.playlist-track-check') : row
if (target instanceof HTMLElement)
@@ -40,7 +40,7 @@ export function PlaylistTracks({ items, playing, selection, selecting, actions,
const lineRef = useRef<HTMLDivElement>(null)
const labelRef = useRef<HTMLDivElement>(null)
const focusAfterMoveRef = useRef<string | null>(null)
// 最后获得焦点的行。勾选框随多选出现和消失,焦点在它上面时会丢。
// 最后获得焦点的行。勾选框随多选出现和消失,焦点在勾选框上时会跟着丢掉。
const focusedRef = useRef<string | null>(null)
const keys = items.map(item => trackKey(item.track))
const { dragging, onPointerDown } = useTrackDrag({
@@ -53,7 +53,7 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
const profileFailed = useStore(profileStore, s => s.failed)
const [preference, setPreference] = useState(loadViewPreference)
const [creating, setCreating] = useState(false)
// 删除歌单之后要获得焦点的歌单(null 表示标题);每次删除换一个新对象,让同一个目标也能再次触发。
// 删除歌单之后要获得焦点的歌单(null 表示标题)。每次删除都换一个新对象,这样同一个目标也能再次触发。
const [refocus, setRefocus] = useState<{ id: string | null } | null>(null)
const titleRef = useRef<HTMLHeadingElement>(null)
const now = useStore(playlistsStore, s => s.loadedAt)
@@ -349,7 +349,7 @@ describe('playlistsStore', () => {
await flush()
void playlistsStore.containing(rain)
await flush()
// 先放行加入,同步结束;再放行加入之前发出的读取。
// 先放行加入,让同步结束。再放行加入之前发出的读取。
release.shift()!()
await flush()
release.shift()!()
+11 -11
View File
@@ -20,7 +20,7 @@ interface PlaylistsState {
playlists: MyPlaylist[] | null
// 最近一次读取失败的原因,成功读取后为 null。
error: string | null
// 歌单最近一次读取成功的时间,相对日期(今天、昨天)以它为准:窗口重新获得焦点会重新读取,跨过午夜后标签随之更新。
// 歌单最近一次读取成功的时间,相对日期(今天、昨天)以这个时间为准。窗口重新获得焦点时会重新读取,所以跨过午夜之后,标签也会随之更新。
loadedAt: number
// 曲目 → 包含它的歌单 id,只有读过的曲目才有。正在同步的歌单是最后一次勾选的结果,同步负责把服务端改成一致。
memberships: ReadonlyMap<string, ReadonlySet<string>>
@@ -30,13 +30,13 @@ const empty: PlaylistsState = { playlists: null, error: null, loadedAt: 0, membe
const nameTexts = { INVALID_ARGUMENT: '歌单名要 1 到 50 个字。' }
const fileTexts = { INVALID_ARGUMENT: '文件里有不符合要求的内容,没有导入。' }
// 接口只收 1 到 2048 字节的文本:太长的这里不发出,对话框不提交空的,INVALID_ARGUMENT 就只剩认不出链接,这时的 message 是写给用户的。
// 接口只收 1 到 2048 字节的文本。太长的在这里就不发出,对话框也不提交空的,所以 INVALID_ARGUMENT 只剩认不出链接这一种原因,这时的 message 是写给用户的。
const maxLinkBytes = 2048
const linkTexts: ErrorTexts = { INVALID_ARGUMENT: message => message }
class PlaylistsStore extends Store<PlaylistsState> {
#account: string | null = null
// 只有最后发起的读取能写入:换了账号,或者之后又读过一次时,旧的结果作废。
// 只有最后发起的读取能写入。换了账号,或者之后又读过一次时,旧的结果作废。
#generation = 0
#membershipReads = new Map<string, object>()
#syncing = new Map<string, Promise<void>>()
@@ -56,7 +56,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
return this.#load(++this.#generation)
}
// 读取包含这首歌的歌单,写入 memberships;没读到时返回 false。读到了却已被之后的读取取代时也返回 true,由后来的读取写入。
// 读取包含这首歌的歌单,写入 memberships。没读到时返回 false。读到了却已被之后的读取取代时也返回 true,由后来的读取写入。
async containing(track: Track): Promise<boolean> {
const account = this.#account
const key = trackKey(track)
@@ -89,7 +89,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
if (this.#syncing.get(key) !== sync)
return
this.#syncing.delete(key)
// 同步之前发出的读取可能带着旧的勾选返回,重新读一次让它们作废;请求超时但服务端已生效时,也以服务端为准。
// 同步之前发出的读取可能带着旧的勾选返回,所以重新读一次,让这些读取作废。请求超时但服务端已经生效时,也以服务端为准。
void this.containing(track)
void this.load()
})
@@ -184,7 +184,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
}
// 返回新加入的首数,to 里已经有的歌不算;失败时返回 null。
// 返回新加入的首数,to 里已经有的歌不算。失败时返回 null。
async copyItems(from: MyPlaylist, to: MyPlaylist, tracks: Track[]): Promise<number | null> {
try {
const { added } = await api.copyMyPlaylistItems(to.id, { from: from.id, tracks })
@@ -210,7 +210,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
}
// 开启分享和重新生成链接都用它,返回新的分享 ID;失败时返回 null。
// 开启分享和重新生成链接都用 share,返回新的分享 ID。失败时返回 null。
async share(playlist: MyPlaylist): Promise<string | null> {
try {
const { shareId } = await api.shareMyPlaylist(playlist.id)
@@ -247,19 +247,19 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
}
// 先改列表再重新读取:菜单要立刻显示新的分享状态,重新读取也让进行中的旧读取作废。
// 先改列表再重新读取,因为菜单要立刻显示新的分享状态。重新读取也会让进行中的旧读取作废。
#changed(id: string, change: Partial<MyPlaylist>): void {
this.setState({ playlists: this.state.playlists?.map(p => p.id === id ? { ...p, ...change } : p) ?? null })
void this.load()
}
// 先放进列表再重新读取:打开新歌单时,列表里要已经有它,否则详情页会以为它被删掉了。
// 先放进列表再重新读取,因为打开新歌单时,列表里要已经有这个歌单,否则详情页会以为它被删掉了。
#added(playlist: MyPlaylist): void {
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })
void this.load()
}
// 请求进行中又改了勾选时,等它完成之后按最新的勾选再发;失败时恢复成服务端的状态,不再发出。
// 请求进行中又改了勾选时,等这个请求完成之后,按最新的勾选再发。失败时恢复成服务端的状态,不再发出。
async #sync(playlistId: string, track: Track, saved: boolean): Promise<void> {
const account = this.#account
while (this.#account === account && this.#includes(playlistId, track) !== saved) {
@@ -293,7 +293,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
}
// 正在同步的歌单以本地为准:读到的可能是同步完成之前的状态。
// 正在同步的歌单以本地为准,因为读到的可能是同步完成之前的状态。
#applyMemberships(track: Track, playlistIds: readonly string[]): void {
const syncing = (id: string) => this.#syncing.has(membershipKey(id, track))
const next = new Set(playlistIds.filter(id => !syncing(id)))
+1 -1
View File
@@ -21,7 +21,7 @@
.playlist-cover-sm { width: 40px; }
.playlist-cover-md { width: 100%; }
.playlist-cover-lg { width: 232px; border-radius: 10px; }
/* z-index 为负:光晕画在最近的层叠上下文的最底层,在文字之下。放大封面的容器要用 isolation 建立层叠上下文,否则光晕会落到页面背景下面。 */
/* z-index 为负,所以光晕画在最近的层叠上下文的最底层,在文字之下。放大封面的容器要用 isolation 建立层叠上下文,否则光晕会落到页面背景下面。 */
.playlist-cover-glow { position: absolute; inset: 0; z-index: -1; opacity: var(--glow-opacity); filter: blur(24px) saturate(1.8); transform: translateY(10%) scale(.95); transition: opacity 240ms ease, transform 240ms ease; pointer-events: none; }
.playlist-cover-lg > .playlist-cover-glow { filter: blur(32px) saturate(1.8); }
:root:is([data-performance='low'], [data-performance='off']) .playlist-cover-glow { display: none; }
@@ -16,7 +16,7 @@ import './playlists.css'
const byUpdate: PlaylistSort = { key: 'updatedAt', order: 'desc' }
const none: ReadonlySet<string> = new Set()
// 保持打开时的先后:保存会更新歌单的更新时间,按实时的时间排,刚勾选的歌单会跳走。打开之后才出现的歌单排在最后。
// 保持打开对话框时的先后。保存会更新歌单的更新时间,如果按实时的更新时间排,刚勾选的歌单会跳走。打开之后才出现的歌单排在最后。
function arrange(playlists: readonly MyPlaylist[], order: readonly string[]): MyPlaylist[] {
const rank = new Map(order.map((id, index) => [id, index]))
const at = (playlist: MyPlaylist) => rank.get(playlist.id) ?? order.length
@@ -46,7 +46,7 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
const [creating, setCreating] = useState(false)
const [saving, setSaving] = useState(false)
const listRef = useRef<HTMLUListElement>(null)
// 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框,聚焦的同时把它滚进视野。
// 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框。聚焦的同时会把这个元素滚进视野。
const returnFocusRef = useRef<string | null>(null)
// 打开时刷新歌单:歌曲数和其他设备上新建的歌单。
useEffect(() => {
@@ -188,7 +188,7 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
}
export function SaveToPlaylistButton({ track, title, className = '' }: { track: Track, title: string, className?: string }) {
// 记下打开时的歌:对话框开着时换了歌,勾选仍然作用于打开时的那首。
// 记下打开时的歌。这样对话框开着时换了歌,勾选仍然作用于打开时的那首。
const [target, setTarget] = useState<{ track: Track, title: string } | null>(null)
return (
<>
@@ -16,7 +16,7 @@ import { saveToPlaylistLabel } from './save-label'
import { SaveToPlaylistDialog } from './save-to-playlist'
import './playlists.css'
// 带上读取的分享 ID:换到另一个分享时,上一个的结果不再显示。
// 结果带上读取时的分享 ID,这样换到另一个分享时,上一个分享的结果不再显示。
type Result = { shareId: string } & ({ kind: 'loaded', playlist: SharedPlaylist } | { kind: 'gone' } | { kind: 'failed', message: string })
function songItem({ track, meta }: MyPlaylistItem, playing: Track | undefined): SongItem {
+3 -3
View File
@@ -1,6 +1,6 @@
// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序;
// 一次失败之后,排在它后面的不再发出,由失败的那次回到它之前的样子。
// 修改进行中读到的列表可能是修改之前的,不能用;最后一个修改结束之后再读一次,以服务端为准。
// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序。
// 一次失败之后,排在后面的修改都不再发出,由失败的那次修改负责回滚,列表回到这次修改之前的样子。
// 修改进行中读到的列表可能是修改之前的,不能用。最后一个修改结束之后再读一次,以服务端为准。
export class TrackEdits {
readonly #reread: () => void
#tail: Promise<void> = Promise.resolve()
+3 -3
View File
@@ -1,5 +1,5 @@
// 与后端的 playlist.Move 一致:order 是显示顺序,把 moved 按给出的先后放到 before 之前,没有 before 时放到最后。
// moved 里不在 order 中的忽略,重复的只算一次;都不在 order 中时原样返回,此时不检查 before 是否还在。
// 与后端的 playlist.Move 一致。order 是显示顺序,把 moved 按给出的先后放到 before 之前,没有 before 时放到最后。
// moved 里不在 order 中的忽略,重复的只算一次。moved 都不在 order 中时原样返回,这时不检查 before 是否还在。
export function moveTracks<T>(order: readonly T[], moved: readonly T[], before?: T): T[] {
if (before !== undefined && moved.includes(before))
throw new Error('before 不能是要移动的歌')
@@ -22,7 +22,7 @@ export interface MovePlan<T> {
before: T | undefined
}
// 把 moved 收拢成一段,放在其余的歌中第 at 首之前;顺序不变时返回 null,不必发请求。
// 把 moved 收拢成一段,放在其余的歌中第 at 首之前。顺序不变时返回 null,不必发请求。
function place<T>(order: readonly T[], moved: ReadonlySet<T>, at: number): MovePlan<T> | null {
const block = order.filter(item => moved.has(item))
const before = order.filter(item => !moved.has(item))[at]
@@ -13,7 +13,7 @@ export function toggle(selection: Selection, key: string): Selection {
return { keys, anchor: key }
}
// 把锚点到 key 的一段加入选择;没有锚点,或者锚点已经不在列表里时,只切换 key。
// 把锚点到 key 的一段加入选择。没有锚点,或者锚点已经不在列表里时,只切换 key。
export function selectRange(selection: Selection, order: readonly string[], key: string): Selection {
const from = selection.anchor === null ? -1 : order.indexOf(selection.anchor)
const to = order.indexOf(key)
@@ -27,7 +27,7 @@ export function selectAll(order: readonly string[]): Selection {
return { keys: new Set(order), anchor: null }
}
// 列表重新读取之后去掉已经不在里面的歌;没有变化时返回原对象。
// 列表重新读取之后,从选择里去掉已经不在列表里的歌。没有变化时返回原对象。
export function retain(selection: Selection, order: readonly string[]): Selection {
const present = new Set(order)
const keys = [...selection.keys].filter(key => present.has(key))
+1 -1
View File
@@ -32,7 +32,7 @@ function insertionIndex(rows: readonly HTMLElement[], y: number): number {
return low
}
// 拖动结束时,按下和松开的位置不在同一行,浏览器仍会派发 click:吞掉它,免得切换选中。
// 拖动结束时,按下和松开的位置不在同一行,浏览器仍会派发 click。所以要吞掉这个 click,免得切换选中。
function swallowNextClick(): void {
const swallow = (event: MouseEvent) => {
event.stopPropagation()
@@ -29,7 +29,7 @@ export function CreateRoomDialog({ onCreated, onClose }: { onCreated: (id: strin
saveTicket(id, (await api.createTicket(id, secret)).ticket)
}
catch (error) {
// 房间已经创建:进入后由加入弹窗重新换取票据,留在此处重试会再创建一个房间。
// 房间已经创建,进入后由加入弹窗重新换取票据。留在此处重试的话,会再创建一个房间。
notificationStore.error(joinErrorMessage(error))
}
void roomEntryStore.enter(id)
+1 -1
View File
@@ -12,7 +12,7 @@ export function EntryDialog({ onCancel }: { onCancel: () => void }) {
room={room}
onJoined={() => roomEntryStore.retry()}
onClose={() => {
// onCancel 要在放弃进入之前:离开这个房间的地址时,要靠进房的信息认出它。
// onCancel 要在放弃进入之前调用,因为离开时要靠进房的信息认出这个房间的地址。
onCancel()
roomEntryStore.reset()
}}
+1 -1
View File
@@ -88,7 +88,7 @@ class RoomEntryStore extends Store<EntryState> {
return
switch (session.stopCode) {
case CloseCode.unauthorized:
// 票据无效,或房间启用、更改了密码:重新查询房间,再让用户输入密码。
// 票据无效,或者房间启用、更改了密码。这时重新查询房间,再让用户输入密码。
deleteTicket(room.id)
this.retry()
return
+1 -1
View File
@@ -6,7 +6,7 @@ export function linkOf(input: string): string | null {
return isLink(link) ? link : null
}
// 临时房间只能点 B 站的链接:服务端把它们识别成 B 站音源的歌,其他链接是只能在常驻房间点的链接曲目。kind 为 undefined 时不在房间里。
// 临时房间只能点 B 站的链接,因为服务端把 B 站链接识别成 B 站音源的歌,其他链接则是只能在常驻房间点的链接曲目。kind 为 undefined 时不在房间里。
export function pickableLink(input: string, kind: RoomKind | undefined): string | null {
const link = linkOf(input)
if (link === null || kind === undefined)
+2 -2
View File
@@ -87,7 +87,7 @@ export function SearchBox({ className, recent, onSearch }: { className: string,
onClick={() => {
searchStore.setText('')
onSearch(searchStore.withKeyword(''))
// 清空按钮随文字一起卸载,需要把焦点移回输入框。
// 清空按钮随文字一起卸载,所以要把焦点移回输入框。
inputRef.current?.focus()
}}
/>
@@ -105,7 +105,7 @@ export function SearchBox({ className, recent, onSearch }: { className: string,
setLinking(true)
const added = await enqueue({ source: 'link', id: link })
setLinking(false)
// 请求期间输入框仍可编辑:文字未变时才清空,期间输入的内容要保留。
// 请求期间输入框仍可编辑,所以文字没变时才清空,期间输入的内容要保留。
if (added && searchStore.state.text === text) {
searchStore.setText('')
setOpen(false)
+7 -7
View File
@@ -45,7 +45,7 @@ interface Results {
interface SearchState extends Results {
text: string
// 当前结果对应的关键词;为空时页面显示最近搜索。
// 当前结果对应的关键词,为空时页面显示最近搜索。
keyword: string
source: SearchSource
view: SearchView
@@ -67,7 +67,7 @@ export function keywordSearch(keyword: string, source: SearchSource, category: S
return { view: category === 'playlists' && !canOpenPlaylists(source) ? 'all' : category, keyword, source, page }
}
// 用户发起的搜索记入搜索历史:提交、点最近搜索、换来源和类别;翻页、推荐、打开歌单,以及前进、后退还原的搜索都不记。
// 用户发起的搜索记入搜索历史,包括提交、点最近搜索、换来源和类别。翻页、推荐、打开歌单,以及前进、后退还原的搜索都不记。
export function recordSearch(query: KeywordQuery): void {
if (query.keyword)
historyStore.recordSearch(query.keyword, query.source, query.view)
@@ -79,7 +79,7 @@ function sameQuery(a: SearchQuery, b: SearchQuery): boolean {
return a.view === b.view && a.keyword === b.keyword && a.source === b.source && a.page === b.page
}
// shown 是当前结果对应的条件。缓存里有就直接用,不必连着房间;连上房间之前不发出请求:推迟时让当前结果作废,之后回到哪个条件都会重新取。
// shown 是当前结果对应的条件。缓存里有就直接用,不必连着房间。连上房间之前不发出请求,推迟时让当前结果作废,这样之后回到哪个条件都会重新取。
export function nextLoad(key: string | null, shown: string | null, cached: boolean, connected: boolean, force: boolean): { shown: string | null, loading: boolean, from: 'none' | 'cache' | 'request' } | null {
if (key === null)
return { shown: null, loading: false, from: 'none' }
@@ -104,13 +104,13 @@ export function rememberResult<T>(cache: ReadonlyMap<string, Cached<T>>, key: st
return new Map([...kept, [key, entry] as [string, Cached<T>]].slice(-cacheLimit))
}
// 显示什么由地址决定:页面只把新的条件交给导航,地址变化之后再由 show() 和 showPlaylist() 取结果。
// 显示什么由地址决定。页面只把新的条件交给导航,地址变化之后再由 show() 和 showPlaylist() 取结果。
class SearchStore extends Store<SearchState> {
#request = 0
#roomId: string | null = null
#shown: string | null = null
#cache: ReadonlyMap<string, Cached<Partial<Results>>> = new Map()
// 只有显示着搜索页或歌单页时才请求,连上房间时也只为它们补发。
// 只有显示着搜索页或歌单页时才请求,连上房间时也只为这两种页面补发。
#visible = false
constructor() {
@@ -137,7 +137,7 @@ class SearchStore extends Store<SearchState> {
}
show(query: SearchQuery): void {
// 地址里的条件变了,搜索框的文字就跟着地址里的关键词,没有关键词时清空;只是开关侧栏、关掉弹窗时不动正在输入的文字。
// 地址里的条件变了,搜索框的文字就跟着地址里的关键词,没有关键词时清空。只是开关侧栏、关掉弹窗时,不动正在输入的文字。
const changed = !this.#visible || this.state.playlist !== null || !sameQuery(this.query, query)
this.#visible = true
const keyword = query.view === 'recommend' ? '' : query.keyword
@@ -227,7 +227,7 @@ class SearchStore extends Store<SearchState> {
return [view === 'tracks' ? tracks(page) : playlists(page)]
}
// 结果和缓存只属于当前房间,换了房间都作废(推荐由房间的播放历史得出);连上房间时补发还没有发出的请求。
// 结果和缓存只属于当前房间(推荐由房间的播放历史得出),换了房间都作废。连上房间时补发还没有发出的请求。
#onRoom = (): void => {
const { roomId, status } = roomStore.state
if (roomId !== this.#roomId) {
+1 -1
View File
@@ -113,7 +113,7 @@
}
@media (max-width: 767px) {
.search-page { padding-top: 8px; }
/* 窄屏的按钮宽 28px,搜索容器不超过 480px 时宽 32px:最后一列取按钮的宽度,按钮才能紧贴行的右侧。 */
/* 窄屏的按钮宽 28px,搜索容器不超过 480px 时宽 32px。最后一列取按钮的宽度,按钮才能紧贴行的右侧。 */
.search-song-table { --search-song-columns: 42px minmax(0, 1fr) auto; }
.search-song-table .song-album, .search-song-heading { display: none; }
.search-preview .song-row { grid-template-columns: 42px minmax(0, 1fr) auto; }
@@ -13,7 +13,7 @@ function submitted(event: FormEvent, run: () => Promise<void>): void {
void run()
}
// 绑定和解绑 GitHub 都会更换登录方式,先输入当前密码:只凭这台设备上的登录不能更换。onConfirm 返回 false 时,失败原因已经通过通知显示;
// 绑定和解绑 GitHub 都会更换登录方式,要先输入当前密码,因为只凭这台设备上的登录不能更换。onConfirm 返回 false 时,失败原因已经通过通知显示。
// 绑定成功时页面随即跳到 GitHub,对话框保持忙碌。
export function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise<boolean>, onClose: () => void }) {
const [currentPassword, setCurrentPassword] = useState('')
@@ -57,7 +57,7 @@ export function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login
}
}
catch (error) {
// 用 GitHub 确认身份已经过期:在对话框里重试也没用,关掉它,从按钮重新确认。
// 用 GitHub 确认身份已经过期,在对话框里重试也没用,所以关掉对话框,从按钮重新确认。
if (mode === 'credentials' && error instanceof ApiError && error.code === 'FORBIDDEN') {
notificationStore.error('用 GitHub 确认身份已经超过 10 分钟,请在“用户名和密码”一行再点一次“设置”。')
onClose()
@@ -41,7 +41,7 @@ class AppearanceStore extends Store<{ theme: Theme, fontSize: number }> {
#apply(): void {
const root = document.documentElement
root.dataset.theme = this.state.theme
// 只改 --ui-font-size,不改根元素的字号:间距和布局尺寸不随字号缩放。
// 只改 --ui-font-size,不改根元素的字号,这样间距和布局尺寸不随字号缩放。
root.style.setProperty('--ui-font-size', `${this.state.fontSize}px`)
this.#syncThemeColor()
}
@@ -28,7 +28,7 @@ export function GitHubLoginPage({ fragment, onDone }: { fragment: string, onDone
done()
return
}
// 注册或绑定:成员和成员 token 都不变,不必重新加载。能绑定 GitHub 的只有设置了用户名的账号,注册得到的账号没有用户名。
// 注册或绑定时,成员和成员 token 都不变,不必重新加载。能绑定 GitHub 的只有设置了用户名的账号,注册得到的账号没有用户名。
void profileStore.registered(result.account)
notificationStore.success(result.account.username ? '已绑定 GitHub' : '已用 GitHub 注册')
done()
+6 -6
View File
@@ -4,8 +4,8 @@ import { api, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { notificationStore } from '@/shared/ui'
// GitHub 跳回来时仍在发起授权的这个标签页,所以 nonce 存在 sessionStorage:别的标签页、别的浏览器拿到兑换码也没有它。
// 意图也存在这里,回来之后据此决定接下来做什么。
// GitHub 跳回来时仍在发起授权的这个标签页,所以 nonce 存在 sessionStorage。这样别的标签页、别的浏览器拿到兑换码,也拿不到 nonce。
// 意图也存在 sessionStorage 里,回来之后据此决定接下来做什么。
const nonceKey = 'alisten.github-nonce'
const intentKey = 'alisten.github-intent'
@@ -28,7 +28,7 @@ export async function startGithubLogin(intent: GitHubIntent, currentPassword?: s
return url
}
// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。
// 返回 false 时,失败原因已经通过通知显示。成功时页面随即跳到 GitHub。
export async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise<boolean> {
try {
window.location.assign(await startGithubLogin(intent, currentPassword))
@@ -46,7 +46,7 @@ export type GithubLanding
let landed: { fragment: string, landing: GithubLanding } | null = null
// fragment 是落地页地址中 # 之后的部分:code=<兑换码> 或 error=<错误码>。保存的 nonce 取出即删除,每次发起只能兑换一次;
// fragment 是落地页地址中 # 之后的部分:code=<兑换码> 或 error=<错误码>。保存的 nonce 取出即删除,所以每次发起只能兑换一次。
// 同一次落地重复读取时(开发模式下组件会挂载两次)返回同样的结果。
export function takeGithubLanding(fragment: string): GithubLanding {
if (landed?.fragment === fragment)
@@ -72,7 +72,7 @@ export function takeGithubLanding(fragment: string): GithubLanding {
let finishing: { code: string, result: Promise<FinishGithubLoginResponse> } | null = null
let credentialsPending = false
// 兑换码只能用一次:同一个兑换码重复调用时返回同一次请求的结果。
// 兑换码只能用一次,所以同一个兑换码重复调用时,返回同一次请求的结果。
export function finishGithubLogin(code: string, nonce: string, intent: GitHubIntent): Promise<FinishGithubLoginResponse> {
if (finishing?.code !== code) {
let result = api.finishGithubLogin({ code, nonce })
@@ -87,7 +87,7 @@ export function finishGithubLogin(code: string, nonce: string, intent: GitHubInt
return finishing.result
}
// 用 GitHub 确认了身份,接下来设置用户名和密码:落地页回到设置页,设置页打开对话框。用户名和密码不在跳转期间保存,回来之后才填写。
// 用 GitHub 确认了身份之后,接下来设置用户名和密码。落地页回到设置页,由设置页打开对话框。用户名和密码不在跳转期间保存,回来之后才填写。
// 只返回一次 true。
export function takeCredentialsPrompt(): boolean {
const pending = credentialsPending
@@ -8,8 +8,8 @@ interface NotificationPermissionState {
refused: boolean
}
// 浏览器的授权提示可能一直不返回:用户没理会,或者浏览器把提示收进了地址栏。所以等待期间可以放弃;
// 放弃之后提示才返回时只更新权限,不算这次申请的结果。
// 浏览器的授权提示可能一直不返回,比如用户没理会,或者浏览器把提示收进了地址栏。所以等待期间可以放弃。
// 放弃之后提示才返回时,只更新权限,不算这次申请的结果。
export class NotificationPermissionRequest extends Store<NotificationPermissionState> {
#latest = 0
@@ -17,8 +17,8 @@ export class NotificationPermissionRequest extends Store<NotificationPermissionS
super({ permission: Notification.permission, waiting: false, refused: false })
}
// 只能在用户操作的事件里调用:Safari 和 Firefox 不接受其他时候发起的权限申请。
// 返回这次申请是否拿到了授权;等待期间被放弃,或者又发起了新的申请时为 false。
// 只能在用户操作的事件里调用,因为 Safari 和 Firefox 不接受其他时候发起的权限申请。
// 返回这次申请是否拿到了授权。等待期间被放弃,或者又发起了新的申请时,返回 false。
async request(): Promise<boolean> {
const id = ++this.#latest
this.setState({ waiting: true, refused: false })
+1 -1
View File
@@ -3,7 +3,7 @@ import { PairingCodeForm } from './pairing-dialog'
import { profileStore, switchIdentity } from './profile-store'
import './settings.css'
// 扫描配对二维码打开的页面。要用户点一下才兑换:打开链接就自动登录的话,别人发来的链接能把你登进他的账号。
// 扫描配对二维码打开的页面。要用户点一下才兑换,因为打开链接就自动登录的话,别人发来的链接能把你登进他的账号。
// onSignedIn 在换身份、重新加载页面之前调用,由应用壳决定登录之后停在哪里。
export function PairPage({ code, onSignedIn }: { code: string, onSignedIn: () => void }) {
const account = useStore(profileStore, s => s.account)
+1 -1
View File
@@ -58,7 +58,7 @@ export function PairingDialog({ onClose }: { onClose: () => void }) {
)
}
// 兑换配对码就是登录:成功时把新会话的成员 token 交给调用方,由它换身份。
// 兑换配对码就是登录。成功时把新会话的成员 token 交给调用方,由调用方换身份。
export function PairingCodeForm({ initialCode = '', onSignedIn }: { initialCode?: string, onSignedIn: (memberToken: string) => void }) {
const [input, setInput] = useState(() => normalizePairingCode(initialCode))
const [busy, setBusy] = useState(false)
+3 -3
View File
@@ -1,9 +1,9 @@
import { api } from '@/core/api'
// 按用户名保存服务端发给本机的登录设备凭证:一台设备可能登录过多个账号。退出登录时保留,下次登录这个账号时带上,
// 别人反复猜这个账号的密码时本机仍然能登录。
// 按用户名保存服务端发给本机的登录设备凭证,因为一台设备可能登录过多个账号。退出登录时保留凭证,下次登录这个账号时带上,
// 这样别人反复猜这个账号的密码时,本机仍然能登录。
const devicesKey = 'alisten.login-devices'
// 与协议中的 LoginDevice 一致:本机保存的内容被改坏时不能带上,否则服务端会拒绝整个登录请求。
// 与协议中的 LoginDevice 一致。本机保存的内容被改坏时不能带上,否则服务端会拒绝整个登录请求。
const devicePattern = /^[\w-]{43}$/
function loadDevices(): Record<string, unknown> {
+6 -6
View File
@@ -5,14 +5,14 @@ import { errorMessage } from '@/core/errors'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
// 游客的邮箱只保存在本机,服务端只保存它的哈希;已注册账号的邮箱保存在账号里。
// 游客的邮箱只保存在本机,服务端只保存邮箱的哈希。已注册账号的邮箱保存在账号里。
const emailKey = 'alisten.email'
interface ProfileState {
member: Member | null
account: Account | null
email: string
// 是否记录播放历史,保存在服务端;还没有读到时为 null。
// 是否记录播放历史。这个设置保存在服务端,还没有读到时为 null。
recordListens: boolean | null
failed: boolean
}
@@ -38,7 +38,7 @@ class ProfileStore extends Store<ProfileState> {
return this.#loading
}
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册;本机的副本保留,下次加载时重试。
// 本机已有的邮箱存进新账号。存失败时只提示,不影响注册。这时本机的副本保留,下次加载时重试。
async registered(account: Account): Promise<void> {
this.setState({ account })
await this.#moveLocalEmail()
@@ -67,7 +67,7 @@ class ProfileStore extends Store<ProfileState> {
return true
}
// 一次只改一项,失败时才能说清是哪一项不合格:客户端不能按服务端的 message 区分字段。失败时抛出 Error,message 是给用户看的原因。
// 一次只改一项,失败时才能说清是哪一项不合格,因为客户端不能按服务端的 message 区分字段。失败时抛出 Error,message 是给用户看的原因。
async save(changes: { name: string } | { email: string }): Promise<void> {
const error = await this.#update(changes)
if (error === null)
@@ -140,7 +140,7 @@ class ProfileStore extends Store<ProfileState> {
if (error === null)
return
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
// 服务端不接受的副本重试也不会成功,删掉免得每次加载都报错;其他错误(网络、服务端故障)留到下次再试。
// 服务端不接受的副本,重试也不会成功,所以删掉,免得每次加载都报错。其他错误(网络、服务端故障)留到下次再试。
if (error instanceof ApiError && error.code === 'INVALID_ARGUMENT')
localStorage.removeItem(emailKey)
}
@@ -166,7 +166,7 @@ class ProfileStore extends Store<ProfileState> {
export const profileStore = new ProfileStore()
// 换了身份之后,进房票据和本机保存的邮箱都属于原来的身份,一并清除;重新加载页面,让房间连接和各处的状态都从新身份开始。
// 换了身份之后,进房票据和本机保存的邮箱都属于原来的身份,所以一并清除。然后重新加载页面,让房间连接和各处的状态都从新身份开始。
export function switchIdentity(token: string | null): void {
if (token === null)
deleteMemberToken()
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { SaveStatus, Switch } from '@/shared/ui'
// id 是行内控件的 id:标题用 label 指向它,说明和错误的 id 由它派生。
// id 是行内控件的 id。标题用 label 指向这个控件,说明和错误的 id 都由 id 派生。
export function SettingRow({ id, title, description, error, status, labelled = true, children }: {
id: string
title: ReactNode
@@ -44,10 +44,10 @@ export function IdentityEntry({ onOpen }: { onOpen: () => void }) {
)
}
// 编辑时才挂载:每次开始编辑都从当前的昵称开始,保存中和失败的状态随编辑结束一起丢掉。
// 编辑时才挂载,这样每次开始编辑都从当前的昵称开始,保存中和失败的状态也随编辑结束一起丢掉。
function NameInput({ name, onDone }: { name: string, onDone: (saved: boolean, refocus: boolean) => void }) {
const inputRef = useRef<HTMLInputElement>(null)
// 结束编辑之后输入框随即卸载,有的浏览器会在卸载时触发失焦,不能再提交一次。
// 结束编辑之后输入框随即卸载。有的浏览器会在卸载时触发失焦,这时不能再提交一次。
const doneRef = useRef(false)
const errorId = useId()
const { state, edit, submit } = useInlineSave(name, value => profileStore.save({ name: value.trim() }))
@@ -140,7 +140,7 @@ function NameEditor({ name }: { name: string }) {
}
return (
<div className="settings-name">
{/* 点名字也能编辑;键盘用旁边的铅笔按钮。 */}
{/* 点名字也能编辑。键盘操作时用旁边的铅笔按钮。 */}
<span className="settings-name-text" onClick={() => setEditing(true)}>{name}</span>
<button ref={pencilRef} type="button" className="icon-button settings-name-edit" aria-label="编辑昵称" title="编辑昵称" onClick={() => setEditing(true)}>
<Pencil size={15} strokeWidth={1.9} aria-hidden="true" />
@@ -153,7 +153,7 @@ function NameEditor({ name }: { name: string }) {
function IdentityCard({ member, account }: { member: Member, account: Account | null }) {
return (
<div className="settings-identity">
{/* 有头像时用它模糊后的样子做光晕;没有头像时这一层只有首字,由 CSS 隐藏。 */}
{/* 有头像时,用头像模糊后的样子做光晕。没有头像时这一层只有首字,由 CSS 隐藏。 */}
<Avatar hash={member.avatarHash} name={member.name} className="settings-identity-glow" />
<Avatar hash={member.avatarHash} name={member.name} className="settings-identity-avatar" />
<div className="settings-identity-copy">
+1 -1
View File
@@ -37,7 +37,7 @@ export function notificationHint(permission: NotificationPermission): { descript
}
}
// 申请没有拿到授权时的说明。被拒绝之后 notificationHint 已经写了去哪里允许;提示被关掉或者被浏览器收起时,权限仍是 default,
// 申请没有拿到授权时的说明。被拒绝之后,notificationHint 已经写了去哪里允许。提示被关掉或者被浏览器收起时,权限仍是 default,
// 再申请可能不会弹出提示,所以也指向网站设置。
export function notificationRefusal(permission: NotificationPermission): string {
return permission === 'denied' ? '浏览器拒绝了显示通知。' : '浏览器没有授权,可以在浏览器的网站设置里允许通知。'
@@ -18,7 +18,7 @@ function SystemNotificationsRow() {
const hint = notificationHint(permission)
const error = refused ? notificationRefusal(permission) : setting.state.error
const id = 'settings-system-notifications'
// 浏览器答复之后“授权”按钮不再显示,焦点要是在它上面就掉到了 body:交给同一行的开关。
// 浏览器答复之后“授权”按钮不再显示,焦点如果在这个按钮上,就会掉到 body。这时把焦点交给同一行的开关。
const authorizeShownRef = useRef(hint.authorize)
useEffect(() => {
const shown = authorizeShownRef.current
+4 -4
View File
@@ -13,7 +13,7 @@
.settings-identity { --color-input: var(--color-input-on-card); position: relative; isolation: isolate; display: flex; align-items: center; gap: 18px; overflow: hidden; padding: 22px; border: 1px solid var(--color-border); border-radius: 16px; background: var(--color-card); }
.settings-identity-avatar { width: 64px; font-size: 24px; }
/* 光晕的中心对准头像:内边距 22px 加头像半径 32px。只是几乎察觉不到的氛围,浅色主题的 --glow-opacity 更低,在浅底上同样淡。 */
/* 光晕的中心对准头像:内边距 22px 加头像半径 32px。光晕只是几乎察觉不到的氛围,浅色主题的 --glow-opacity 更低,在浅底上同样淡。 */
.settings-identity-glow { position: absolute; z-index: -1; top: 50%; left: 54px; width: 160px; background: transparent; box-shadow: none; color: transparent; opacity: calc(var(--glow-opacity) * .22); filter: blur(44px) saturate(1.3); transform: translate(-50%, -50%); pointer-events: none; }
.settings-identity-glow:not(:has(img)) { display: none; }
:root:is([data-performance='low'], [data-performance='off']) .settings-identity-glow { display: none; }
@@ -25,7 +25,7 @@
.settings-name-edit { width: 28px; height: 28px; border-radius: 50%; color: var(--color-foreground-subtlest); }
.settings-name-edit:hover:not(:disabled) { background: var(--color-surface-hover); color: var(--color-foreground); }
.settings-name-edit:focus-visible { outline-offset: 0; }
/* 输入框的文字和平时的名字对齐:负的外边距抵消边框和内边距。 */
/* 输入框的文字要和平时的名字对齐,所以用负的外边距抵消边框和内边距。 */
input.settings-name-input { width: min(100%, 280px); margin: -5px 0 -5px -9px; padding: 4px 8px; font-size: var(--text-ui-lg); font-weight: 700; line-height: 1.3; }
input.settings-name-input[aria-invalid='true']:not(:focus) { border-color: var(--color-destructive); }
.settings-identity-copy > .settings-row-error { margin-top: 4px; }
@@ -65,7 +65,7 @@ input.settings-name-input[aria-invalid='true']:not(:focus) { border-color: var(-
[data-theme-preview='light'] .settings-theme-preview { background: #f3f3f6; }
[data-theme-preview='light'] .settings-theme-preview > i:first-child { background: #e8e8ed; }
[data-theme-preview='light'] .settings-theme-preview > i:last-child { background: #fff; }
/* 浅的一半用浅色主题的内容底色:用 #f3f3f6 会和浅色主题下的卡片融成一片,整张看起来只剩深的一半。 */
/* 浅的一半用浅色主题的内容底色,因为用 #f3f3f6 会和浅色主题下的卡片融成一片,整张看起来只剩深的一半。 */
[data-theme-preview='system'] .settings-theme-preview { background: linear-gradient(90deg, #121212 50%, #fff 50%); }
[data-theme-preview='system'] .settings-theme-preview > i:first-child { background: #232326; }
.settings-theme-caption { display: flex; align-items: center; justify-content: space-between; gap: 4px; min-height: 18px; font-size: var(--text-ui-caption); }
@@ -73,7 +73,7 @@ input.settings-name-input[aria-invalid='true']:not(:focus) { border-color: var(-
.settings-volume-icon { color: var(--color-foreground-subtle); }
.settings-volume-control { display: flex; align-items: center; gap: 10px; width: 220px; max-width: 100%; }
/* 浅的 accent 会让浏览器把未填充的一段画成近黑色,在浅色主题的卡片里很扎眼;brand 在浅色主题里是深绿,未填充的一段就是浅色。 */
/* 浅的 accent 会让浏览器把未填充的一段画成近黑色,在浅色主题的卡片里很扎眼。brand 在浅色主题里是深绿,未填充的一段就是浅色。 */
.settings-volume-control .volume-slider { flex: 1; min-width: 0; accent-color: var(--color-brand); }
.settings-volume-control .volume-input-control { display: flex; align-items: center; }
/* 百分比平时像一段文字,悬停或聚焦时才显出输入框。 */
-1
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
// 站点是否开放注册、是否开启 GitHub 登录。
export type Site = { status: 'loading' } | { status: 'failed' } | { status: 'ready', registrationOpen: boolean, githubLogin: boolean }
export function useSite(): { site: Site, reload: () => void } {
+1 -1
View File
@@ -1,4 +1,4 @@
// 歌名里已经有书名号时不再加一层:B 站的标题常带《》。
// 歌名里已经有书名号时不再加一层,因为 B 站的标题常带《》。
export function bookTitle(title: string): string {
return /[《》]/.test(title) ? title : `《${title}》`
}
+2 -2
View File
@@ -1,5 +1,5 @@
// 这次按键属于输入法,比如用回车确认候选词、用 Esc 取消拼写,不是给输入框的。
// Safari 先结束组字再触发这次 keydown,isComposing 已经是 false,只能靠 keyCode 229 认出来。
// 返回这次按键是否属于输入法,比如用回车确认候选词、用 Esc 取消拼写。这样的按键不是给输入框的。
// Safari 先结束组字再触发这次 keydown,这时 isComposing 已经是 false,只能靠 keyCode 229 认出来。
export function isImeKey(event: Pick<KeyboardEvent, 'isComposing' | 'keyCode'>): boolean {
return event.isComposing || event.keyCode === 229
}
+2 -2
View File
@@ -1,4 +1,4 @@
// saved 是已经保存的值,draft 是输入框里的值;两者不同就是“编辑中”。
// saved 是已经保存的值,draft 是输入框里的值。两者不同就是“编辑中”。
export interface InlineSaveState {
draft: string
saved: string
@@ -6,7 +6,7 @@ export interface InlineSaveState {
error: string | null
}
// succeeded 的 value 是提交的值;external 是外部传入的新值,比如其他设备改了资料。
// succeeded 的 value 是提交的值。external 的 value 是外部传入的新值,比如其他设备改了资料。
export type InlineSaveEvent
= | { type: 'edit', value: string }
| { type: 'submit' }
+2 -2
View File
@@ -1,5 +1,5 @@
// 页码条最多 7 项:首页、省略号、当前页和前后各一页、省略号、末页。不超过 7 页时全部列出;
// 省略号只盖住一页时,直接显示那一页,占的位置一样。
// 页码条最多 7 项:首页、省略号、当前页和前后各一页、省略号、末页。不超过 7 页时全部列出。
// 省略号只盖住一页时,直接显示那一页,因为占的位置一样。
export function pageItems(current: number, totalPages: number): (number | 'gap')[] {
if (totalPages <= 7)
return Array.from({ length: totalPages }, (_, index) => index + 1)
+1 -1
View File
@@ -1,4 +1,4 @@
// 锚点是上一次点击的 id。按住 Shift 点击时选中锚点到这一项之间的全部,不取消范围外已经选中的项;
// 锚点是上一次点击的 id。按住 Shift 点击时选中锚点到这一项之间的全部,不取消范围外已经选中的项。
// 没有锚点,或者锚点已经不在列表里(比如翻了页)时,和普通点击一样只切换这一项。
export function toggleSelection(selected: ReadonlySet<string>, ids: readonly string[], id: string, anchor: string | null, shift: boolean): { selected: Set<string>, anchor: string } {
const next = new Set(selected)
+2 -2
View File
@@ -1,5 +1,5 @@
// 删掉列表里的若干行之后焦点落到哪一行:真正删掉的第一行原来的位置上,现在排着的那一行(后面的行补上来,翻页补进来的也算)。
// before 和 after 是删除前后这一页各行的 key。那个位置上已经没有行时返回 null。
// 删掉列表里的若干行之后,焦点落到真正删掉的第一行原来的位置上,也就是现在排在这个位置的那一行。后面的行补上来、翻页补进来的行都算。
// before 和 after 是删除前后这一页各行的 key。一行也没有真正删掉,或者这个位置上已经没有行时,返回 null。
export function rowAfterRemoval(before: readonly string[], after: readonly string[], removed: readonly string[]): string | null {
const remaining = new Set(after)
const requested = new Set(removed)
+1 -1
View File
@@ -1,4 +1,4 @@
// 单选组里方向键要去的那一项,和原生的单选按钮一样:←/↑ 和 →/↓ 等价,到头之后从另一头接着走。不是这几个键时返回 null。
// 返回单选组里方向键要去的那一项,规则和原生的单选按钮一样。← 和 ↑ 等价,→ 和 ↓ 等价,到头之后从另一头接着走。不是这几个键时返回 null。
export function rovingIndex(key: string, index: number, count: number): number | null {
if (!count)
return null
+1 -1
View File
@@ -33,7 +33,7 @@ interface HistoryEntry {
url: string
}
// 和浏览器一样,后退要到之后的任务才完成:settle() 模拟那一刻的 popstate。
// 和浏览器一样,back() 要到之后的任务才完成后退。settle() 模拟后退完成时派发的 popstate。
class FakeHistory {
entries: HistoryEntry[]
at = 0
+9 -9
View File
@@ -1,6 +1,6 @@
export interface Position {
index: number
// 已知的最后一条记录:后退之后,后面的记录还在,可以前进。
// 已知的最后一条记录。后退之后,后面的记录还在,所以还可以前进。
last: number
}
@@ -42,7 +42,7 @@ let position: Position = { index: 0, last: 0 }
let current: EntryState = { index: 0 }
let notify = (): void => {}
const modals: { close: () => void }[] = []
// 退回标记记录下面的页面要等到 popstate 才算完成,其间的写入排队,落在页面的记录上。
// 从标记记录退回下面的页面,要等到 popstate 才算完成。这期间的写入先排队,退回之后再落在页面的记录上。
let awaitingBack = false
const queued: Write[] = []
@@ -62,8 +62,8 @@ function readEntry(state: unknown): EntryState | null {
return entry
}
// 调用太频繁时浏览器会抛出 SecurityError,输入筛选词时每次按键都要替换地址:这时只让地址暂时落后,页面照常显示。
// 写不进弹窗的标记记录时弹窗照常显示,返回键在关掉它的同时也会后退一页。
// 调用太频繁时浏览器会抛出 SecurityError,而输入筛选词时每次按键都要替换地址。遇到 SecurityError 时只让地址暂时落后,页面照常显示。
// 写不进弹窗的标记记录时,弹窗照常显示,返回键关掉弹窗的同时也会后退一页。
function write(change: () => void): boolean {
try {
change()
@@ -102,18 +102,18 @@ function onPop(event: PopStateEvent): void {
return
}
if (current.modal && !modals.length) {
// 前进到了已经关闭的弹窗留下的标记记录:退回它下面的页面。
// 前进到了已经关闭的弹窗留下的标记记录,就退回标记记录下面的页面。
back()
return
}
// 先让页面跟上这条记录再关最上层的弹窗:关弹窗时可能接着导航,比如取消进入房间时后退。
// 先让页面跟上这条记录,再关最上层的弹窗,因为关弹窗时可能接着导航,比如取消进入房间时会后退。
notify()
modals.pop()?.close()
if (modals.length && !current.modal && !awaitingBack)
pushMarker()
}
// 返回当前记录是否第一次打开:刷新、前进和后退回到的记录都带着应用写入的状态。
// 返回当前记录是不是第一次打开。判断的依据是刷新、前进和后退回到的记录都带着应用写入的状态。
export function startHistory(onChange: () => void): boolean {
notify = onChange
const entry = readEntry(window.history.state)
@@ -134,7 +134,7 @@ export function currentEntry(): { position: Position, title: string | undefined,
return { position, title: current.title, from: current.from }
}
// 后退一个页面。弹窗的标记记录压在当前页面上(或者正在退掉它)时只退到页面的记录:落到那里时导航会收到通知,由它决定要不要接着后退。
// 后退一个页面。弹窗的标记记录压在当前页面上,或者正在退掉标记记录时,只退到页面的记录。落到页面的记录时导航会收到通知,由导航决定要不要接着后退。
export function goBack(): void {
if (awaitingBack)
return
@@ -150,7 +150,7 @@ export function writeEntry(url: string, replace: boolean, title?: string): void
return
}
if (replace && current.modal) {
// 弹窗的标记记录压在页面的记录上:先退回页面的记录,再替换它。
// 弹窗的标记记录压在页面的记录上,所以先退回页面的记录,再替换它。
queued.push({ url, replace, title })
back()
return
+2 -2
View File
@@ -1,4 +1,4 @@
// 改完即生效的控件(开关、分段选择)的保存状态。pending 是正在保存的新值,控件先显示它;保存失败时丢掉它,控件回到已保存的值。
// 改完即生效的控件(开关、分段选择)的保存状态。pending 是正在保存的新值,控件先显示这个值。保存失败时丢掉 pending,控件回到已保存的值。
export interface SettingSave<T> {
pending: { value: T } | null
status: 'idle' | 'saving' | 'saved' | 'failed'
@@ -17,7 +17,7 @@ export const idleSave: SettingSave<never> = { pending: null, status: 'idle', err
export function settingSave<T>(state: SettingSave<T>, event: SettingSaveEvent<T>): SettingSave<T> {
switch (event.type) {
case 'start':
// 一次只保存一个值:保存中再改,结果回来时分不清是哪一次的。
// 一次只保存一个值,因为保存中再改的话,结果回来时分不清是哪一次的。
return state.status === 'saving' ? state : { pending: { value: event.value }, status: 'saving', error: null }
case 'succeeded':
return state.status === 'saving' ? { pending: null, status: 'saved', error: null } : state
+1 -1
View File
@@ -2,7 +2,7 @@ export interface ExternalStore<T> extends EventTarget {
readonly state: T
}
// 状态整体替换:订阅方可以按引用比较字段,判断它是否变化。
// 状态整体替换,所以订阅方可以按引用比较字段,判断字段是否变化。
export class Store<T extends object> extends EventTarget implements ExternalStore<T> {
#state: T
+3 -3
View File
@@ -5,7 +5,7 @@ import { inlineSave } from './inline-save'
const savedNoticeMs = 1500
// 文本的保存流程:失焦或回车时 submit,Esc 时 revert。onSave 失败时抛出 Error,它的 message 是给用户看的原因。
// submit 返回提交之后输入框里的值是否已经保存:没有改动也算已经保存;正在保存或者保存失败时返回 false。
// submit 返回提交之后输入框里的值是否已经保存。没有改动也算已经保存,正在保存或者保存失败时返回 false。
export function useInlineSave(value: string, onSave: (value: string) => Promise<void>) {
const [state, setState] = useState<InlineSaveState>({ draft: value, saved: value, status: 'idle', error: null })
const [seenValue, setSeenValue] = useState(value)
@@ -41,7 +41,7 @@ export function useInlineSave(value: string, onSave: (value: string) => Promise<
await onSave(submitted)
setState((current) => {
const done = inlineSave(current, { type: 'succeeded', value: submitted })
// 保存期间外部传入了新值时以它为准:服务端可能改写了提交的值,比如去掉首尾空白。
// 保存期间外部传入了新值时,以新值为准,因为服务端可能改写了提交的值,比如去掉首尾空白。
return current.saved === savedBefore ? done : inlineSave(done, { type: 'external', value: current.saved })
})
setSavedNotice(count => count + 1)
@@ -58,7 +58,7 @@ export function useInlineSave(value: string, onSave: (value: string) => Promise<
return {
state,
// “已保存”在保存成功后显示 1.5 秒;又改了输入框就不再显示。
// “已保存”在保存成功后显示 1.5 秒。又改了输入框就不再显示。
savedNotice: state.status === 'saved' && savedNotice > 0 && state.draft === state.saved,
edit: (draft: string) => apply({ type: 'edit', value: draft }),
submit,
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff. Show more