重构client,拆分组件
This commit is contained in:
parent
5868407216
commit
fe9bc8fd6c
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 2
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
15
app/aweme/[awemeId]/components/BackgroundCanvas.tsx
Normal file
15
app/aweme/[awemeId]/components/BackgroundCanvas.tsx
Normal 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";
|
||||||
56
app/aweme/[awemeId]/components/CommentList.tsx
Normal file
56
app/aweme/[awemeId]/components/CommentList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/aweme/[awemeId]/components/CommentPanel.tsx
Normal file
71
app/aweme/[awemeId]/components/CommentPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
app/aweme/[awemeId]/components/CommentText.tsx
Normal file
30
app/aweme/[awemeId]/components/CommentText.tsx
Normal 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/aweme/[awemeId]/components/ImageCarousel.tsx
Normal file
39
app/aweme/[awemeId]/components/ImageCarousel.tsx
Normal 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";
|
||||||
56
app/aweme/[awemeId]/components/ImageNavigationButtons.tsx
Normal file
56
app/aweme/[awemeId]/components/ImageNavigationButtons.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
app/aweme/[awemeId]/components/MediaControls.tsx
Normal file
256
app/aweme/[awemeId]/components/MediaControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
app/aweme/[awemeId]/components/NavigationButtons.tsx
Normal file
60
app/aweme/[awemeId]/components/NavigationButtons.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/aweme/[awemeId]/components/ProgressBar.tsx
Normal file
18
app/aweme/[awemeId]/components/ProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
app/aweme/[awemeId]/components/SegmentedProgressBar.tsx
Normal file
40
app/aweme/[awemeId]/components/SegmentedProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/aweme/[awemeId]/components/VideoPlayer.tsx
Normal file
41
app/aweme/[awemeId]/components/VideoPlayer.tsx
Normal 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";
|
||||||
12
app/aweme/[awemeId]/components/index.ts
Normal file
12
app/aweme/[awemeId]/components/index.ts
Normal 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";
|
||||||
7
app/aweme/[awemeId]/hooks/index.ts
Normal file
7
app/aweme/[awemeId]/hooks/index.ts
Normal 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";
|
||||||
95
app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts
Normal file
95
app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts
Normal 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]);
|
||||||
|
}
|
||||||
30
app/aweme/[awemeId]/hooks/useCommentState.ts
Normal file
30
app/aweme/[awemeId]/hooks/useCommentState.ts
Normal 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 };
|
||||||
|
}
|
||||||
113
app/aweme/[awemeId]/hooks/useImageCarousel.ts
Normal file
113
app/aweme/[awemeId]/hooks/useImageCarousel.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
138
app/aweme/[awemeId]/hooks/useNavigation.ts
Normal file
138
app/aweme/[awemeId]/hooks/useNavigation.ts
Normal 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]);
|
||||||
|
}
|
||||||
61
app/aweme/[awemeId]/hooks/usePlayerState.ts
Normal file
61
app/aweme/[awemeId]/hooks/usePlayerState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
171
app/aweme/[awemeId]/hooks/useVideoPlayer.ts
Normal file
171
app/aweme/[awemeId]/hooks/useVideoPlayer.ts
Normal 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]);
|
||||||
|
}
|
||||||
43
app/aweme/[awemeId]/types.ts
Normal file
43
app/aweme/[awemeId]/types.ts
Normal 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";
|
||||||
72
app/aweme/[awemeId]/utils.ts
Normal file
72
app/aweme/[awemeId]/utils.ts
Normal 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());
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user