Compare commits

...

2 Commits

12 changed files with 323 additions and 121 deletions

View File

@ -2,13 +2,13 @@
import { BrowserContext, Page, chromium, type Response } from 'playwright'; import { BrowserContext, Page, chromium, type Response } from 'playwright';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { uploadFile, generateUniqueFileName } from '@/lib/minio'; import { uploadFile, generateUniqueFileName } from '@/lib/minio';
import { createCamelCompatibleProxy } from '@/app/fetcher/utils'; import { createCamelCompatibleProxy } from '@/app/api/fetcher/utils';
import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/fetcher/network'; import { waitForFirstResponse, waitForResponseWithTimeout, safeJson, downloadBinary } from '@/app/api/fetcher/network';
import { pickBestPlayAddr, extractFirstFrame } from '@/app/fetcher/media'; import { pickBestPlayAddr, extractFirstFrame } from '@/app/api/fetcher/media';
import { handleImagePost } from '@/app/fetcher/uploader'; import { handleImagePost } from '@/app/api/fetcher/uploader';
import { saveToDB, saveImagePostToDB } from '@/app/fetcher/persist'; import { saveToDB, saveImagePostToDB } from '@/app/api/fetcher/persist';
import chalk from 'chalk'; import chalk from 'chalk';
import { acquireBrowserContext, releaseBrowserContext } from '@/app/fetcher/browser'; import { acquireBrowserContext, releaseBrowserContext } from '@/app/api/fetcher/browser';
const DETAIL_PATH = '/aweme/v1/web/aweme/detail/'; const DETAIL_PATH = '/aweme/v1/web/aweme/detail/';
const COMMENT_PATH = '/aweme/v1/web/comment/list/'; const COMMENT_PATH = '/aweme/v1/web/comment/list/';

View File

