diff --git a/web/src/features/playback/playback.css b/web/src/features/playback/playback.css index 57c70b9..efb7a24 100644 --- a/web/src/features/playback/playback.css +++ b/web/src/features/playback/playback.css @@ -13,13 +13,18 @@ .progress-track { height: 3px; flex: 1; background: var(--color-border-hover); border-radius: 4px; overflow: hidden; } .progress-fill { height: 100%; background: var(--color-foreground); transform: scaleX(0); transform-origin: left; } .player-tools { display: flex; align-items: center; justify-content: flex-end; gap: 4px; } -.player-tools .volume-slider { width: 90px; margin-right: 16px; accent-color: var(--color-foreground); } +.player-tools .volume-control { display: flex; align-items: center; gap: 6px; margin-right: 16px; } +.player-tools .volume-slider { width: 90px; accent-color: var(--color-foreground); } +.player-tools .volume-input { box-sizing: border-box; width: 4.5ch; min-width: 0; padding: 4px 3px; text-align: center; font-variant-numeric: tabular-nums; -moz-appearance: textfield; } +.player-tools .volume-input::-webkit-inner-spin-button, .player-tools .volume-input::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; } +.player-tools .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); } .player-panel-toggle { color: var(--color-foreground-subtle); } .player-panel-toggle:hover:not(:disabled), .player-panel-toggle:focus-visible:not(:disabled), .player-panel-toggle[aria-pressed='true'] { color: var(--color-brand); background: transparent; } .player-panel-toggle:active:not(:disabled) { color: var(--color-brand); background: transparent; transform: none; } @media (max-width: 1100px) { .player-bar { gap: 12px; } - .player-tools .volume-slider { width: 60px; margin-right: 4px; } + .player-tools .volume-control { margin-right: 4px; } + .player-tools .volume-slider { width: 60px; } } @media (max-width: 767px) { /* 窄屏的播放器栏由内容撑开高度;内容比封面矮时(比如出错提示),仍保持封面加上下内边距的高度。 */ diff --git a/web/src/features/playback/player-bar.tsx b/web/src/features/playback/player-bar.tsx index 9e74e8f..e054d20 100644 --- a/web/src/features/playback/player-bar.tsx +++ b/web/src/features/playback/player-bar.tsx @@ -1,5 +1,5 @@ import { ListMusic, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { roomStore } from '@/core/room' import { sourceName } from '@/features/queue' import { bookTitle } from '@/shared/lib/book-title' @@ -65,7 +65,26 @@ 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 (
@@ -95,20 +114,49 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
volumeStore.setMuted(!muted)} /> - { - const next = Number(event.target.value) - volumeStore.setVolume(next) - if (muted && next > 0) - volumeStore.setMuted(false) - }} - /> +
+ { + 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() + }} + /> + +
diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx index 0125eb2..91c4f53 100644 --- a/web/src/features/settings/settings-page.tsx +++ b/web/src/features/settings/settings-page.tsx @@ -209,8 +209,25 @@ export function SettingsPage() {
- update('volume', Number(event.target.value))} /> - {`${draft.volume}%`} + 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)))) + }} + /> + +
diff --git a/web/src/features/settings/settings.css b/web/src/features/settings/settings.css index 453b5ed..79c65da 100644 --- a/web/src/features/settings/settings.css +++ b/web/src/features/settings/settings.css @@ -18,7 +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-control output { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; } +.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-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); }