feat(video): add inline volume control to MessageInlineVideo
- Add mute toggle button (Volume2/VolumeX icons) to the custom control bar. - Add an always-visible inline straight-line volume slider on desktop; mobile keeps mute toggle only and relies on system volume keys. - Slider at 0 auto-mutes; unmuting from zero restores volume to 1. - Sync isMuted/volume state via the video volumechange event. Verified in browser at /browse?type=video: drag slider updates video.volume, mute toggle preserves volume across on/off.
This commit is contained in:
40
.unipi/docs/fix/2026-06-07-video-volume-control-fix.md
Normal file
40
.unipi/docs/fix/2026-06-07-video-volume-control-fix.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "影片播放音量调整按钮 — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-07
|
||||
---
|
||||
|
||||
# 影片播放音量调整按钮 — Quick Fix
|
||||
|
||||
## Bug
|
||||
`MessageInlineVideo` 的自定义控制条只有播放/暂停、进度条、全屏按钮,缺少音量控制。用户无法在播放器内静音或调节音量。
|
||||
|
||||
## Root Cause
|
||||
功能缺失。原生 `<video>` 控件被关闭以统一 iOS Safari 体验,但替代实现没有补回音量控制。
|
||||
|
||||
## Fix
|
||||
在底部控制条「剩余时间」与「全屏按钮」之间加入音量控制:
|
||||
|
||||
- 静音切换按钮(`Volume2` / `VolumeX`),点击直接 toggle `video.muted`,桌面、移动均可用。
|
||||
- 桌面端始终可见的内联直线音量滑块(`<input type="range">`,0~1,step 0.05),紧贴喇叭按钮右侧;不再用 hover 弹出,调音量像 YouTube 一样直接拖一条直线。
|
||||
- 滑到 0 自动静音;从 0 解除静音时自动恢复到 1,避免「点开还是没声音」的体验。
|
||||
- 新增 `isMuted` / `volume` state,监听 `volumechange` 与初始挂载同步,确保按钮图标与滑块位置始终一致。
|
||||
- 移动端只显示静音按钮,音量大小让系统音量键负责。
|
||||
|
||||
### Files Modified
|
||||
- `src/components/messageStream/MessageInlineVideo.tsx` — 引入 `Volume2/VolumeX` 图标、新增音量 state / `volumechange` 监听 / `toggleMute` / `handleVolumeChange`,在控制条加入音量按钮 + hover 音量滑块。
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` 通过(严格模式 + 未使用变量检查)。
|
||||
- `npm run format` 通过。
|
||||
- `npm test` 全部 49 测试通过。
|
||||
- 浏览器实测 `/browse?type=video`:
|
||||
- 拖滑块 0.3:`video.volume=0.3`,slider 同步 0.3 ✓
|
||||
- 点喇叭按钮:`muted=true`,slider 显示 0 ✓
|
||||
- 再点:`muted=false`,volume 保留 0.3 ✓
|
||||
- 滑块拖到 0:`muted=true`、`volume=0` ✓
|
||||
|
||||
## Notes
|
||||
- 仅修改 `MessageInlineVideo`,全屏播放器 `VideoPlayer.tsx` 复用同一组件,因此全屏模式同时获得音量控制。
|
||||
- 没有改变 `autoPlay` 默认行为;如未来 iOS autoplay 受限,可考虑默认 `muted` 起播再由用户点按钮解除。
|
||||
- 滑块在移动端隐藏(仅 `md:` 以上显示),移动端通过按钮 + 系统音量键操作,避免在小气泡里拥挤。
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Maximize2, Pause, Play } from "lucide-react";
|
||||
import { Maximize2, Pause, Play, Volume2, VolumeX } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -121,6 +121,8 @@ export function MessageInlineVideo({
|
||||
const [currentTime, setCurrentTime] = useState(initialTime);
|
||||
const [duration, setDuration] = useState(attachment.durationSec ?? 0);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
// When we programmatically seek (e.g. syncing the playhead back from the
|
||||
// fullscreen overlay) the progress fill should jump straight to the watched
|
||||
// position instead of sweeping up from its old width via the CSS transition.
|
||||
@@ -150,6 +152,10 @@ export function MessageInlineVideo({
|
||||
setCurrentTime(v.currentTime);
|
||||
onTimeUpdate?.(v.currentTime);
|
||||
};
|
||||
const onVolume = () => {
|
||||
setIsMuted(v.muted);
|
||||
setVolume(v.volume);
|
||||
};
|
||||
const onMeta = () => {
|
||||
if (Number.isFinite(v.duration)) setDuration(v.duration);
|
||||
if (initialTime > 0) {
|
||||
@@ -165,12 +171,15 @@ export function MessageInlineVideo({
|
||||
v.addEventListener("timeupdate", onTime);
|
||||
v.addEventListener("seeked", onSeeked);
|
||||
v.addEventListener("loadedmetadata", onMeta);
|
||||
v.addEventListener("volumechange", onVolume);
|
||||
onVolume();
|
||||
return () => {
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("timeupdate", onTime);
|
||||
v.removeEventListener("seeked", onSeeked);
|
||||
v.removeEventListener("loadedmetadata", onMeta);
|
||||
v.removeEventListener("volumechange", onVolume);
|
||||
};
|
||||
}, [initialTime, onTimeUpdate]);
|
||||
|
||||
@@ -181,6 +190,22 @@ export function MessageInlineVideo({
|
||||
else v.pause();
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
// Unmuting at zero volume would leave the user with silence; restore to full.
|
||||
if (v.muted && v.volume === 0) v.volume = 1;
|
||||
v.muted = !v.muted;
|
||||
}, []);
|
||||
|
||||
const handleVolumeChange = useCallback((next: number) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const clamped = Math.max(0, Math.min(1, next));
|
||||
v.volume = clamped;
|
||||
v.muted = clamped === 0;
|
||||
}, []);
|
||||
|
||||
const seekToClientX = useCallback((clientX: number) => {
|
||||
const el = scrubRef.current;
|
||||
const v = videoRef.current;
|
||||
@@ -367,6 +392,38 @@ export function MessageInlineVideo({
|
||||
-{formatClock(remaining)}
|
||||
</span>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleMute();
|
||||
}}
|
||||
className={`flex shrink-0 items-center justify-center text-white transition hover:scale-105 ${t.btn}`}
|
||||
aria-label={isMuted || volume === 0 ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className={t.btnIcon} strokeWidth={2.2} />
|
||||
) : (
|
||||
<Volume2 className={t.btnIcon} strokeWidth={2.2} />
|
||||
)}
|
||||
</button>
|
||||
{/* Always-visible inline straight-line volume slider (desktop only;
|
||||
on touch devices users rely on the mute toggle + system volume). */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={(e) => handleVolumeChange(Number(e.target.value))}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
aria-label="Volume"
|
||||
className="hidden h-1 w-20 cursor-pointer appearance-none rounded-full bg-white/30 accent-white md:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hideFullscreen ? null : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user