Files
douyin-archive/app/aweme/[awemeId]/components/MediaControls.tsx
T
feie9454andClaude Opus 5.5 b84fcec77b feat: 播放页按来源列表切换,新增作者/搜索/hashtag 侧栏
- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条
  在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序
- 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出
- 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换
- 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表
- 从搜索结果进入或点 hashtag 时自动打开对应列表
- 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos)
- 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致
- 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:50:09 +08:00

498 lines
16 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,
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, useMemo } from "react";
import { parseDescHashtags } from "@/lib/hashtag";
import type { AbLoop, 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>;
abLoop?: AbLoop;
onClearAbLoop?: () => void;
// 图文专用
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;
/** 点作者头像/名字:在侧栏打开「TA的作品」 */
onAuthorClick?: () => void;
/** 点描述里的 hashtag:切到该 hashtag 的作品列表 */
onHashtagClick?: (name: string) => 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,
abLoop,
onClearAbLoop,
currentIndex = 0,
totalSegments = 0,
segmentProgress = 0,
musicUrl,
audioRef,
hasTranscript = false,
onShowTranscript,
onTogglePlay,
onSeek,
onVolumeChange,
onRateChange,
onRotationChange,
onObjectFitChange,
onLoopModeChange,
onDownload,
onToggleFullscreen,
onAuthorClick,
onHashtagClick,
}: MediaControlsProps) {
const descParts = useMemo(() => parseDescHashtags(desc || ""), [desc]);
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
data-no-swipe
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 && onAuthorClick ? (
<button
type="button"
onClick={onAuthorClick}
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity cursor-pointer"
title="查看TA的作品"
>
<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>
</button>
) : 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">
{descParts.map((part, i) =>
part.type === "hashtag" && onHashtagClick ? (
<button
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
key={i}
type="button"
onClick={() => onHashtagClick(part.name)}
className="pointer-events-auto font-semibold text-white hover:underline cursor-pointer"
title={`查看所有带 #${part.name} 的作品`}
>
{part.text}
</button>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
<span key={i}>{part.text}</span>
),
)}
</p>
</div>
) : null}
{/* 进度条:图文=分段;视频=单段 */}
{!isVideo && totalSegments > 0 ? (
<SegmentedProgressBar
totalSegments={totalSegments}
currentIndex={currentIndex}
segmentProgress={segmentProgress}
onSeek={onSeek}
/>
) : (
<ProgressBar
progress={progress}
onSeek={onSeek}
abLoop={isVideo ? abLoop : undefined}
duration={isVideo ? (videoRef?.current?.duration ?? 0) : 0}
/>
)}
{/* 控制按钮行 - 响应式布局 */}
<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>
)}
{/* AB 循环状态提示与快捷清除 */}
{isVideo && abLoop && (abLoop.a !== null || abLoop.b !== null) && (
<button
type="button"
onClick={onClearAbLoop}
className="h-[30px] px-2.5 rounded-full text-[11px] font-mono bg-amber-400/20 text-amber-300 border border-amber-400/50 cursor-pointer shrink-0 hover:bg-amber-400/30 transition-colors inline-flex items-center gap-1"
title="点击清除 AB 循环 (快捷键 \)"
aria-label="清除 AB 循环"
>
<span>
AB {formatTime(abLoop.a ?? 0)}-
{abLoop.b !== null
? formatTime(abLoop.b)
: videoRef?.current?.duration
? formatTime(videoRef.current.duration)
: "末"}
</span>
<span className="text-amber-200/80 text-xs leading-none">×</span>
</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>
);
});