重构client,拆分组件

This commit is contained in:
feie9456 2025-10-22 20:14:11 +08:00
parent 5868407216
commit fe9bc8fd6c
23 changed files with 1601 additions and 1137 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.tabSize": 2
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
import { forwardRef } from "react";
interface BackgroundCanvasProps {}
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => {
return (
<canvas
ref={ref}
className="fixed inset-0 w-full h-full -z-10"
style={{ filter: "blur(40px)" }}
/>
);
});
BackgroundCanvas.displayName = "BackgroundCanvas";

View File

@ -0,0 +1,56 @@
import { ThumbsUp } from "lucide-react";
import type { Comment, User } from "../types";
import { formatRelativeTime } from "../utils";
import { CommentText } from "./CommentText";
interface CommentListProps {
author: User;
createdAt: string | Date;
comments: Comment[];
}
export function CommentList({ author, createdAt, comments }: CommentListProps) {
return (
<>
<header className="flex items-center gap-4 mb-5">
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
{author.avatar_url ? (
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
) : null}
</div>
<div>
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div>
<div className="text-xs text-white/50" title={new Date(createdAt).toLocaleString()}>
{formatRelativeTime(createdAt)}
</div>
</div>
</header>
<ul className="space-y-4 sm:space-y-5">
{comments.map((c) => (
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
{c.user.avatar_url ? (
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
<span className="text-xs text-white/50">{formatRelativeTime(c.created_at)}</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} />
</p>
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
<ThumbsUp size={14} />
<span>{c.digg_count}</span>
</div>
</div>
</li>
))}
{comments.length === 0 ? <li className="text-sm text-white/60"></li> : null}
</ul>
</>
);
}

View File

@ -0,0 +1,71 @@
import { X } from "lucide-react";
import type { Comment, User } from "../types";
import { CommentList } from "./CommentList";
interface CommentPanelProps {
open: boolean;
onClose: () => void;
author: User;
createdAt: string | Date;
comments: Comment[];
mounted: boolean;
}
export function CommentPanel({ open, onClose, author, createdAt, comments, mounted }: CommentPanelProps) {
return (
<>
{/* 横屏评论面板:并排分栏 */}
<aside
className={`
hidden landscape:flex
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white
relative h-full overflow-hidden
${mounted ? "transition-[width] duration-200 ease-out" : ""}
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
<div className="text-white font-semibold"> {comments.length > 0 ? `(${comments.length})` : ""}</div>
</div>
<div className="p-3 overflow-auto">
<CommentList author={author} createdAt={createdAt} comments={comments} />
</div>
</aside>
{/* 竖屏评论面板bottom sheet */}
<aside
className={`
landscape:hidden
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)]
${mounted ? "transition-transform duration-200 ease-out" : ""}
border-t border-white/10
${open ? "translate-y-0" : "translate-y-full"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<div className="text-white font-semibold"> {comments.length > 0 ? `(${comments.length})` : ""}</div>
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div className="p-3 overflow-auto">
<CommentList author={author} createdAt={createdAt} comments={comments} />
</div>
</aside>
</>
);
}

View File

@ -0,0 +1,30 @@
import { parseCommentText } from "../utils";
// 渲染评论文本(包含表情)
export function CommentText({ text }: { text: string }) {
const parts = parseCommentText(text);
return (
<>
{parts.map((part, idx: number) => {
if (typeof part === "string") {
return <span key={idx}>{part}</span>;
}
return (
<img
key={idx}
src={`/emojis/${part.name}.webp`}
alt={part.name}
className="inline-block w-5 h-5 align-text-bottom mx-0.5"
onError={(e) => {
// 如果图片加载失败,显示原始文本
e.currentTarget.style.display = "none";
const textNode = document.createTextNode(`[${part.name}]`);
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget);
}}
/>
);
})}
</>
);
}

View File

