257 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ArrowDownUp,
Download,
Maximize,
Maximize2,
Minimize,
Minimize2,
Pause,
Play,
Repeat1,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
} from "lucide-react";
import type { RefObject } from "react";
import type { LoopMode, ObjectFit, User } from "../types";
import { formatRelativeTime, formatTime } from "../utils";
import { ProgressBar } from "./ProgressBar";
import { SegmentedProgressBar } from "./SegmentedProgressBar";
interface MediaControlsProps {
isVideo: boolean;
isPlaying: boolean;
progress: number;
volume: number;
rate: number;
rotation: number;
objectFit: ObjectFit;
loopMode: LoopMode;
isFullscreen: boolean;
author: User;
createdAt: string | Date;
desc: string;
// 视频专用
videoRef?: RefObject<HTMLVideoElement | null>;
// 图文专用
currentIndex?: number;
totalSegments?: number;
segmentProgress?: number;
musicUrl?: string | null;
audioRef?: RefObject<HTMLAudioElement | null>;
// 回调
onTogglePlay: () => void;
onSeek: (ratio: number) => void;
onVolumeChange: (volume: number) => void;
onRateChange: (rate: number) => void;
onRotationChange: (rotation: number) => void;
onObjectFitChange: (fit: ObjectFit) => void;
onLoopModeChange: (mode: LoopMode) => void;
onDownload: () => void;
onToggleFullscreen: () => void;
}
export function MediaControls({
isVideo,
isPlaying,
progress,
volume,
rate,
rotation,
objectFit,
loopMode,
isFullscreen,
author,
createdAt,
desc,
videoRef,
currentIndex = 0,
totalSegments = 0,
segmentProgress = 0,
musicUrl,
audioRef,
onTogglePlay,
onSeek,
onVolumeChange,
onRateChange,
onRotationChange,
onObjectFitChange,
onLoopModeChange,
onDownload,
onToggleFullscreen,
}: MediaControlsProps) {
return (
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
{/* 描述行 */}
<div className="pointer-events-none flex items-center gap-2.5 mb-1">
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
<span
className="text-[11px] leading-tight text-white/95 drop-shadow"
title={new Date(createdAt).toLocaleString()}
>
{formatRelativeTime(createdAt)}
</span>
</div>
{desc ? (
<div className="pointer-events-none">
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
{desc}
</p>
</div>
) : null}
{/* 进度条:图文=分段;视频=单段 */}
{!isVideo && totalSegments > 0 ? (
<SegmentedProgressBar
totalSegments={totalSegments}
currentIndex={currentIndex}
segmentProgress={segmentProgress}
onSeek={onSeek}
/>
) : (
<ProgressBar progress={progress} onSeek={onSeek} />
)}
{/* 控制按钮行 - 响应式布局 */}
<div className="flex items-center justify-between gap-1.5 sm:gap-2.5">
{/* 左侧:播放控制 + 时间/进度 */}
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
<button
className="w-[34px] h-[34px] shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={onTogglePlay}
aria-label={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
{/* 播放进度显示 - 所有设备都显示 */}
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
{isVideo ? (
(() => {
const v = videoRef?.current;
const current = v?.currentTime ?? 0;
const total = v?.duration ?? 0;
return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--";
})()
) : (
`${currentIndex + 1} / ${totalSegments}`
)}
</div>
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
{isVideo && (
<button
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer shrink-0"
onClick={() => {
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
const i = steps.indexOf(rate);
const next = steps[(i + 1) % steps.length];
onRateChange(next);
}}
aria-label="切换倍速"
>
{rate}x
</button>
)}
</div>
{/* 中间:音量控制 - 中等屏幕以上显示 */}
<div className="hidden md:inline-flex items-center gap-2 shrink-0">
{/* 旋转按钮 - 小屏幕以上显示 */}
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onRotationChange((rotation + 270) % 360)}
aria-label="向左旋转 90 度"
title="向左旋转 90 度"
>
<RotateCcw size={18} />
</button>
<button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? "静音" : "取消静音"}
>
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
className="w-20 lg:w-28 accent-white cursor-pointer"
aria-label="音量"
/>
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onRotationChange((rotation + 90) % 360)}
aria-label="向右旋转 90 度"
title="向右旋转 90 度"
>
<RotateCw size={18} />
</button>
</div>
{/* 右侧:功能按钮组 */}
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
<button
className="md:hidden w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? "静音" : "取消静音"}
>
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button>
{/* 循环模式 - 中等屏幕以上显示 */}
<button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
>
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
</button>
{/* 适配模式 - 小屏幕以上显示 */}
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
>
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
</button>
{/* 下载 - 中等屏幕以上显示 */}
<button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={onDownload}
aria-label={isVideo ? "下载视频" : "下载当前图片"}
title={isVideo ? "下载视频" : "下载当前图片"}
>
<Download size={18} />
</button>
{/* 全屏 - 所有设备都显示 */}
<button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={onToggleFullscreen}
aria-label="切换全屏"
>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
{/* 图文 BGM隐藏控件仅用于播放 */}
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null}
</div>
);
}