docs: reword comments and protocol descriptions for readability
This commit is contained in:
1 parent
d72b4203cb
commit
b986abf937
324 files changed
+1134
-1125
No files matched your search
@@ -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
@@ -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('加入房间的窗口出错了,没有加入房间,再选一次房间。')
|
||||
|
||||
@@ -23,7 +23,7 @@ export function watchConnection(): void {
|
||||
notificationStore.error('与房间的连接中断了,正在自动重连。', 5000)
|
||||
}
|
||||
})
|
||||
// 握手时票据被拒绝会以 4401 断开,进房流程随后重新打开加入弹窗,不另外弹通知。
|
||||
// 握手时票据被拒绝,连接会以 4401 断开,随后进房流程会重新打开加入弹窗,所以不另外弹通知。
|
||||
roomStore.errors.on((error) => {
|
||||
if (error.code === 'UNAUTHORIZED')
|
||||
return
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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 '出错了,请稍后再试。'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 === '')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 登录"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
// 删空了这一页时显示的是上一页,那里没有“下一行”。
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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,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">
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 才能位移和缩放,只有放大的字需要滤镜。 */
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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="播放器">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ async function copyShareLink(shareId: string, copied: string): Promise<void> {
|
||||
notificationStore.success(copied)
|
||||
}
|
||||
catch {
|
||||
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权;有的浏览器在请求返回之后已不算用户操作,也会拒绝写入。
|
||||
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权。有的浏览器在请求返回之后已经不算用户操作,也会拒绝写入。
|
||||
notificationStore.info(`请手动复制分享链接:${url}`, 8000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()!()
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序;
|
||||
// 一次失败之后,排在它后面的不再发出,由失败的那次回到它之前的样子。
|
||||
// 修改进行中读到的列表可能是修改之前的,不能用;最后一个修改结束之后再读一次,以服务端为准。
|
||||
// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序。
|
||||
// 一次失败之后,排在后面的修改都不再发出,由失败的那次修改负责回滚,列表回到这次修改之前的样子。
|
||||
// 修改进行中读到的列表可能是修改之前的,不能用。最后一个修改结束之后再读一次,以服务端为准。
|
||||
export class TrackEdits {
|
||||
readonly #reread: () => void
|
||||
#tail: Promise<void> = Promise.resolve()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,7 @@ export function EntryDialog({ onCancel }: { onCancel: () => void }) {
|
||||
room={room}
|
||||
onJoined={() => roomEntryStore.retry()}
|
||||
onClose={() => {
|
||||
// onCancel 要在放弃进入之前:离开这个房间的地址时,要靠进房的信息认出它。
|
||||
// onCancel 要在放弃进入之前调用,因为离开时要靠进房的信息认出这个房间的地址。
|
||||
onCancel()
|
||||
roomEntryStore.reset()
|
||||
}}
|
||||
|
||||
@@ -88,7 +88,7 @@ class RoomEntryStore extends Store<EntryState> {
|
||||
return
|
||||
switch (session.stopCode) {
|
||||
case CloseCode.unauthorized:
|
||||
// 票据无效,或房间启用、更改了密码:重新查询房间,再让用户输入密码。
|
||||
// 票据无效,或者房间启用、更改了密码。这时重新查询房间,再让用户输入密码。
|
||||
deleteTicket(room.id)
|
||||
this.retry()
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
// 歌名里已经有书名号时不再加一层:B 站的标题常带《》。
|
||||
// 歌名里已经有书名号时不再加一层,因为 B 站的标题常带《》。
|
||||
export function bookTitle(title: string): string {
|
||||
return /[《》]/.test(title) ? title : `《${title}》`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
// 单选组里方向键要去的那一项,和原生的单选按钮一样:←/↑ 和 →/↓ 等价,到头之后从另一头接着走。不是这几个键时返回 null。
|
||||
// 返回单选组里方向键要去的那一项,规则和原生的单选按钮一样。← 和 ↑ 等价,→ 和 ↓ 等价,到头之后从另一头接着走。不是这几个键时返回 null。
|
||||
export function rovingIndex(key: string, index: number, count: number): number | null {
|
||||
if (!count)
|
||||
return null
|
||||
|
||||
@@ -33,7 +33,7 @@ interface HistoryEntry {
|
||||
url: string
|
||||
}
|
||||
|
||||
// 和浏览器一样,后退要到之后的任务才完成:settle() 模拟那一刻的 popstate。
|
||||
// 和浏览器一样,back() 要到之后的任务才完成后退。settle() 模拟后退完成时派发的 popstate。
|
||||
class FakeHistory {
|
||||
entries: HistoryEntry[]
|
||||
at = 0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user