@ -0,0 +1,39 @@
import { forwardRef } from "react";
import type { ImageData } from "../types";
interface ImageCarouselProps {
images: ImageData["images"];
currentIndex: number;
onTogglePlay: () => void;
}
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
({ images, currentIndex, onTogglePlay }, ref) => {
return (
<div
ref={ref}
className="flex h-full w-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
>
{images.map((img, i) => (
<div
key={img.id}
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer"
onClick={onTogglePlay}
>
<img
src={img.url}
alt={`image-${i + 1}`}
className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
}}
/>
</div>
))}
</div>
);
}
);
ImageCarousel.displayName = "ImageCarousel";

View File

@ -0,0 +1,56 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
interface ImageNavigationButtonsProps {
onPrev: () => void;
onNext: () => void;
currentIndex: number;
totalImages: number;
}
export function ImageNavigationButtons({
onPrev,
onNext,
currentIndex,
totalImages,
}: ImageNavigationButtonsProps) {
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < totalImages - 1;
return (
<>
{/* 左侧按钮 */}
<button
className={`
absolute left-4 top-7/16 -translate-y-1/2 z-20
w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20
flex items-center justify-center
text-white transition-all
${hasPrev ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"}
`}
onClick={onPrev}
disabled={!hasPrev}
aria-label="上一张图片"
>
<ChevronLeft size={24} />
</button>
{/* 右侧按钮 */}
<button
className={`
absolute right-4 top-7/16 -translate-y-1/2 z-20
w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20
flex items-center justify-center
text-white transition-all
${hasNext ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"}
`}
onClick={onNext}
disabled={!hasNext}
aria-label="下一张图片"
>
<ChevronRight size={24} />
</button>
</>
);
}

View File

@ -0,0 +1,256 @@
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>
);
}

View File

@ -0,0 +1,60 @@
import { ChevronDown, ChevronUp, MessageSquareText } from "lucide-react";
import type { Neighbors } from "../types";
interface NavigationButtonsProps {
neighbors: Neighbors;
commentsCount: number;
onNavigatePrev: () => void;
onNavigateNext: () => void;
onToggleComments: () => void;
}
export function NavigationButtons({
neighbors,
commentsCount,
onNavigatePrev,
onNavigateNext,
onToggleComments,
}: NavigationButtonsProps) {
return (
<>
{/* 评论开关(右侧中部) */}
<button
className="z-10 grid place-items-center w-[54px] h-[54px] rounded-full absolute right-4 top-2/3 -translate-y-1/2 cursor-pointer"
onClick={onToggleComments}
aria-label="切换评论"
>
<div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" />
{commentsCount > 0 ? <span className="text-[18px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
<span className="text-[12px] font-semibold text-white/90 drop-shadow"></span>
}
</div>
</button>
{/* 上下切换按钮(右侧胶囊形状) */}
<div className="absolute right-4 top-6/11 -translate-y-1/2 z-10">
<div className="flex flex-col rounded-full bg-black/40 backdrop-blur-sm border border-white/20 overflow-hidden">
<button
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors"
onClick={onNavigatePrev}
disabled={!neighbors.prev}
aria-label="上一条"
>
<ChevronUp size={24} />
</button>
<button
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors border-t border-white/20"
onClick={onNavigateNext}
disabled={!neighbors.next}
aria-label="下一条"
>
<ChevronDown size={24} />
</button>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,18 @@
interface ProgressBarProps {
progress: number;
onSeek: (ratio: number) => void;
}
export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
return (
<div
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onSeek((e.clientX - rect.left) / rect.width);
}}
>
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} />
</div>
);
}

View File

