fix: unify volume input handling

This commit is contained in:
DiheChen committed 2026-11-01 01:43:07 +08:00
1 parent bfae6ff9a1
commit fbe8c516f7
7 files changed
+151 -91

No files matched your search

+11 -64
View File
@@ -1,11 +1,11 @@
import { ListMusic, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { roomStore } from '@/core/room'
import { sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { formatDuration } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, IconButton } from '@/shared/ui'
import { Artwork, AsyncIconButton, IconButton, VolumeInput } from '@/shared/ui'
import { playerStore } from './player-store'
import { volumeStore } from './volume-store'
import './playback.css'
@@ -65,26 +65,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
const connected = useStore(roomStore, s => s.status === 'open')
const volume = useStore(volumeStore, s => s.volume)
const muted = useStore(volumeStore, s => s.muted)
const [volumeDraft, setVolumeDraft] = useState(String(volume))
const volumeEditingRef = useRef(false)
const meta = nowPlaying?.entry.meta
useEffect(() => {
if (!volumeEditingRef.current)
setVolumeDraft(String(volume))
}, [volume])
const commitVolumeDraft = () => {
const parsed = Number(volumeDraft)
if (volumeDraft.trim() === '' || !Number.isFinite(parsed)) {
setVolumeDraft(String(volume))
return
}
const next = Math.min(100, Math.max(0, Math.round(parsed)))
volumeStore.setVolume(next)
setVolumeDraft(String(next))
if (muted && next > 0)
volumeStore.setMuted(false)
}
const source = nowPlaying && sourceName(nowPlaying.entry.track)
return (
<section className="player-bar" aria-label="播放器">
@@ -114,49 +95,15 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
</div>
<div className="player-tools">
<IconButton icon={muted ? VolumeX : Volume2} label={muted ? '取消静音' : '静音'} onClick={() => volumeStore.setMuted(!muted)} />
<div className="volume-control">
<input
className="volume-slider"
type="range"
min="0"
max="100"
value={muted ? 0 : volume}
aria-label="音量"
onChange={(event) => {
const next = Number(event.target.value)
volumeStore.setVolume(next)
setVolumeDraft(String(next))
if (muted && next > 0)
volumeStore.setMuted(false)
}}
/>
<input
className="volume-input"
type="number"
min="0"
max="100"
step="1"
inputMode="numeric"
value={volumeDraft}
aria-label="音量百分比"
onFocus={() => {
volumeEditingRef.current = true
}}
onChange={event => setVolumeDraft(event.target.value)}
onBlur={() => {
volumeEditingRef.current = false
commitVolumeDraft()
}}
onKeyDown={(event) => {
if (event.key !== 'Enter')
return
event.preventDefault()
commitVolumeDraft()
event.currentTarget.blur()
}}
/>
<span className="volume-unit" aria-hidden="true">%</span>
</div>
<VolumeInput
volume={volume}
sliderValue={muted ? 0 : volume}
onCommit={(next) => {
volumeStore.setVolume(next)
if (muted && next > 0)
volumeStore.setMuted(false)
}}
/>
<IconButton icon={MessageCircle} label="房间聊天" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={chatOpen} onClick={onChat} />
<IconButton icon={ListMusic} label="播放列表" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={queueOpen} onClick={onQueue} />
</div>
+2 -22
View File
@@ -6,7 +6,7 @@ import { errorMessage } from '@/core/errors'
import { historyStore, searchLimits } from '@/features/history'
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore } from '@/shared/ui'
import { notificationStore, VolumeInput } from '@/shared/ui'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { profileStore } from './profile-store'
@@ -208,27 +208,7 @@ export function SettingsPage() {
<h2 id="settings-playback-title" className="text-ui-lg">播放</h2>
<div className="settings-group">
<SettingRow id="settings-volume" title="音量" description="只调整这台设备的播放音量。" field>
<div className="settings-volume-control">
<input id="settings-volume" aria-describedby="settings-volume-description" type="range" min="0" max="100" value={draft.volume} aria-label="音量滑块" onChange={event => update('volume', Number(event.target.value))} />
<div className="settings-volume-input-control">
<input
className="settings-volume-input"
type="number"
min="0"
max="100"
step="1"
inputMode="numeric"
value={draft.volume}
aria-label="音量百分比"
onChange={(event) => {
const value = Number(event.target.value)
if (Number.isFinite(value))
update('volume', Math.min(100, Math.max(0, Math.round(value))))
}}
/>
<span className="settings-volume-unit" aria-hidden="true">%</span>
</div>
</div>
<VolumeInput id="settings-volume" className="settings-volume-control" volume={draft.volume} onCommit={next => update('volume', next)} />
</SettingRow>
<SettingRow id="settings-muted" title="静音" description="暂时关闭这台设备的声音。">
<input id="settings-muted" aria-describedby="settings-muted-description" className="settings-switch" type="checkbox" role="switch" checked={draft.muted} onChange={event => update('muted', event.target.checked)} />
+5 -5
View File
@@ -18,11 +18,11 @@
.settings-row-control select:disabled, .settings-row-control input:disabled { opacity: .5; cursor: not-allowed; }
.settings-row-field .settings-row-control { width: 280px; }
.settings-volume-control { display: flex; align-items: center; gap: 16px; width: 100%; }
.settings-volume-input-control { display: flex; align-items: center; gap: 4px; }
.settings-volume-input { -moz-appearance: textfield; }
.settings-volume-input::-webkit-inner-spin-button, .settings-volume-input::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; }
.settings-volume-input { box-sizing: border-box; width: 5ch; padding: 6px 4px; text-align: center; font-variant-numeric: tabular-nums; }
.settings-volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); }
.settings-volume-control .volume-input-control { display: flex; align-items: center; gap: 4px; }
.settings-volume-control .volume-input { -moz-appearance: textfield; }
.settings-volume-control .volume-input::-webkit-inner-spin-button, .settings-volume-control .volume-input::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; }
.settings-volume-control .volume-input { box-sizing: border-box; width: 5ch; padding: 6px 4px; text-align: center; font-variant-numeric: tabular-nums; }
.settings-volume-control .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); }
.settings-switch[type='checkbox'] { appearance: none; position: relative; width: 40px; height: 24px; margin: 0; border: 1px solid var(--color-border-hover); border-radius: 999px; background: var(--color-selected); cursor: pointer; }
.settings-switch[type='checkbox']::before { content: ''; display: block; width: 18px; height: 18px; margin: 2px; border-radius: 50%; background: var(--color-foreground); transform: translateX(0); transition: transform 150ms ease; }
.settings-switch[type='checkbox']:checked { border-color: var(--color-primary); background: var(--color-primary); }
+1
View File
@@ -12,3 +12,4 @@ export { Skeleton } from './skeleton'
export { SongArtwork } from './song-artwork'
export { SongList } from './song-list'
export type { SongAction, SongItem } from './song-list'
export { VolumeInput } from './volume-input'
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { beginVolumeEdit, changeVolumeDraft, commitVolumeDraft, initialVolumeEditor, syncVolumeDraft } from './volume-editor'
describe('音量草稿', () => {
it('非编辑时同步外部音量', () => {
const editor = initialVolumeEditor(75)
expect(syncVolumeDraft(editor, 40)).toEqual({ draft: '40', editing: false })
})
it('编辑期间不被外部音量覆盖', () => {
const editor = changeVolumeDraft(beginVolumeEdit(initialVolumeEditor(75)), '20')
expect(syncVolumeDraft(editor, 90)).toEqual({ draft: '20', editing: true })
})
it('提交空值或非法输入时恢复有效音量', () => {
for (const draft of ['', ' ', 'abc']) {
const editor = changeVolumeDraft(beginVolumeEdit(initialVolumeEditor(75)), draft)
expect(commitVolumeDraft(editor, 75)).toEqual({ editor: { draft: '75', editing: false }, value: null })
}
})
it('提交时取整并限制范围', () => {
for (const testCase of [
{ draft: '-1', want: 0 },
{ draft: '0', want: 0 },
{ draft: '12.6', want: 13 },
{ draft: '100', want: 100 },
{ draft: '101', want: 100 },
]) {
const editor = changeVolumeDraft(beginVolumeEdit(initialVolumeEditor(75)), testCase.draft)
expect(commitVolumeDraft(editor, 75).value).toBe(testCase.want)
}
})
it('提交后退出编辑状态并显示提交值', () => {
const editor = changeVolumeDraft(beginVolumeEdit(initialVolumeEditor(75)), '42')
expect(commitVolumeDraft(editor, 75)).toEqual({ editor: { draft: '42', editing: false }, value: 42 })
})
})
+29
View File
@@ -0,0 +1,29 @@
export interface VolumeEditor {
draft: string
editing: boolean
}
export function initialVolumeEditor(volume: number): VolumeEditor {
return { draft: String(volume), editing: false }
}
export function beginVolumeEdit(editor: VolumeEditor): VolumeEditor {
return { ...editor, editing: true }
}
export function changeVolumeDraft(_editor: VolumeEditor, draft: string): VolumeEditor {
return { draft, editing: true }
}
export function syncVolumeDraft(editor: VolumeEditor, volume: number): VolumeEditor {
return editor.editing ? editor : { draft: String(volume), editing: false }
}
export function commitVolumeDraft(editor: VolumeEditor, fallbackVolume: number): { editor: VolumeEditor, value: number | null } {
const parsed = Number(editor.draft)
if (editor.draft.trim() === '' || !Number.isFinite(parsed)) {
return { editor: { draft: String(fallbackVolume), editing: false }, value: null }
}
const value = Math.min(100, Math.max(0, Math.round(parsed)))
return { editor: { draft: String(value), editing: false }, value }
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { beginVolumeEdit, changeVolumeDraft, commitVolumeDraft, initialVolumeEditor, syncVolumeDraft } from './volume-editor'
interface VolumeInputProps {
volume: number
onCommit: (volume: number) => void
sliderValue?: number
id?: string
className?: string
disabled?: boolean
}
export function VolumeInput({ volume, onCommit, sliderValue, id, className = '', disabled = false }: VolumeInputProps) {
const [editor, setEditor] = useState(() => initialVolumeEditor(volume))
const syncedEditor = syncVolumeDraft(editor, volume)
const commit = () => {
const result = commitVolumeDraft(syncedEditor, volume)
setEditor(result.editor)
if (result.value !== null)
onCommit(result.value)
}
return (
<div className={`volume-control ${className}`}>
<input
id={id}
className="volume-slider"
type="range"
min="0"
max="100"
value={sliderValue ?? volume}
aria-label="音量"
disabled={disabled}
onChange={(event) => {
const next = Number(event.target.value)
setEditor(current => changeVolumeDraft(current, String(next)))
onCommit(next)
}}
/>
<div className="volume-input-control">
<input
className="volume-input"
type="number"
min="0"
max="100"
step="1"
inputMode="numeric"
value={syncedEditor.draft}
aria-label="音量百分比"
disabled={disabled}
onFocus={() => setEditor(current => beginVolumeEdit(current))}
onChange={event => setEditor(current => changeVolumeDraft(current, event.target.value))}
onBlur={commit}
onKeyDown={(event) => {
if (event.key !== 'Enter')
return
event.preventDefault()
event.currentTarget.blur()
}}
/>
<span className="volume-unit" aria-hidden="true">%</span>
</div>
</div>
)
}