diff --git a/web/src/features/playback/player-bar.tsx b/web/src/features/playback/player-bar.tsx index e054d20..c3d3862 100644 --- a/web/src/features/playback/player-bar.tsx +++ b/web/src/features/playback/player-bar.tsx @@ -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 (
@@ -114,49 +95,15 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
volumeStore.setMuted(!muted)} /> -
- { - const next = Number(event.target.value) - volumeStore.setVolume(next) - setVolumeDraft(String(next)) - if (muted && next > 0) - volumeStore.setMuted(false) - }} - /> - { - 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() - }} - /> - -
+ { + volumeStore.setVolume(next) + if (muted && next > 0) + volumeStore.setMuted(false) + }} + />
diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx index 91c4f53..5b5ee63 100644 --- a/web/src/features/settings/settings-page.tsx +++ b/web/src/features/settings/settings-page.tsx @@ -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() {

播放

-
- update('volume', Number(event.target.value))} /> -
- { - const value = Number(event.target.value) - if (Number.isFinite(value)) - update('volume', Math.min(100, Math.max(0, Math.round(value)))) - }} - /> - -
-
+ update('volume', next)} />
update('muted', event.target.checked)} /> diff --git a/web/src/features/settings/settings.css b/web/src/features/settings/settings.css index 79c65da..8ec73bc 100644 --- a/web/src/features/settings/settings.css +++ b/web/src/features/settings/settings.css @@ -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); } diff --git a/web/src/shared/ui/index.ts b/web/src/shared/ui/index.ts index d9bf14e..f0bee99 100644 --- a/web/src/shared/ui/index.ts +++ b/web/src/shared/ui/index.ts @@ -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' diff --git a/web/src/shared/ui/volume-editor.test.ts b/web/src/shared/ui/volume-editor.test.ts new file mode 100644 index 0000000..682ef97 --- /dev/null +++ b/web/src/shared/ui/volume-editor.test.ts @@ -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 }) + }) +}) diff --git a/web/src/shared/ui/volume-editor.ts b/web/src/shared/ui/volume-editor.ts new file mode 100644 index 0000000..91614eb --- /dev/null +++ b/web/src/shared/ui/volume-editor.ts @@ -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 } +} diff --git a/web/src/shared/ui/volume-input.tsx b/web/src/shared/ui/volume-input.tsx new file mode 100644 index 0000000..760b24f --- /dev/null +++ b/web/src/shared/ui/volume-input.tsx @@ -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 ( +
+ { + const next = Number(event.target.value) + setEditor(current => changeVolumeDraft(current, String(next))) + onCommit(next) + }} + /> +
+ 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() + }} + /> + +
+
+ ) +}