@ -0,0 +1,40 @@
interface SegmentedProgressBarProps {
totalSegments: number;
currentIndex: number;
segmentProgress: number;
onSeek: (ratio: number) => void;
}
export function SegmentedProgressBar({
totalSegments,
currentIndex,
segmentProgress,
onSeek,
}: SegmentedProgressBarProps) {
return (
<div
className="relative h-1.5 cursor-pointer"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onSeek((e.clientX - rect.left) / rect.width);
}}
>
<div className="flex gap-1.5 h-full">
{Array.from({ length: totalSegments }).map((_, i) => {
let fill = 0;
if (i < currentIndex) fill = 1;
else if (i === currentIndex) fill = segmentProgress;
return (
<div
key={i}
aria-label={`${i + 1}`}
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
>
<div className="h-full origin-left bg-white" style={{ transform: `scaleX(${fill})` }} />
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { forwardRef } from "react";
import type { ObjectFit } from "../types";
interface VideoPlayerProps {
videoUrl: string;
rotation: number;
objectFit: ObjectFit;
loop: boolean;
onTogglePlay: () => void;
}
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
return (
<video
ref={ref}
src={videoUrl}
className={[
// 旋转 0/180充满容器盒子
// 旋转 90/270用中心定位 + 100vh/100vw保证铺满全屏
rotation % 180 === 0
? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer"
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer",
].join(" ")}
style={{
transform:
rotation % 180 === 0
? `rotate(${rotation}deg)`
: `translate(-50%, -50%) rotate(${rotation}deg)`,
transformOrigin: "center center",
objectFit,
}}
playsInline
loop={loop}
onClick={onTogglePlay}
/>
);
}
);
VideoPlayer.displayName = "VideoPlayer";

View File

@ -0,0 +1,12 @@
// 组件导出
export { BackgroundCanvas } from "./BackgroundCanvas";
export { CommentPanel } from "./CommentPanel";
export { CommentList } from "./CommentList";
export { CommentText } from "./CommentText";
export { ImageCarousel } from "./ImageCarousel";
export { ImageNavigationButtons } from "./ImageNavigationButtons";
export { MediaControls } from "./MediaControls";
export { NavigationButtons } from "./NavigationButtons";
export { ProgressBar } from "./ProgressBar";
export { SegmentedProgressBar } from "./SegmentedProgressBar";
export { VideoPlayer } from "./VideoPlayer";

View File

@ -0,0 +1,7 @@
// Hooks 导出
export { useBackgroundCanvas } from "./useBackgroundCanvas";
export { useCommentState } from "./useCommentState";
export { useImageCarousel } from "./useImageCarousel";
export { useNavigation } from "./useNavigation";
export { usePlayerState } from "./usePlayerState";
export { useVideoPlayer } from "./useVideoPlayer";

View File

@ -0,0 +1,95 @@
import { useEffect } from "react";
import type { RefObject } from "react";
interface UseBackgroundCanvasProps {
isVideo: boolean;
idx: number;
videoRef: RefObject<HTMLVideoElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
}
export function useBackgroundCanvas({
isVideo,
idx,
videoRef,
scrollerRef,
backgroundCanvasRef,
}: UseBackgroundCanvasProps) {
useEffect(() => {
const canvas = backgroundCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const updateCanvasSize = () => {
canvas.width = Math.floor(window.innerWidth / 10);
canvas.height = Math.floor(window.innerHeight / 10);
};
updateCanvasSize();
let resizeTimeout: NodeJS.Timeout;
const debouncedResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateCanvasSize, 300);
};
window.addEventListener("resize", debouncedResize);
const drawMediaToCanvas = () => {
if (!ctx) return;
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null;
if (isVideo) {
sourceElement = videoRef.current;
} else {
const scroller = scrollerRef.current;
if (scroller) {
const currentImgContainer = scroller.children[idx] as HTMLElement;
if (currentImgContainer) {
sourceElement = currentImgContainer.querySelector("img");
}
}
}
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const sourceWidth =
sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth;
const sourceHeight =
sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
if (!sourceWidth || !sourceHeight) return;
const canvasRatio = canvasWidth / canvasHeight;
const sourceRatio = sourceWidth / sourceHeight;
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number;
if (canvasRatio > sourceRatio) {
drawWidth = canvasWidth;
drawHeight = canvasWidth / sourceRatio;
offsetX = 0;
offsetY = (canvasHeight - drawHeight) / 2;
} else {
drawHeight = canvasHeight;
drawWidth = canvasHeight * sourceRatio;
offsetX = (canvasWidth - drawWidth) / 2;
offsetY = 0;
}
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
};
const intervalId = setInterval(drawMediaToCanvas, 20);
return () => {
clearInterval(intervalId);
window.removeEventListener("resize", debouncedResize);
clearTimeout(resizeTimeout);
};
}, [isVideo, idx, videoRef, scrollerRef, backgroundCanvasRef]);
}