@ -29,6 +29,26 @@ import {
type User = { nickname: string; avatar_url: string | null }; type User = { nickname: string; avatar_url: string | null };
type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User }; type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User };
// 格式化相对时间
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 '刚刚';
}
// 处理评论文本中的表情占位符 // 处理评论文本中的表情占位符
function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] { function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] {
const parts: (string | { type: "emoji"; name: string })[] = []; const parts: (string | { type: "emoji"; name: string })[] = [];
@ -117,15 +137,25 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
const router = useRouter(); const router = useRouter();
// ====== 布局 & 评论 ====== // ====== 布局 & 评论 ======
const [open, setOpen] = useState(() => { const [open, setOpen] = useState(false); // 评论是否展开(竖屏为 bottom sheet横屏为并排分栏
// 从 localStorage 读取评论区状态,默认 false const [mounted, setMounted] = useState(false); // 用于跳过首次加载的动画
if (typeof window === "undefined") return false;
const saved = localStorage.getItem("aweme_player_comments_open");
if (!saved) return false;
return saved === "true";
}); // 评论是否展开(竖屏为 bottom sheet横屏为并排分栏
const comments = useMemo(() => data.comments ?? [], [data]); const comments = useMemo(() => data.comments ?? [], [data]);
// ====== 从 localStorage 恢复评论区状态(仅客户端) ======
useEffect(() => {
if (typeof window === "undefined") return;
const saved = localStorage.getItem("aweme_player_comments_open");
if (saved === "true") {
setOpen(true);
}
// 短暂延迟后标记为已挂载,启用动画
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setMounted(true);
});
});
}, []);
// ====== 媒体引用 ====== // ====== 媒体引用 ======
const mediaContainerRef = useRef<HTMLDivElement | null>(null); const mediaContainerRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
@ -316,6 +346,33 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
}; };
}, [isVideo, loopMode, neighbors?.next?.aweme_id, router]); }, [isVideo, loopMode, neighbors?.next?.aweme_id, router]);
// ====== 视频:监听自动播放失败 ======
useEffect(() => {
if (!isVideo) return;
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);
}
}, [isVideo, data.aweme_id]); // 依赖 aweme_id 确保切换视频时重新检查
useEffect(() => { useEffect(() => {
if (!isVideo) return; if (!isVideo) return;
const v = videoRef.current; const v = videoRef.current;
@ -488,7 +545,20 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
const toggleFullscreen = () => { const toggleFullscreen = () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
document.body.requestFullscreen().catch(() => { }); if (document.body.requestFullscreen) {
document.body.requestFullscreen().catch(() => { });
return
}
const vRef = videoRef.current;
if (vRef && vRef.requestFullscreen) {
vRef.requestFullscreen().catch(() => { });
return
}
// @ts-ignore
if (vRef && vRef.webkitEnterFullscreen) {
// @ts-ignore
vRef.webkitEnterFullscreen();
}
} else { } else {
document.exitFullscreen().catch(() => { }); document.exitFullscreen().catch(() => { });
} }
@ -547,21 +617,54 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
}; };
// ====== 侧栏(横屏)/ 抽屉竖屏样式Tailwind // ====== 评论内容组件 - 使用 useMemo 避免不必要的重新渲染 ======
const asideClasses = [ const commentContent = useMemo(() => (
"z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white", <>
// 竖屏bottom sheet从下向上弹出 <header className="flex items-center gap-4 mb-5">
"portrait:fixed portrait:inset-x-0 portrait:top-110 portrait:w-full portrait:h-[min(80vh,88dvh)]", <div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
"portrait:transition-transform portrait:duration-200 portrait:ease-out", {data.author.avatar_url ? (
open ? "portrait:translate-y-0" : "portrait:translate-y-full", // eslint-disable-next-line @next/next/no-img-element
"portrait:border-t portrait:border-white/10", <img src={data.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
// 横屏:并排分栏,宽度过渡 ) : null}
"landscape:relative landscape:h-full landscape:overflow-hidden", </div>
"landscape:transition-[width] landscape:duration-200 landscape:ease-out", <div>
open <div className="font-medium text-white/95 text-sm sm:text-base">{data.author.nickname}</div>
? "landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10" <div className="text-xs text-white/50" title={new Date(data.created_at).toLocaleString()}>
: "landscape:w-0", {formatRelativeTime(data.created_at)}
].join(" "); </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 ? (
// eslint-disable-next-line @next/next/no-img-element
<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" title={new Date(c.created_at).toLocaleString()}>
{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>
</>
), [comments, data.author, data.created_at]);
// ====== 预取上/下一条路由,提高切换流畅度 ====== // ====== 预取上/下一条路由,提高切换流畅度 ======
useEffect(() => { useEffect(() => {
@ -600,6 +703,72 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
return () => el.removeEventListener("wheel", onWheel as any); return () => el.removeEventListener("wheel", onWheel as any);
}, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]); }, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]);
// ====== 键盘快捷键 ======
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();
// 上下方向键 / w s切换上一条/下一条视频
if (key === 'arrowup' || key === 'w') {
e.preventDefault();
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return; // 冷却 700ms
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; // 冷却 700ms
if (neighbors?.next) {
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`);
}
}
// 左右方向键 / a d快进快退(视频) 或 切换图片(图文)
else if (key === 'arrowleft' || key === 'a') {
e.preventDefault();
if (isVideo) {
// 视频:后退 5 秒
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) {
// 视频:前进 5 秒
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?.prev?.aweme_id, neighbors?.next?.aweme_id, router]);
// ====== 动态模糊背景:定时截取当前媒体内容绘制到背景 canvas ====== // ====== 动态模糊背景:定时截取当前媒体内容绘制到背景 canvas ======
useEffect(() => { useEffect(() => {
const canvas = backgroundCanvasRef.current; const canvas = backgroundCanvasRef.current;
@ -610,11 +779,18 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
// 更新 canvas 尺寸以匹配视口 // 更新 canvas 尺寸以匹配视口
const updateCanvasSize = () => { const updateCanvasSize = () => {
canvas.width = window.innerWidth; canvas.width = Math.floor(window.innerWidth / 10);
canvas.height = window.innerHeight; canvas.height = Math.floor(window.innerHeight / 10);
}; };
updateCanvasSize(); updateCanvasSize();
window.addEventListener("resize", updateCanvasSize);
// 防抖处理 resize 事件300ms
let resizeTimeout: NodeJS.Timeout;
const debouncedResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateCanvasSize, 300);
};
window.addEventListener("resize", debouncedResize);
// 绘制媒体内容到 canvascover 策略) // 绘制媒体内容到 canvascover 策略)
const drawMediaToCanvas = () => { const drawMediaToCanvas = () => {
@ -672,12 +848,12 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight); ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
}; };
// 使用较高频率的定时器以保持背景连贯20ms ~= 50fps
const intervalId = setInterval(drawMediaToCanvas, 20); const intervalId = setInterval(drawMediaToCanvas, 20);
return () => { return () => {
clearInterval(intervalId); clearInterval(intervalId);
window.removeEventListener("resize", updateCanvasSize); window.removeEventListener("resize", debouncedResize);
clearTimeout(resizeTimeout);
}; };
}, [isVideo, idx]); }, [isVideo, idx]);
@ -717,7 +893,6 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
objectFit, objectFit,
}} }}
playsInline playsInline
autoPlay
loop={loopMode === "loop"} loop={loopMode === "loop"}
onClick={togglePlay} onClick={togglePlay}
/> />
@ -761,15 +936,30 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
</div> </div>
)} )}
{/* 暂停状态时显示的播放图标 */}
{!isPlaying && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<div className="w-20 h-20 rounded-full bg-black/40 backdrop-blur-sm border border-white/30 flex items-center justify-center">
<Play size={40} className="text-white/90 ml-1" />
</div>
</div>
)}
{/* 统一控制条desc 在上、进度在下 */} {/* 统一控制条desc 在上、进度在下 */}
<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="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"> <div className="pointer-events-none flex items-center gap-2.5 mb-1">
<img src={data.author.avatar_url!} alt="" className="w-8 h-8 rounded-full" /> <img src={data.author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
<span 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"> <span className="text-[15px] leading-tight text-white/95 drop-shadow">
{data.author.nickname} {data.author.nickname}
</span> </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(data.created_at).toLocaleString()}>
{formatRelativeTime(data.created_at)}
</span>
</div> </div>
{data.desc ? ( {data.desc ? (
@ -824,41 +1014,20 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
</div> </div>
)} )}
{/* 控制按钮行 */} {/* 控制按钮行 - 响应式布局 */}
<div className="flex items-center justify-between gap-2.5"> <div className="flex items-center justify-between gap-1.5 sm:gap-2.5">
<div className="inline-flex items-center gap-2"> {/* 左侧:播放控制 + 时间/进度 */}
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
<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" 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={togglePlay} onClick={togglePlay}
aria-label={isPlaying ? "暂停" : "播放"} aria-label={isPlaying ? "暂停" : "播放"}
> >
{isPlaying ? <Pause size={18} /> : <Play size={18} />} {isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button> </button>
{/* 播放进度显示 - 所有设备都显示 */}
{/* 倍速仅视频展示 */} <div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
{isVideo ? (
<>
<button
className="h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
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];
setRate(next);
}}
aria-label="切换倍速"
>
{rate}x
</button>
{/* 旋转:向左/向右各 90° */}
</>
) : null}
{/* 播放进度显示 */}
<div className="text-[13px] text-white/90 font-mono min-w-[80px] ml-2">
{isVideo ? ( {isVideo ? (
(() => { (() => {
const v = videoRef.current; const v = videoRef.current;
@ -870,11 +1039,29 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
`${idx + 1} / ${totalSegments}` `${idx + 1} / ${totalSegments}`
)} )}
</div> </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];
setRate(next);
}}
aria-label="切换倍速"
>
{rate}x
</button>
)}
</div> </div>
<div className="inline-flex items-center gap-2"> {/* 中间:音量控制 - 中等屏幕以上显示 */}
<div className="hidden md:inline-flex items-center gap-2 shrink-0">
{/* 旋转按钮 - 小屏幕以上显示 */}
<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" 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={() => setRotation((r) => (r + 270) % 360)} onClick={() => setRotation((r) => (r + 270) % 360)}
aria-label="向左旋转 90 度" aria-label="向左旋转 90 度"
title="向左旋转 90 度" title="向左旋转 90 度"
@ -895,11 +1082,11 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
step={0.05} step={0.05}
value={volume} value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))} onChange={(e) => setVolume(parseFloat(e.target.value))}
className="w-28 accent-white cursor-pointer" className="w-20 lg:w-28 accent-white cursor-pointer"
aria-label="音量" aria-label="音量"
/> />
<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" 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={() => setRotation((r) => (r + 90) % 360)} onClick={() => setRotation((r) => (r + 90) % 360)}
aria-label="向右旋转 90 度" aria-label="向右旋转 90 度"
title="向右旋转 90 度" title="向右旋转 90 度"
@ -908,32 +1095,49 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
</button> </button>
</div> </div>
<div className="inline-flex items-center gap-2"> {/* 右侧:功能按钮组 */}
{/* 循环模式切换 */} <div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
<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" 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={() => setVolume((v) => (v > 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={() => setLoopMode((m) => (m === "loop" ? "sequential" : "loop"))} onClick={() => setLoopMode((m) => (m === "loop" ? "sequential" : "loop"))}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"} aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
title={loopMode === "loop" ? "循环播放" : "顺序播放"} title={loopMode === "loop" ? "循环播放" : "顺序播放"}
> >
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />} {loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
</button> </button>
{/* 适配模式 - 小屏幕以上显示 */}
<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" 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={() => setObjectFit((f) => (f === "contain" ? "cover" : "contain"))} onClick={() => setObjectFit((f) => (f === "contain" ? "cover" : "contain"))}
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"} aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"} title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
> >
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />} {objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
</button> </button>
{/* 下载 - 中等屏幕以上显示 */}
<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" 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={handleDownload} onClick={handleDownload}
aria-label={isVideo ? "下载视频" : "下载当前图片"} aria-label={isVideo ? "下载视频" : "下载当前图片"}
title={isVideo ? "下载视频" : "下载当前图片"} title={isVideo ? "下载视频" : "下载当前图片"}
> >
<Download size={18} /> <Download size={18} />
</button> </button>
{/* 全屏 - 所有设备都显示 */}
<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" 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={toggleFullscreen} onClick={toggleFullscreen}
@ -994,65 +1198,63 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
</div> </div>
</section> </section>
{/* 评论面板:竖屏 bottom sheet横屏并排分栏 */} {/* 横屏评论面板:并排分栏 */}
<aside className={asideClasses}> <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"> <div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
{/* 竖屏:评论在左,关闭按钮在右 */}
<div className="text-white font-semibold portrait:order-1 landscape:order-2">
{comments.length > 0 ? `(${comments.length})` : ""}
</div>
<button <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 portrait:order-2 landscape:order-1" 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={() => setOpen(false)} onClick={() => setOpen(false)}
aria-label="关闭评论" aria-label="关闭评论"
> >
<X size={18} /> <X size={18} />
</button> </button>
<div className="text-white font-semibold">
{comments.length > 0 ? `(${comments.length})` : ""}
</div>
</div> </div>
<div className="p-3 overflow-auto"> <div className="p-3 overflow-auto">
<header className="flex items-center gap-4 mb-5"> {commentContent}
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
{data.author.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={data.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">{data.author.nickname}</div>
<div className="text-xs text-white/50"> {new Date(data.created_at).toLocaleString()}</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 ? (
// eslint-disable-next-line @next/next/no-img-element
<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">{new Date(c.created_at).toLocaleString()}</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>
</div> </div>
</aside> </aside>
</div> </div>
{/* 竖屏评论面板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={() => setOpen(false)}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div className="p-3 overflow-auto">
{commentContent}
</div>
</aside>
</div> </div>
); );
} }

View File

@ -26,7 +26,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="zh-CN">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >

View File

@ -81,7 +81,7 @@ export default function TasksPage() {
controllers.current.set(task.id, ctrl); controllers.current.set(task.id, ctrl);
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t)); setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t));
try { try {
const res = await fetch(`/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" }); const res = await fetch(`/api/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {