425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
import {
|
|
ArrowDownUp,
|
|
Download,
|
|
FileText,
|
|
Maximize,
|
|
Maximize2,
|
|
Minimize,
|
|
Minimize2,
|
|
Pause,
|
|
Play,
|
|
Repeat,
|
|
Repeat1,
|
|
RotateCcw,
|
|
RotateCw,
|
|
Volume2,
|
|
VolumeX,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { memo, type ReactNode, type RefObject } from "react";
|
|
import type { LoopMode, ObjectFit, User } from "../types.ts";
|
|
import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
|
|
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
|
|
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>;
|
|
// 转录相关
|
|
hasTranscript?: boolean;
|
|
onShowTranscript?: () => void;
|
|
// 回调
|
|
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;
|
|
}
|
|
|
|
const CONTROL_BTN_CLASS =
|
|
"w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 cursor-pointer hover:bg-white/25 transition-colors";
|
|
|
|
export const MediaControls = memo(function MediaControls({
|
|
isVideo,
|
|
isPlaying,
|
|
progress,
|
|
volume,
|
|
rate,
|
|
rotation,
|
|
objectFit,
|
|
loopMode,
|
|
isFullscreen,
|
|
author,
|
|
createdAt,
|
|
desc,
|
|
videoRef,
|
|
currentIndex = 0,
|
|
totalSegments = 0,
|
|
segmentProgress = 0,
|
|
musicUrl,
|
|
audioRef,
|
|
hasTranscript = false,
|
|
onShowTranscript,
|
|
onTogglePlay,
|
|
onSeek,
|
|
onVolumeChange,
|
|
onRateChange,
|
|
onRotationChange,
|
|
onObjectFitChange,
|
|
onLoopModeChange,
|
|
onDownload,
|
|
onToggleFullscreen,
|
|
}: MediaControlsProps) {
|
|
const normalizedLoopMode =
|
|
isVideo && loopMode === "single" ? "loop" : loopMode;
|
|
const loopLabel = isVideo
|
|
? normalizedLoopMode === "loop"
|
|
? "循环播放"
|
|
: "顺序播放"
|
|
: loopMode === "single"
|
|
? "单页循环"
|
|
: loopMode === "loop"
|
|
? "图文循环"
|
|
: "顺序播放";
|
|
const handleLoopModeToggle = (): void => {
|
|
if (isVideo) {
|
|
onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop");
|
|
return;
|
|
}
|
|
|
|
onLoopModeChange(
|
|
loopMode === "loop"
|
|
? "single"
|
|
: loopMode === "single"
|
|
? "sequential"
|
|
: "loop",
|
|
);
|
|
};
|
|
const renderLoopIcon = (): ReactNode => {
|
|
if (!isVideo && loopMode === "single") return <Repeat1 size={18} />;
|
|
if (normalizedLoopMode === "loop") return <Repeat size={18} />;
|
|
return <ArrowDownUp size={18} />;
|
|
};
|
|
|
|
return (
|
|
<div className="absolute left-0 right-0 bottom-0 z-40 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="flex items-center gap-2.5 mb-1 pointer-events-none">
|
|
{author.sec_uid ? (
|
|
<Link
|
|
href={`/author/${author.sec_uid}`}
|
|
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
|
|
>
|
|
<img
|
|
src={author.avatar_url ?? ""}
|
|
alt=""
|
|
className="w-8 h-8 rounded-full"
|
|
/>
|
|
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
|
|
{author.nickname}
|
|
</span>
|
|
</Link>
|
|
) : (
|
|
<div className="flex items-center gap-2.5">
|
|
<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>
|
|
</div>
|
|
)}
|
|
<span className="text-[13px] leading-tight text-white/95 drop-shadow">
|
|
·
|
|
</span>
|
|
<span
|
|
className="text-[11px] leading-tight text-white/95 drop-shadow"
|
|
title={formatAbsoluteUTC(createdAt)}
|
|
>
|
|
{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
|
|
type="button"
|
|
className={`${CONTROL_BTN_CLASS} shrink-0`}
|
|
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
|
|
type="button"
|
|
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 cursor-pointer shrink-0 hover:bg-white/25 transition-colors"
|
|
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
|
|
type="button"
|
|
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
|
onClick={() => onRotationChange((rotation + 270) % 360)}
|
|
aria-label="向左旋转 90 度"
|
|
title="向左旋转 90 度"
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={CONTROL_BTN_CLASS}
|
|
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
|
|
type="button"
|
|
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
|
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
|
|
type="button"
|
|
className={`md:hidden ${CONTROL_BTN_CLASS}`}
|
|
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
|
|
aria-label={volume > 0 ? "静音" : "取消静音"}
|
|
>
|
|
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
|
|
</button>
|
|
|
|
{/* 循环模式 - 中等屏幕以上显示 */}
|
|
<button
|
|
type="button"
|
|
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
|
onClick={handleLoopModeToggle}
|
|
aria-label={loopLabel}
|
|
title={loopLabel}
|
|
>
|
|
{renderLoopIcon()}
|
|
</button>
|
|
|
|
{/* 适配模式 - 小屏幕以上显示 */}
|
|
<button
|
|
type="button"
|
|
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
|
|
onClick={() =>
|
|
onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
|
|
}
|
|
aria-label={
|
|
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
|
|
}
|
|
title={
|
|
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
|
|
}
|
|
>
|
|
{objectFit === "contain" ? (
|
|
<Maximize2 size={18} />
|
|
) : (
|
|
<Minimize size={18} />
|
|
)}
|
|
</button>
|
|
|
|
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
|
|
{isVideo && hasTranscript && onShowTranscript && (
|
|
<button
|
|
type="button"
|
|
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
|
onClick={onShowTranscript}
|
|
aria-label="显示转录文本"
|
|
title="显示转录文本"
|
|
>
|
|
<FileText size={18} />
|
|
</button>
|
|
)}
|
|
|
|
{/* 下载 - 中等屏幕以上显示 */}
|
|
<button
|
|
type="button"
|
|
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
|
|
onClick={onDownload}
|
|
aria-label={isVideo ? "下载视频" : "下载当前图片"}
|
|
title={isVideo ? "下载视频" : "下载当前图片"}
|
|
>
|
|
<Download size={18} />
|
|
</button>
|
|
|
|
{/* 更多菜单 - 只在有按钮被折叠时显示 */}
|
|
<div className="sm:block md:hidden">
|
|
<MoreMenu>
|
|
<div className="sm:hidden">
|
|
<MoreMenuItem
|
|
icon={
|
|
objectFit === "contain" ? (
|
|
<Maximize2 size={18} />
|
|
) : (
|
|
<Minimize size={18} />
|
|
)
|
|
}
|
|
label={objectFit === "contain" ? "填充模式" : "适应模式"}
|
|
onClick={() =>
|
|
onObjectFitChange(
|
|
objectFit === "contain" ? "cover" : "contain",
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:hidden">
|
|
<MoreMenuItem
|
|
icon={renderLoopIcon()}
|
|
label={loopLabel}
|
|
onClick={handleLoopModeToggle}
|
|
/>
|
|
</div>
|
|
|
|
{isVideo && hasTranscript && onShowTranscript && (
|
|
<div className="md:hidden">
|
|
<MoreMenuItem
|
|
icon={<FileText size={18} />}
|
|
label="显示转录"
|
|
onClick={onShowTranscript}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="md:hidden">
|
|
<MoreMenuItem
|
|
icon={<Download size={18} />}
|
|
label={isVideo ? "下载视频" : "下载图片"}
|
|
onClick={onDownload}
|
|
/>
|
|
</div>
|
|
|
|
{isVideo && (
|
|
<div className="md:hidden">
|
|
<MoreMenuItem
|
|
icon={<span className="text-sm font-mono">{rate}x</span>}
|
|
label="播放速度"
|
|
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);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</MoreMenu>
|
|
</div>
|
|
|
|
{/* 全屏 - 所有设备都显示 */}
|
|
<button
|
|
type="button"
|
|
className={CONTROL_BTN_CLASS}
|
|
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>
|
|
);
|
|
});
|