View File

@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { getStringFromStorage, saveToStorage } from "../utils";
export function useCommentState() {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// 从 localStorage 恢复评论区状态
useEffect(() => {
if (typeof window === "undefined") return;
const saved = getStringFromStorage("aweme_player_comments_open", "false");
if (saved === "true") {
setOpen(true);
}
// 短暂延迟后标记为已挂载,启用动画
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setMounted(true);
});
});
}, []);
// 持久化评论区状态
useEffect(() => {
if (typeof window === "undefined") return;
saveToStorage("aweme_player_comments_open", open.toString());
}, [open]);
return { open, setOpen, mounted };
}

View File

@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { ImageData, LoopMode, Neighbors } from "../types";
const SEGMENT_MS = 5000;
interface UseImageCarouselProps {
images: ImageData["images"];
isPlaying: boolean;
loopMode: LoopMode;
neighbors: Neighbors;
volume: number;
audioRef: RefObject<HTMLAudioElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
setProgress: (progress: number) => void;
}
export function useImageCarousel({
images,
isPlaying,
loopMode,
neighbors,
volume,
audioRef,
scrollerRef,
setProgress,
}: UseImageCarouselProps) {
const router = useRouter();
const [idx, setIdx] = useState(0);
const [segProgress, setSegProgress] = useState(0);
const segStartRef = useRef<number | null>(null);
const idxRef = useRef<number>(0);
const rafRef = useRef<number | null>(null);
useEffect(() => {
idxRef.current = idx;
}, [idx]);
// BGM 控制
useEffect(() => {
const el = audioRef.current;
if (!el) return;
el.volume = volume;
if (isPlaying) {
el.play().catch(() => {});
} else {
el.pause();
}
}, [audioRef, isPlaying, volume]);
// 自动切页
useEffect(() => {
if (!images?.length) return;
if (segStartRef.current == null) segStartRef.current = performance.now();
let lastTs = performance.now();
const tick = (ts: number) => {
if (!images?.length) return;
if (!isPlaying) segStartRef.current! += ts - lastTs;
lastTs = ts;
let start = segStartRef.current!;
let localIdx = idxRef.current;
let elapsed = ts - start;
while (elapsed >= SEGMENT_MS) {
elapsed -= SEGMENT_MS;
if (localIdx >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
return;
}
localIdx = 0;
} else {
localIdx = localIdx + 1;
}
}
segStartRef.current = ts - elapsed;
if (localIdx !== idxRef.current) {
idxRef.current = localIdx;
setIdx(localIdx);
const el = scrollerRef.current;
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
}
const localSeg = Math.max(0, Math.min(1, elapsed / SEGMENT_MS));
setSegProgress(localSeg);
setProgress((localIdx + localSeg) / images.length);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress]);
return {
idx,
setIdx,
segProgress,
segStartRef,
idxRef,
};
}

View File

