@@ -0,0 +1,105 @@
|
||||
import { RELATED_DISPLAY_COUNT } from "@/components/drama-detail/data";
|
||||
import { pickVideoCardTags } from "@/components/common/card-tags";
|
||||
import { formatHeat } from "@/components/ranking/data";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { mapVideoListItem } from "@/components/video/data";
|
||||
import type {
|
||||
QueryVideoInfoResp,
|
||||
VideoData,
|
||||
VideoDetailInfo,
|
||||
VideoRecommendListResp,
|
||||
} from "@/lib/api/types";
|
||||
import { resolvePlayURL } from "@/lib/media/playback";
|
||||
|
||||
/** 详情页:直接使用接口 videoInfo 字段 */
|
||||
export type VideoDetail = VideoDetailInfo;
|
||||
|
||||
/** H5 / 侧栏热门推荐条数 */
|
||||
export const VIDEO_RECOMMEND_DISPLAY_COUNT = 10;
|
||||
|
||||
export type VideoPlayView = {
|
||||
detail: VideoDetail;
|
||||
src: string;
|
||||
related: ShortDramaItem[];
|
||||
};
|
||||
|
||||
function mapHotVideoItem(item: VideoData): ShortDramaItem {
|
||||
return {
|
||||
kind: "drama",
|
||||
id: String(item.id),
|
||||
slug: item.slug?.trim() || undefined,
|
||||
title: (item.title || "").trim() || "未命名",
|
||||
subtitle: "",
|
||||
tags: pickVideoCardTags(item),
|
||||
heat: formatHeat(item.watchNum),
|
||||
episodes: "",
|
||||
cover: (item.coverImg || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 热门推荐:保留广告位;排除当前视频 */
|
||||
export function mapVideoRecommendList(
|
||||
data: VideoRecommendListResp | null | undefined,
|
||||
options?: { excludeId?: number | string | null; limit?: number },
|
||||
): ShortDramaItem[] {
|
||||
const excludeId = options?.excludeId != null ? Number(options.excludeId) : NaN;
|
||||
const limit = options?.limit ?? VIDEO_RECOMMEND_DISPLAY_COUNT;
|
||||
return (data?.list ?? [])
|
||||
.filter((item) => {
|
||||
if (item.type === 2) return true;
|
||||
if (!item.id) return false;
|
||||
if (Number.isFinite(excludeId) && Number(item.id) === excludeId) return false;
|
||||
return true;
|
||||
})
|
||||
.map(mapVideoListItem)
|
||||
.filter((item): item is ShortDramaItem => Boolean(item))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** 透传接口字段,仅拼播放地址 / 根级点赞收藏态 */
|
||||
export function mapVideoInfo(data: QueryVideoInfoResp | null | undefined): VideoPlayView | null {
|
||||
const info = data?.videoInfo;
|
||||
if (!info?.id) return null;
|
||||
|
||||
const media = info.video;
|
||||
const enableH265 = Boolean(data?.config?.enableH265);
|
||||
const path = (enableH265 && media?.h265Url?.trim() ? media.h265Url : media?.url) || media?.h265Url || "";
|
||||
const src = resolvePlayURL(path);
|
||||
|
||||
return {
|
||||
detail: {
|
||||
...info,
|
||||
isLike: Boolean(data?.isLike ?? info.isLike),
|
||||
isFavorite: Boolean(data?.isFavorite ?? info.isFavorite),
|
||||
},
|
||||
src,
|
||||
related: (data?.hotVideoList ?? [])
|
||||
.filter((item) => Boolean(item.id))
|
||||
.slice(0, RELATED_DISPLAY_COUNT)
|
||||
.map(mapHotVideoItem),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatWatchNum(count?: number | null) {
|
||||
const value = Number(count) || 0;
|
||||
if (value >= 10000) {
|
||||
const wan = value / 10000;
|
||||
const text = wan >= 100 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, "");
|
||||
return `${text}万次播放`;
|
||||
}
|
||||
return `${value}次播放`;
|
||||
}
|
||||
|
||||
export function formatCount(count?: number | null) {
|
||||
const value = Number(count) || 0;
|
||||
if (value >= 10000) {
|
||||
const wan = value / 10000;
|
||||
const text = wan >= 100 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, "");
|
||||
return `${text}万`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
const k = value / 1000;
|
||||
return `${k.toFixed(1).replace(/\.0$/, "")}k`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { BackButton } from "@/components/common/back-button";
|
||||
import { AdBanners, AdIcons } from "@/components/common/ads";
|
||||
import { H5CommentsTab, useComments, type CommentTarget } from "@/components/common/comments";
|
||||
import { XgPlayer } from "@/components/player/xg-player";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { tagPageHref } from "@/components/tag/href";
|
||||
import { H5VideoCard } from "@/components/video/video-card";
|
||||
|
||||
import { formatCount, formatWatchNum, type VideoDetail } from "./data";
|
||||
|
||||
type Tab = "intro" | "comments";
|
||||
|
||||
type StatsProps = {
|
||||
liked: boolean;
|
||||
/** 接口 likeNum:点赞数 */
|
||||
likeNum?: number;
|
||||
collected: boolean;
|
||||
/** 接口 countCollect:收藏数 */
|
||||
countCollect?: number;
|
||||
onLike: () => void;
|
||||
onCollect: () => void;
|
||||
onShare: () => void;
|
||||
};
|
||||
|
||||
function H5VideoPlayer({
|
||||
detail,
|
||||
src,
|
||||
onPlaySuccess,
|
||||
}: {
|
||||
detail: VideoDetail;
|
||||
src: string;
|
||||
onPlaySuccess?: () => void;
|
||||
}) {
|
||||
// 上下文由 VideoPlayPage 统一设置;此处仅绑定播放器行为
|
||||
return (
|
||||
<div className="sticky top-0 z-20 aspect-video w-full overflow-hidden bg-black">
|
||||
<XgPlayer
|
||||
src={src}
|
||||
poster={detail.coverImg || ""}
|
||||
onPlaySuccess={onPlaySuccess}
|
||||
analyticsKind="video"
|
||||
clickToPlay
|
||||
className="absolute inset-0"
|
||||
options={{ ignores: ["cssfullscreen"], fullscreen: { rotateFullscreen: true } }}
|
||||
/>
|
||||
<BackButton fallback="/video/hot" className="absolute top-3 left-4 z-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntroTab({
|
||||
detail,
|
||||
related,
|
||||
liked,
|
||||
likeNum,
|
||||
collected,
|
||||
countCollect,
|
||||
onLike,
|
||||
onCollect,
|
||||
onShare,
|
||||
adGrid,
|
||||
bannerAds,
|
||||
}: StatsProps & {
|
||||
detail: VideoDetail;
|
||||
related: ShortDramaItem[];
|
||||
adGrid?: ReactNode;
|
||||
bannerAds?: ReactNode;
|
||||
}) {
|
||||
const tags = (detail.tags ?? []).filter((tag) =>
|
||||
Boolean(tag.name?.trim() || tag.slug?.trim() || tag.id),
|
||||
);
|
||||
const likeCount = likeNum ?? detail.likeNum;
|
||||
const collectCount = countCollect ?? detail.countCollect;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[18px] bg-[#010406] px-4 py-3">
|
||||
<h1 className="line-clamp-2 text-sm leading-5 font-medium text-white/80">{detail.title}</h1>
|
||||
{detail.desc?.trim() ? (
|
||||
<p className="text-xs leading-[18px] text-white/80">{detail.desc}</p>
|
||||
) : null}
|
||||
|
||||
{tags.length ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tags.map((tag) => {
|
||||
const name = (tag.tagName || tag.name || "").trim() || tag.slug?.trim() || "";
|
||||
if (!name) return null;
|
||||
const canLink = Boolean(tag.slug?.trim() || tag.id != null);
|
||||
return canLink ? (
|
||||
<Link
|
||||
key={tag.slug || tag.id || name}
|
||||
href={tagPageHref({ slug: tag.slug, id: tag.id }, "video")}
|
||||
className="rounded-[3px] bg-white/10 px-1.5 py-0.5 text-xs leading-[18px] text-white/90 hover:text-[#F12B9E]"
|
||||
>
|
||||
#{name}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
key={name}
|
||||
className="rounded-[3px] bg-white/10 px-1.5 py-0.5 text-xs leading-[18px] text-white/90"
|
||||
>
|
||||
#{name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between text-xs leading-[18px]">
|
||||
<span className="text-[#BDBDBD]">{formatWatchNum(detail.watchNum)}</span>
|
||||
<div className="flex items-center gap-3 text-[#BDBDBD]">
|
||||
<button type="button" aria-pressed={liked} onClick={onLike} className="cursor-pointer flex items-center gap-0.5">
|
||||
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
|
||||
<path
|
||||
d="M8 14c-.2 0-.4-.07-.56-.2C5.3 12 2 9.7 2 6.6 2 4.6 3.4 3 5.2 3c1.1 0 2.1.6 2.8 1.5C8.7 3.6 9.7 3 10.8 3 12.6 3 14 4.6 14 6.6c0 3.1-3.3 5.4-5.44 7.2-.16.13-.36.2-.56.2Z"
|
||||
fill={liked ? "#F52C56" : "none"}
|
||||
stroke="#F52C56"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
{formatCount(likeCount)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={collected}
|
||||
onClick={onCollect}
|
||||
className="cursor-pointer flex items-center gap-0.5"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
|
||||
<path
|
||||
d="m8 2 1.8 3.7 4 .6-2.9 2.8.7 4L8 11.2l-3.6 1.9.7-4-2.9-2.8 4-.6L8 2Z"
|
||||
fill={collected ? "#FFC53D" : "none"}
|
||||
stroke="#FFC53D"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{formatCount(collectCount)}
|
||||
</button>
|
||||
<button type="button" onClick={onShare} className="cursor-pointer flex items-center gap-0.5">
|
||||
<img src="/images/share_h5.svg" alt="" className="size-4" />
|
||||
分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* icon 广告 → 热门推荐 → banner 广告 */}
|
||||
{adGrid ?? <AdIcons variant="h5" />}
|
||||
|
||||
{related.length ? (
|
||||
<section className="flex flex-col items-center gap-3">
|
||||
<h2 className="w-full text-lg leading-6 font-semibold text-white">热门推荐</h2>
|
||||
<div className="grid w-full grid-cols-2 gap-x-[7px] gap-y-3">
|
||||
{related.map((item) => (
|
||||
<H5VideoCard key={item.id} item={item} showEpisodes={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{bannerAds ?? <AdBanners variant="h5" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function H5VideoPlay({
|
||||
detail,
|
||||
src,
|
||||
related = [],
|
||||
commentTarget,
|
||||
onPlaySuccess,
|
||||
adGrid,
|
||||
bannerAds,
|
||||
...stats
|
||||
}: StatsProps & {
|
||||
detail: VideoDetail;
|
||||
src: string;
|
||||
related?: ShortDramaItem[];
|
||||
commentTarget: CommentTarget | null;
|
||||
onPlaySuccess?: () => void;
|
||||
adGrid?: ReactNode;
|
||||
bannerAds?: ReactNode;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("intro");
|
||||
const comments = useComments({ target: commentTarget, initialSort: "最新" });
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: "intro", label: "简介" },
|
||||
{ key: "comments", label: `评论(${comments.total})` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div data-component="video-h5-play" className="flex flex-col bg-[#010406] lg:hidden">
|
||||
<H5VideoPlayer detail={detail} src={src} onPlaySuccess={onPlaySuccess} />
|
||||
<div role="tablist" className="flex gap-3 px-4 pt-[18px]">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === item.key}
|
||||
onClick={() => setTab(item.key)}
|
||||
className={`cursor-pointer text-base leading-[22px] ${tab === item.key ? "font-medium text-white" : "text-[#8D8E8F]"}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "intro" ? (
|
||||
<IntroTab detail={detail} related={related} adGrid={adGrid} bannerAds={bannerAds} {...stats} />
|
||||
) : (
|
||||
<H5CommentsTab
|
||||
state={comments}
|
||||
footerExtra={
|
||||
bannerAds ? <div className="px-4 py-3">{bannerAds}</div> : <AdBanners variant="h5" className="px-4 py-3" />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { videoHref } from "@/components/video/video-card";
|
||||
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
|
||||
|
||||
export function VideoRelatedAside({ items }: { items: ShortDramaItem[] }) {
|
||||
const list = items.filter((item) => item.kind === "drama");
|
||||
if (!list.length) return null;
|
||||
|
||||
return (
|
||||
<aside data-component="video-related-aside" className="flex min-w-0 flex-1 flex-col gap-4 rounded-2xl bg-[rgba(24,23,46,0.88)] p-6 outline outline-1 -outline-offset-1 outline-[#383657]">
|
||||
<div className="flex h-9 items-center justify-between">
|
||||
<h2 className="text-[22px] font-bold text-white">热门推荐</h2>
|
||||
<Link href="/video/hot" className="flex items-center gap-1 text-xs leading-[18px] text-[#B8B2C2] hover:text-white">
|
||||
更多
|
||||
<img src="/images/more_arrow.svg" alt="" className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
{list.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href || videoHref(item.id, item.slug)}
|
||||
className="group flex h-24 items-center gap-4"
|
||||
>
|
||||
<DecryptedImage
|
||||
src={item.cover}
|
||||
fallback={IMG_PLACEHOLDER}
|
||||
alt=""
|
||||
className="h-24 w-[128px] shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<h3 className="line-clamp-2 text-base leading-[23px] text-[#F5F0FA] group-hover:text-[#F12B9E]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between text-xs leading-[18px]">
|
||||
<span className="truncate text-[#A89EB5]">{item.tags[0]?.name}</span>
|
||||
<span className="shrink-0 text-[#ABA1B8]">{item.heat}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import { reportVideoHistory } from "@/lib/api/video-actions";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
|
||||
async function ensureToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 播放成功后上报观看历史;同一视频 id 只报一次 */
|
||||
export function useReportVideoHistory(videoId: number) {
|
||||
const reportedIdRef = useRef<number | null>(null);
|
||||
|
||||
return useCallback(async () => {
|
||||
if (!Number.isInteger(videoId) || videoId < 1) return;
|
||||
if (reportedIdRef.current === videoId) return;
|
||||
reportedIdRef.current = videoId;
|
||||
|
||||
const token = await ensureToken();
|
||||
if (!token) {
|
||||
reportedIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await reportVideoHistory({ id: videoId }, token);
|
||||
} catch (error) {
|
||||
reportedIdRef.current = null;
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[video/history] report failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}, [videoId]);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "@heroui/react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { useVideoContext } from "@/components/analytics/use-playback-analytics";
|
||||
import { openLoginDialog } from "@/components/auth/login-dialog";
|
||||
import { AdBanners, AdIcons } from "@/components/common/ads";
|
||||
import { CommentPanel, videoCommentTarget } from "@/components/common/comments";
|
||||
import { DecryptedImage } from "@/components/common/decrypted-image";
|
||||
import { ActionPill } from "@/components/drama-play/drama-play-page";
|
||||
import { PcPlayer } from "@/components/drama-play/pc-player";
|
||||
import { FloatingActions } from "@/components/home/floating-actions";
|
||||
import { PageBreadcrumb } from "@/components/seo/page-breadcrumb";
|
||||
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
|
||||
import type { ShortDramaItem } from "@/components/short-drama/data";
|
||||
import { favoriteVideo, getVideoInfo, likeVideo } from "@/lib/api/video-actions";
|
||||
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
|
||||
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
|
||||
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
|
||||
|
||||
import type { VideoDetail } from "./data";
|
||||
import { H5VideoPlay } from "./h5-video-play";
|
||||
import { VideoRelatedAside } from "./related-aside";
|
||||
import { useReportVideoHistory } from "./use-report-video-history";
|
||||
|
||||
async function ensureVideoAuthToken() {
|
||||
let token = getAuthToken();
|
||||
if (!token) {
|
||||
try {
|
||||
await ensureGuestSession();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
token = getAuthToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveVideoLikeFavorite(data: Awaited<ReturnType<typeof getVideoInfo>> | null | undefined) {
|
||||
const info = data?.videoInfo;
|
||||
return {
|
||||
isLike: Boolean(data?.isLike ?? info?.isLike),
|
||||
isFavorite: Boolean(data?.isFavorite ?? info?.isFavorite),
|
||||
likeNum: Number(info?.likeNum) || 0,
|
||||
countCollect: Number(info?.countCollect) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function VideoPlayPage({
|
||||
detail,
|
||||
src,
|
||||
related = [],
|
||||
relatedAside,
|
||||
promo,
|
||||
adIcons,
|
||||
adGrid,
|
||||
bannerAds,
|
||||
}: {
|
||||
detail: VideoDetail;
|
||||
src: string;
|
||||
related?: ShortDramaItem[];
|
||||
relatedAside?: ReactNode;
|
||||
promo?: ReactNode;
|
||||
adIcons?: ReactNode;
|
||||
adGrid?: ReactNode;
|
||||
bannerAds?: ReactNode;
|
||||
}) {
|
||||
const [liked, setLiked] = useState(Boolean(detail.isLike));
|
||||
const [likeNum, setLikeNum] = useState(Number(detail.likeNum) || 0);
|
||||
const [liking, setLiking] = useState(false);
|
||||
const [collected, setCollected] = useState(Boolean(detail.isFavorite));
|
||||
const [countCollect, setCountCollect] = useState(Number(detail.countCollect) || 0);
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
const reportWatchHistory = useReportVideoHistory(Number(detail.id));
|
||||
const tagName = (detail.tags?.[0]?.tagName || detail.tags?.[0]?.name || "").trim();
|
||||
const videoTrackContext = useMemo(
|
||||
() => ({
|
||||
media_id: String(detail.id || ""),
|
||||
video_id: String(detail.id || ""),
|
||||
video_title: detail.title || "未命名视频",
|
||||
video_type_id: "0",
|
||||
video_type_name: "未分类",
|
||||
video_content_type: "video",
|
||||
recommend_trace_id: "",
|
||||
video_tag_key: tagName ? "tag" : "default",
|
||||
video_tag_name: tagName || "默认标签",
|
||||
video_duration: Math.max(0, Math.round(Number(detail.video?.videoTime) || 0)),
|
||||
}),
|
||||
[detail.id, detail.title, detail.video?.videoTime, tagName],
|
||||
);
|
||||
useVideoContext(videoTrackContext);
|
||||
|
||||
const likeFavoriteKey = `${detail.id}:${detail.isLike}:${detail.likeNum}:${detail.isFavorite}:${detail.countCollect}`;
|
||||
const [likeFavoriteSync, setLikeFavoriteSync] = useState(likeFavoriteKey);
|
||||
if (likeFavoriteSync !== likeFavoriteKey) {
|
||||
setLikeFavoriteSync(likeFavoriteKey);
|
||||
setLiked(Boolean(detail.isLike));
|
||||
setLikeNum(Number(detail.likeNum) || 0);
|
||||
setCollected(Boolean(detail.isFavorite));
|
||||
setCountCollect(Number(detail.countCollect) || 0);
|
||||
}
|
||||
|
||||
// SSR 详情走 "use cache" 无登录态;客户端带 token 再拉一次,纠正已点赞 / 已收藏
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const token = await ensureVideoAuthToken();
|
||||
if (!token || cancelled) return;
|
||||
try {
|
||||
const data = await getVideoInfo(
|
||||
detail.slug?.trim() ? { slug: detail.slug.trim() } : { id: detail.id },
|
||||
token,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const status = resolveVideoLikeFavorite(data);
|
||||
setLiked(status.isLike);
|
||||
setCollected(status.isFavorite);
|
||||
setLikeNum(status.likeNum);
|
||||
setCountCollect(status.countCollect);
|
||||
} catch (error) {
|
||||
console.warn("[video-play/status]", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detail.id, detail.slug]);
|
||||
|
||||
const share = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
toast.success("链接已复制");
|
||||
} catch {
|
||||
toast.danger("复制失败,请稍后重试");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (liking) return;
|
||||
const videoId = Number(detail.id);
|
||||
if (!Number.isInteger(videoId) || videoId < 1) return;
|
||||
|
||||
const token = await ensureVideoAuthToken();
|
||||
if (!token) {
|
||||
openLoginDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = !liked;
|
||||
setLiked(next);
|
||||
setLikeNum((value) => Math.max(0, value + (next ? 1 : -1)));
|
||||
setLiking(true);
|
||||
try {
|
||||
const result = await likeVideo({ id: videoId, isLike: next }, token);
|
||||
setLiked(Boolean(result?.isLike ?? next));
|
||||
if (typeof result?.likeNum === "number") setLikeNum(result.likeNum);
|
||||
} catch (error) {
|
||||
setLiked(!next);
|
||||
setLikeNum((value) => Math.max(0, value + (next ? -1 : 1)));
|
||||
handleUnauthorized(error);
|
||||
} finally {
|
||||
setLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCollect = async () => {
|
||||
if (collecting) return;
|
||||
const videoId = Number(detail.id);
|
||||
if (!Number.isInteger(videoId) || videoId < 1) return;
|
||||
|
||||
const token = await ensureVideoAuthToken();
|
||||
if (!token) {
|
||||
openLoginDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = !collected;
|
||||
setCollected(next);
|
||||
setCountCollect((value) => Math.max(0, value + (next ? 1 : -1)));
|
||||
setCollecting(true);
|
||||
try {
|
||||
const result = await favoriteVideo({ id: videoId, isFavorite: next }, token);
|
||||
setCollected(Boolean(result?.isFavorite ?? next));
|
||||
if (typeof result?.countCollect === "number") {
|
||||
setCountCollect(result.countCollect);
|
||||
} else {
|
||||
// 响应无 countCollect 时再拉详情同步
|
||||
try {
|
||||
const data = await getVideoInfo(
|
||||
detail.slug?.trim() ? { slug: detail.slug.trim() } : { id: detail.id },
|
||||
token,
|
||||
);
|
||||
const num = Number(data?.videoInfo?.countCollect);
|
||||
if (Number.isFinite(num) && num >= 0) setCountCollect(num);
|
||||
} catch {
|
||||
/* 保留乐观值 */
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setCollected(!next);
|
||||
setCountCollect((value) => Math.max(0, value + (next ? -1 : 1)));
|
||||
handleUnauthorized(error);
|
||||
} finally {
|
||||
setCollecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main data-component="video-play-page" className="relative flex-1 bg-[#0B0709]">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 hidden h-[1200px] overflow-hidden [mask-image:linear-gradient(180deg,#000_70%,transparent_100%)] lg:block"
|
||||
>
|
||||
<DecryptedImage
|
||||
src={detail.coverImg}
|
||||
fallback={IMG_PLACEHOLDER}
|
||||
alt=""
|
||||
className="size-full scale-110 object-cover opacity-70 blur-[12px]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(9,5,9,0.32)_0%,rgba(9,5,9,0.56)_55%,rgba(6,5,9,0.94)_100%)]" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-pc-scale
|
||||
className="relative mx-auto hidden w-[1700px] flex-col gap-6 pt-6 pb-8 [zoom:var(--page-scale,1)] lg:flex"
|
||||
>
|
||||
<SeoBreadcrumb
|
||||
items={[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: detail.title || "视频详情" },
|
||||
]}
|
||||
/>
|
||||
<PageBreadcrumb
|
||||
items={[
|
||||
{ name: "首页", href: "/" },
|
||||
{ name: detail.title || "视频详情" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<PcPlayer
|
||||
src={src}
|
||||
poster={detail.coverImg || ""}
|
||||
title={detail.title || ""}
|
||||
locked={false}
|
||||
dramaTitle={detail.title || ""}
|
||||
layout="wide"
|
||||
onPlaySuccess={reportWatchHistory}
|
||||
analyticsKind="video"
|
||||
/>
|
||||
|
||||
{adIcons ?? <AdIcons variant="pc" labels={false} />}
|
||||
|
||||
<section className="flex h-[140px] items-center justify-between gap-10 rounded-xl bg-[rgba(19,15,22,0.9)] px-6 py-5 outline outline-1 -outline-offset-1 outline-[rgba(199,145,89,0.7)]">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<p className="truncate text-[28px] leading-[34px] font-semibold text-white">{detail.title}</p>
|
||||
<p className="line-clamp-2 text-xs leading-[18px] text-[#9991AB]">{detail.desc}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-3">
|
||||
<ActionPill
|
||||
icon="/images/icon_like.svg"
|
||||
label={liked ? "已点赞" : "点赞"}
|
||||
active={liked}
|
||||
onClick={liking ? undefined : toggleLike}
|
||||
/>
|
||||
<ActionPill
|
||||
icon="/images/icon_collect.svg"
|
||||
label={collected ? "已收藏" : "收藏"}
|
||||
active={collected}
|
||||
onClick={collecting ? undefined : toggleCollect}
|
||||
/>
|
||||
<ActionPill icon="/images/icon_share.svg" label="分享" onClick={share} />
|
||||
<ActionPill icon="/images/icon_feedback.svg" label="反馈" href="/feedback" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-start gap-6">
|
||||
<CommentPanel variant="play" target={videoCommentTarget(detail.id)} />
|
||||
{relatedAside ?? <VideoRelatedAside items={related} />}
|
||||
</div>
|
||||
|
||||
{promo ?? <AdBanners variant="pc" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<H5VideoPlay
|
||||
detail={detail}
|
||||
src={src}
|
||||
related={related}
|
||||
liked={liked}
|
||||
likeNum={likeNum}
|
||||
collected={collected}
|
||||
countCollect={countCollect}
|
||||
onLike={liking ? () => undefined : toggleLike}
|
||||
onCollect={collecting ? () => undefined : toggleCollect}
|
||||
onShare={share}
|
||||
commentTarget={videoCommentTarget(detail.id)}
|
||||
onPlaySuccess={reportWatchHistory}
|
||||
adGrid={adGrid}
|
||||
bannerAds={bannerAds}
|
||||
/>
|
||||
|
||||
<div className="max-lg:hidden">
|
||||
<FloatingActions />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user