fix: unify volume input handling
This commit is contained in:
1 parent
bfae6ff9a1
commit
fbe8c516f7
7 files changed
+151
-91
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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'
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user