@ -0,0 +1,138 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { Neighbors } from "../types";
interface UseNavigationProps {
neighbors: Neighbors;
isVideo: boolean;
mediaContainerRef: RefObject<HTMLDivElement | null>;
videoRef?: RefObject<HTMLVideoElement | null>;
prevImg?: () => void;
nextImg?: () => void;
togglePlay: () => void;
}
export function useNavigation({
neighbors,
isVideo,
mediaContainerRef,
videoRef,
prevImg,
nextImg,
togglePlay,
}: UseNavigationProps) {
const router = useRouter();
const wheelCooldownRef = useRef<number>(0);
// 预取路由
useEffect(() => {
if (!neighbors) return;
if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`);
if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`);
}, [neighbors, router]);
// 鼠标滚轮切换
useEffect(() => {
const el = mediaContainerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey) return;
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return;
const dy = e.deltaY;
if (Math.abs(dy) < 40) return;
if ((dy > 0 && neighbors?.next) || (dy < 0 && neighbors?.prev)) {
e.preventDefault();
}
if (dy > 0 && neighbors?.next) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`);
} else if (dy < 0 && neighbors?.prev) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.prev.aweme_id}`);
}
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel as any);
}, [neighbors, mediaContainerRef, router]);
// 键盘快捷键
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return;
}
const key = e.key.toLowerCase();
if (key === "arrowup" || key === "w") {
e.preventDefault();
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return;
if (neighbors?.prev) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.prev.aweme_id}`);
}
} else if (key === "arrowdown" || key === "s") {
e.preventDefault();
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return;
if (neighbors?.next) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`);
}
} else if (key === "arrowleft" || key === "a") {
e.preventDefault();
if (isVideo) {
const v = videoRef?.current;
if (v && v.duration) {
v.currentTime = Math.max(0, v.currentTime - 5);
}
} else {
prevImg?.();
}
} else if (key === "arrowright" || key === "d") {
e.preventDefault();
if (isVideo) {
const v = videoRef?.current;
if (v && v.duration) {
v.currentTime = Math.min(v.duration, v.currentTime + 5);
}
} else {
nextImg?.();
}
} else if (key === " ") {
e.preventDefault();
togglePlay();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
// 浏览器返回事件
useEffect(() => {
window.history.pushState({ interceptBack: true }, "");
const handlePopState = () => {
window.close();
setTimeout(() => {
if (!document.hidden) {
router.push("/");
}
}, 100);
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [router]);
}

View File

@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import type { LoopMode, ObjectFit } from "../types";
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils";
export function usePlayerState() {
const [isPlaying, setIsPlaying] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1));
const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1));
const [progress, setProgress] = useState(0);
const [rotation, setRotation] = useState(0);
const [progressRestored, setProgressRestored] = useState(false);
const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
const [loopMode, setLoopMode] = useState<LoopMode>(() => {
const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
return saved === "sequential" ? "sequential" : "loop";
});
// 持久化音量
useEffect(() => {
saveToStorage("aweme_player_volume", volume);
}, [volume]);
// 持久化倍速
useEffect(() => {
saveToStorage("aweme_player_rate", rate);
}, [rate]);
// 持久化循环模式
useEffect(() => {
saveToStorage("aweme_player_loop_mode", loopMode);
}, [loopMode]);
// 监听全屏状态
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onFsChange);
return () => document.removeEventListener("fullscreenchange", onFsChange);
}, []);
return {
isPlaying,
setIsPlaying,
isFullscreen,
setIsFullscreen,
volume,
setVolume,
rate,
setRate,
progress,
setProgress,
rotation,
setRotation,
progressRestored,
setProgressRestored,
objectFit,
setObjectFit,
loopMode,
setLoopMode,
};
}

View File

@ -0,0 +1,171 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { LoopMode, Neighbors } from "../types";
interface UseVideoPlayerProps {
awemeId: string;
videoRef: RefObject<HTMLVideoElement | null>;
volume: number;
rate: number;
loopMode: LoopMode;
neighbors: Neighbors;
progressRestored: boolean;
setIsPlaying: (playing: boolean) => void;
setProgress: (progress: number) => void;
setProgressRestored: (restored: boolean) => void;
}
export function useVideoPlayer({
awemeId,
videoRef,
volume,
rate,
loopMode,
neighbors,
progressRestored,
setIsPlaying,
setProgress,
setProgressRestored,
}: UseVideoPlayerProps) {
const router = useRouter();
// 恢复播放进度
useEffect(() => {
if (progressRestored) return;
const v = videoRef.current;
if (!v) return;
const onLoadedMetadata = () => {
if (progressRestored) return;
try {
const key = `aweme_progress_${awemeId}`;
const saved = localStorage.getItem(key);
if (!saved) {
setProgressRestored(true);
return;
}
const { time, timestamp } = JSON.parse(saved);
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) {
v.currentTime = time;
console.log(`恢复播放进度: ${Math.round(time)}s`);
} else if (now - timestamp >= fiveMinutes) {
localStorage.removeItem(key);
}
} catch (e) {
console.error("恢复播放进度失败", e);
}
setProgressRestored(true);
};
if (v.readyState >= 1) {
onLoadedMetadata();
} else {
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
}
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
// 保存播放进度
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const saveProgress = () => {
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
try {
const key = `aweme_progress_${awemeId}`;
const value = JSON.stringify({
time: v.currentTime,
timestamp: Date.now(),
});
localStorage.setItem(key, value);
} catch (e) {
console.error("保存播放进度失败", e);
}
};
const interval = setInterval(saveProgress, 2000);
const onBeforeUnload = () => saveProgress();
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
clearInterval(interval);
window.removeEventListener("beforeunload", onBeforeUnload);
saveProgress();
};
}, [awemeId, videoRef]);
// 监听播放状态和进度
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onTime = () => {
if (!v.duration || Number.isNaN(v.duration)) return;
setProgress(v.currentTime / v.duration);
};
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onEnded = () => {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
}
};
v.addEventListener("timeupdate", onTime);
v.addEventListener("loadedmetadata", onTime);
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("ended", onEnded);
return () => {
v.removeEventListener("timeupdate", onTime);
v.removeEventListener("loadedmetadata", onTime);
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
v.removeEventListener("ended", onEnded);
};
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
// 自动播放检测
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const checkAutoplay = async () => {
try {
await v.play();
setIsPlaying(true);
} catch (error) {
console.log("自动播放被阻止,需要用户交互");
setIsPlaying(false);
}
};
if (v.readyState >= 1) {
checkAutoplay();
} else {
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
}
}, [awemeId, videoRef, setIsPlaying]);
// 更新音量和倍速
useEffect(() => {
const v = videoRef.current;
if (v) v.volume = volume;
}, [volume, videoRef]);
useEffect(() => {
const v = videoRef.current;
if (v) v.playbackRate = rate;
}, [rate, videoRef]);
}

View File

@ -0,0 +1,43 @@
export type User = { nickname: string; avatar_url: string | null };
export type Comment = {
cid: string;
text: string;
digg_count: number;
created_at: string | Date;
user: User;
};
export type VideoData = {
type: "video";
aweme_id: string;
desc: string;
created_at: string | Date;
duration_ms?: number | null;
video_url: string;
width?: number | null;
height?: number | null;
author: User;
comments: Comment[];
};
export type ImageData = {
type: "image";
aweme_id: string;
desc: string;
created_at: string | Date;
images: { id: string; url: string; width?: number; height?: number }[];
music_url?: string | null;
author: User;
comments: Comment[];
};
export type AwemeData = VideoData | ImageData;
export type Neighbors = {
prev: { aweme_id: string } | null;
next: { aweme_id: string } | null;
};
export type LoopMode = "loop" | "sequential";
export type ObjectFit = "contain" | "cover";

View File

@ -0,0 +1,72 @@
// 格式化相对时间
export function formatRelativeTime(date: string | Date): string {
const now = new Date();
const target = new Date(date);
const diffMs = now.getTime() - target.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
if (diffYears > 0) return `${diffYears}年前`;
if (diffMonths > 0) return `${diffMonths}月前`;
if (diffDays > 0) return `${diffDays}天前`;
if (diffHours > 0) return `${diffHours}小时前`;
if (diffMinutes > 0) return `${diffMinutes}分钟前`;
return "刚刚";
}
// 格式化时间显示 (mm:ss)
export function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
// 处理评论文本中的表情占位符
export function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] {
const parts: (string | { type: "emoji"; name: string })[] = [];
const regex = /\[([^\]]+)\]/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// 添加表情前的文本
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// 添加表情
parts.push({ type: "emoji", name: match[1] });
lastIndex = regex.lastIndex;
}
// 添加剩余文本
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
// 从 localStorage 获取数值
export function getNumberFromStorage(key: string, defaultValue: number): number {
if (typeof window === "undefined") return defaultValue;
const saved = localStorage.getItem(key);
if (!saved) return defaultValue;
const parsed = parseFloat(saved);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
// 从 localStorage 获取字符串
export function getStringFromStorage(key: string, defaultValue: string): string {
if (typeof window === "undefined") return defaultValue;
return localStorage.getItem(key) || defaultValue;
}
// 保存到 localStorage
export function saveToStorage(key: string, value: string | number): void {
if (typeof window === "undefined") return;
localStorage.setItem(key, value.toString());
}