增加动漫数据源配置
This commit is contained in:
+746
-399
@@ -1,10 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar, Clock, ExternalLink, Film, Globe, Images, Star, Tag, Users, X } from 'lucide-react';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Film,
|
||||
Globe,
|
||||
Images,
|
||||
Star,
|
||||
Tag,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getBangumiSubject } from '@/lib/bangumi.client';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
@@ -105,9 +117,14 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const [detailData, setDetailData] = useState<DetailData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [seasonData, setSeasonData] = useState<{ seasons: any[]; episodes: Episode[] } | null>(null);
|
||||
const [seasonData, setSeasonData] = useState<{
|
||||
seasons: any[];
|
||||
episodes: Episode[];
|
||||
} | null>(null);
|
||||
const [loadingSeasons, setLoadingSeasons] = useState(false);
|
||||
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(new Set());
|
||||
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
||||
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
@@ -122,12 +139,16 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
|
||||
const galleryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
// 数据源状态管理
|
||||
const [currentSource, setCurrentSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
|
||||
const [originalSource, setOriginalSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
|
||||
const [currentSource, setCurrentSource] = useState<
|
||||
'douban' | 'bangumi' | 'cms' | 'tmdb'
|
||||
>('tmdb');
|
||||
const [originalSource, setOriginalSource] = useState<
|
||||
'douban' | 'bangumi' | 'cms' | 'tmdb'
|
||||
>('tmdb');
|
||||
const [isUsingTmdb, setIsUsingTmdb] = useState(false);
|
||||
const [originalDetailData, setOriginalDetailData] = useState<DetailData | null>(null);
|
||||
const [originalDetailData, setOriginalDetailData] =
|
||||
useState<DetailData | null>(null);
|
||||
|
||||
const getExternalUrl = () => {
|
||||
if (currentSource === 'douban' && doubanId) {
|
||||
@@ -387,14 +408,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
if (sourceId && source) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/source-detail?id=${encodeURIComponent(sourceId)}&source=${encodeURIComponent(source)}&title=${encodeURIComponent(title)}`
|
||||
`/api/source-detail?id=${encodeURIComponent(
|
||||
sourceId
|
||||
)}&source=${encodeURIComponent(
|
||||
source
|
||||
)}&title=${encodeURIComponent(title)}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const detailData = {
|
||||
title: data.title || title,
|
||||
intro: data.desc || '',
|
||||
episodesCount: data.episodes?.length || cmsData.episodes?.length,
|
||||
episodesCount:
|
||||
data.episodes?.length || cmsData.episodes?.length,
|
||||
poster: data.poster || poster,
|
||||
year: data.year,
|
||||
};
|
||||
@@ -415,11 +441,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
setCurrentSource('bangumi');
|
||||
setOriginalSource('bangumi');
|
||||
const actualBangumiId = bangumiId || doubanId;
|
||||
const response = await fetch(`https://api.bgm.tv/v0/subjects/${actualBangumiId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取Bangumi详情失败');
|
||||
if (!actualBangumiId) {
|
||||
throw new Error('Bangumi ID 缺失');
|
||||
}
|
||||
const data = await response.json();
|
||||
const data = await getBangumiSubject(actualBangumiId);
|
||||
|
||||
const detailData = {
|
||||
title: data.name_cn || data.name,
|
||||
@@ -517,10 +542,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const seasonStr = match[1];
|
||||
// 中文数字转数字
|
||||
const chineseNumbers: Record<string, number> = {
|
||||
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
|
||||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
|
||||
一: 1,
|
||||
二: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9,
|
||||
十: 10,
|
||||
};
|
||||
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
||||
extractedSeasonNumber =
|
||||
chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -540,7 +574,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const mediaType = result.media_type || type;
|
||||
|
||||
// 获取详情
|
||||
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
|
||||
const detailResponse = await fetch(
|
||||
`/api/tmdb/detail?id=${detailId}&type=${mediaType}`
|
||||
);
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error('获取TMDB详情失败');
|
||||
}
|
||||
@@ -566,17 +602,26 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
mediaType === 'movie'
|
||||
? detailResult.title
|
||||
: seasonData?.name
|
||||
? `${detailResult.name} ${seasonData.name}`
|
||||
: detailResult.name,
|
||||
? `${detailResult.name} ${seasonData.name}`
|
||||
: detailResult.name,
|
||||
originalTitle:
|
||||
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
|
||||
mediaType === 'movie'
|
||||
? detailResult.original_title
|
||||
: detailResult.original_name,
|
||||
year:
|
||||
mediaType === 'movie'
|
||||
? detailResult.release_date?.substring(0, 4)
|
||||
: seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4),
|
||||
poster: (seasonData?.poster_path || detailResult.poster_path)
|
||||
? processImageUrl(getTMDBImageUrl(seasonData?.poster_path || detailResult.poster_path, 'w500'))
|
||||
: poster,
|
||||
: seasonData?.air_date?.substring(0, 4) ||
|
||||
detailResult.first_air_date?.substring(0, 4),
|
||||
poster:
|
||||
seasonData?.poster_path || detailResult.poster_path
|
||||
? processImageUrl(
|
||||
getTMDBImageUrl(
|
||||
seasonData?.poster_path || detailResult.poster_path,
|
||||
'w500'
|
||||
)
|
||||
)
|
||||
: poster,
|
||||
rating: detailResult.vote_average
|
||||
? {
|
||||
value: detailResult.vote_average,
|
||||
@@ -587,8 +632,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
genres: detailResult.genres?.map((g: any) => g.name),
|
||||
countries: detailResult.production_countries?.map((c: any) => c.name),
|
||||
languages: detailResult.spoken_languages?.map((l: any) => l.name),
|
||||
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
|
||||
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
|
||||
duration: detailResult.runtime
|
||||
? `${detailResult.runtime}分钟`
|
||||
: undefined,
|
||||
episodesCount:
|
||||
seasonData?.episodes?.length || detailResult.number_of_episodes,
|
||||
releaseDate:
|
||||
mediaType === 'movie'
|
||||
? detailResult.release_date
|
||||
@@ -609,7 +657,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
}, [isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source, isUsingTmdb]);
|
||||
}, [
|
||||
isOpen,
|
||||
doubanId,
|
||||
bangumiId,
|
||||
isBangumi,
|
||||
tmdbId,
|
||||
title,
|
||||
type,
|
||||
seasonNumber,
|
||||
poster,
|
||||
cmsData,
|
||||
sourceId,
|
||||
source,
|
||||
isUsingTmdb,
|
||||
]);
|
||||
|
||||
// 切换数据源的函数
|
||||
const handleToggleSource = async () => {
|
||||
@@ -664,10 +726,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const seasonStr = match[1];
|
||||
// 中文数字转数字
|
||||
const chineseNumbers: Record<string, number> = {
|
||||
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
|
||||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
|
||||
一: 1,
|
||||
二: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9,
|
||||
十: 10,
|
||||
};
|
||||
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
||||
extractedSeasonNumber =
|
||||
chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -687,7 +758,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const mediaType = result.media_type || type;
|
||||
|
||||
// 获取详情
|
||||
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
|
||||
const detailResponse = await fetch(
|
||||
`/api/tmdb/detail?id=${detailId}&type=${mediaType}`
|
||||
);
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error('获取TMDB详情失败');
|
||||
}
|
||||
@@ -713,17 +786,26 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
mediaType === 'movie'
|
||||
? detailResult.title
|
||||
: seasonData?.name
|
||||
? `${detailResult.name} ${seasonData.name}`
|
||||
: detailResult.name,
|
||||
? `${detailResult.name} ${seasonData.name}`
|
||||
: detailResult.name,
|
||||
originalTitle:
|
||||
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
|
||||
mediaType === 'movie'
|
||||
? detailResult.original_title
|
||||
: detailResult.original_name,
|
||||
year:
|
||||
mediaType === 'movie'
|
||||
? detailResult.release_date?.substring(0, 4)
|
||||
: seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4),
|
||||
poster: (seasonData?.poster_path || detailResult.poster_path)
|
||||
? processImageUrl(getTMDBImageUrl(seasonData?.poster_path || detailResult.poster_path, 'w500'))
|
||||
: poster,
|
||||
: seasonData?.air_date?.substring(0, 4) ||
|
||||
detailResult.first_air_date?.substring(0, 4),
|
||||
poster:
|
||||
seasonData?.poster_path || detailResult.poster_path
|
||||
? processImageUrl(
|
||||
getTMDBImageUrl(
|
||||
seasonData?.poster_path || detailResult.poster_path,
|
||||
'w500'
|
||||
)
|
||||
)
|
||||
: poster,
|
||||
rating: detailResult.vote_average
|
||||
? {
|
||||
value: detailResult.vote_average,
|
||||
@@ -734,8 +816,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
genres: detailResult.genres?.map((g: any) => g.name),
|
||||
countries: detailResult.production_countries?.map((c: any) => c.name),
|
||||
languages: detailResult.spoken_languages?.map((l: any) => l.name),
|
||||
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
|
||||
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
|
||||
duration: detailResult.runtime
|
||||
? `${detailResult.runtime}分钟`
|
||||
: undefined,
|
||||
episodesCount:
|
||||
seasonData?.episodes?.length || detailResult.number_of_episodes,
|
||||
releaseDate:
|
||||
mediaType === 'movie'
|
||||
? detailResult.release_date
|
||||
@@ -758,7 +843,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
// 异步获取季度和集数详情(仅TMDB)
|
||||
useEffect(() => {
|
||||
if (!detailData?.tmdbId || !detailData?.mediaType || detailData.mediaType !== 'tv' || seasonsLoaded) {
|
||||
if (
|
||||
!detailData?.tmdbId ||
|
||||
!detailData?.mediaType ||
|
||||
detailData.mediaType !== 'tv' ||
|
||||
seasonsLoaded
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -766,7 +856,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
setLoadingSeasons(true);
|
||||
try {
|
||||
// 获取所有季度
|
||||
const seasonsResponse = await fetch(`/api/tmdb/seasons?tvId=${detailData.tmdbId}`);
|
||||
const seasonsResponse = await fetch(
|
||||
`/api/tmdb/seasons?tvId=${detailData.tmdbId}`
|
||||
);
|
||||
if (!seasonsResponse.ok) return;
|
||||
const seasonsData = await seasonsResponse.json();
|
||||
|
||||
@@ -794,24 +886,36 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
};
|
||||
|
||||
fetchSeasonData();
|
||||
}, [detailData?.tmdbId, detailData?.mediaType, detailData?.seasonNumber, seasonsLoaded]);
|
||||
}, [
|
||||
detailData?.tmdbId,
|
||||
detailData?.mediaType,
|
||||
detailData?.seasonNumber,
|
||||
seasonsLoaded,
|
||||
]);
|
||||
|
||||
// 自动滚动到当前集数
|
||||
useEffect(() => {
|
||||
if (!currentEpisode || !seasonData?.episodes || !episodesScrollRef.current || currentSource !== 'tmdb') {
|
||||
if (
|
||||
!currentEpisode ||
|
||||
!seasonData?.episodes ||
|
||||
!episodesScrollRef.current ||
|
||||
currentSource !== 'tmdb'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待 DOM 更新后再滚动
|
||||
const timer = setTimeout(() => {
|
||||
const episodeElement = document.getElementById(`episode-${currentEpisode}`);
|
||||
const episodeElement = document.getElementById(
|
||||
`episode-${currentEpisode}`
|
||||
);
|
||||
if (episodeElement && episodesScrollRef.current) {
|
||||
// 计算滚动位置,使当前集数居中显示
|
||||
const container = episodesScrollRef.current;
|
||||
const elementLeft = episodeElement.offsetLeft;
|
||||
const elementWidth = episodeElement.offsetWidth;
|
||||
const containerWidth = container.offsetWidth;
|
||||
const scrollLeft = elementLeft - (containerWidth / 2) + (elementWidth / 2);
|
||||
const scrollLeft = elementLeft - containerWidth / 2 + elementWidth / 2;
|
||||
|
||||
container.scrollLeft = scrollLeft;
|
||||
}
|
||||
@@ -822,7 +926,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
// 异步获取演职人员信息(仅TMDB)
|
||||
useEffect(() => {
|
||||
if (!detailData?.tmdbId || !detailData?.mediaType || currentSource !== 'tmdb') {
|
||||
if (
|
||||
!detailData?.tmdbId ||
|
||||
!detailData?.mediaType ||
|
||||
currentSource !== 'tmdb'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -840,30 +948,39 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const creditsData = await creditsResponse.json();
|
||||
|
||||
// 更新演员和导演信息
|
||||
setDetailData(prev => prev ? {
|
||||
...prev,
|
||||
directors: creditsData.crew
|
||||
?.filter((person: any) => person.job === 'Director')
|
||||
.slice(0, 5)
|
||||
.map((person: any) => ({
|
||||
name: person.name,
|
||||
profile_path: person.profile_path,
|
||||
})) || prev.directors,
|
||||
actors: creditsData.cast
|
||||
?.slice(0, 15)
|
||||
.map((person: any) => ({
|
||||
name: person.name,
|
||||
character: person.character,
|
||||
profile_path: person.profile_path,
|
||||
})) || prev.actors,
|
||||
} : null);
|
||||
setDetailData((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
directors:
|
||||
creditsData.crew
|
||||
?.filter((person: any) => person.job === 'Director')
|
||||
.slice(0, 5)
|
||||
.map((person: any) => ({
|
||||
name: person.name,
|
||||
profile_path: person.profile_path,
|
||||
})) || prev.directors,
|
||||
actors:
|
||||
creditsData.cast?.slice(0, 15).map((person: any) => ({
|
||||
name: person.name,
|
||||
character: person.character,
|
||||
profile_path: person.profile_path,
|
||||
})) || prev.actors,
|
||||
}
|
||||
: null
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('获取演职人员信息失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCredits();
|
||||
}, [detailData?.tmdbId, detailData?.mediaType, currentSource, detailData?.actors]);
|
||||
}, [
|
||||
detailData?.tmdbId,
|
||||
detailData?.mediaType,
|
||||
currentSource,
|
||||
detailData?.actors,
|
||||
]);
|
||||
|
||||
// 切换季度时获取集数
|
||||
const handleSeasonChange = async (seasonNumber: number) => {
|
||||
@@ -879,25 +996,43 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const episodesData = await episodesResponse.json();
|
||||
|
||||
// 从当前 seasonData 中查找季度信息
|
||||
const season = seasonData?.seasons.find((s: any) => s.season_number === seasonNumber);
|
||||
const season = seasonData?.seasons.find(
|
||||
(s: any) => s.season_number === seasonNumber
|
||||
);
|
||||
|
||||
setSeasonData(prev => ({
|
||||
setSeasonData((prev) => ({
|
||||
seasons: prev?.seasons || [],
|
||||
episodes: episodesData.episodes || [],
|
||||
}));
|
||||
|
||||
// 更新季度元信息
|
||||
setDetailData(prev => prev ? {
|
||||
...prev,
|
||||
title: (episodesData.name || season?.name)
|
||||
? `${prev.seriesTitle || prev.title} ${episodesData.name || season?.name}`
|
||||
: prev.title,
|
||||
intro: episodesData.overview || season?.overview || prev.overview,
|
||||
poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster,
|
||||
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
|
||||
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
|
||||
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
|
||||
} : null);
|
||||
setDetailData((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
title:
|
||||
episodesData.name || season?.name
|
||||
? `${prev.seriesTitle || prev.title} ${
|
||||
episodesData.name || season?.name
|
||||
}`
|
||||
: prev.title,
|
||||
intro: episodesData.overview || season?.overview || prev.overview,
|
||||
poster: season?.poster_path
|
||||
? getTMDBImageUrl(season.poster_path, 'w500')
|
||||
: prev.poster,
|
||||
releaseDate:
|
||||
episodesData.air_date || season?.air_date || prev.releaseDate,
|
||||
year:
|
||||
episodesData.air_date?.substring(0, 4) ||
|
||||
season?.air_date?.substring(0, 4) ||
|
||||
prev.year,
|
||||
episodesCount:
|
||||
episodesData.episodes?.length ||
|
||||
season?.episode_count ||
|
||||
prev.episodesCount,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
setExpandedEpisodes(new Set());
|
||||
} catch (err) {
|
||||
@@ -1006,7 +1141,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const galleryEntryButton = canShowGalleryEntry ? (
|
||||
<button
|
||||
onClick={openGallery}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors"
|
||||
className='inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors'
|
||||
>
|
||||
<Images size={16} />
|
||||
照片墙
|
||||
@@ -1016,7 +1151,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const virtualGalleryLayout = React.useMemo(() => {
|
||||
if (galleryImages.length === 0 || galleryViewportWidth <= 0) {
|
||||
return {
|
||||
visibleItems: [] as Array<GalleryImage & { top: number; left: number; renderWidth: number; renderHeight: number; index: number }>,
|
||||
visibleItems: [] as Array<
|
||||
GalleryImage & {
|
||||
top: number;
|
||||
left: number;
|
||||
renderWidth: number;
|
||||
renderHeight: number;
|
||||
index: number;
|
||||
}
|
||||
>,
|
||||
totalHeight: 0,
|
||||
usedWidth: 0,
|
||||
};
|
||||
@@ -1026,8 +1169,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const overscan = 800;
|
||||
const horizontalPadding = 32;
|
||||
const width = Math.max(galleryViewportWidth - horizontalPadding, 0);
|
||||
const columnCount = width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
|
||||
const columnWidth = Math.floor((width - gap * (columnCount - 1)) / columnCount);
|
||||
const columnCount =
|
||||
width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
|
||||
const columnWidth = Math.floor(
|
||||
(width - gap * (columnCount - 1)) / columnCount
|
||||
);
|
||||
const usedWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
||||
const columnHeights = new Array(columnCount).fill(0);
|
||||
|
||||
@@ -1039,7 +1185,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = image.width && image.height ? image.height / image.width : (image.imageType === 'poster' ? 1.5 : 0.5625);
|
||||
const ratio =
|
||||
image.width && image.height
|
||||
? image.height / image.width
|
||||
: image.imageType === 'poster'
|
||||
? 1.5
|
||||
: 0.5625;
|
||||
const renderHeight = Math.max(Math.round(columnWidth * ratio), 80);
|
||||
const top = columnHeights[targetColumn];
|
||||
const left = targetColumn * (columnWidth + gap);
|
||||
@@ -1058,32 +1209,52 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
const totalHeight = Math.max(...columnHeights, 0);
|
||||
const minVisibleTop = Math.max(galleryScrollTop - overscan, 0);
|
||||
const maxVisibleBottom = galleryScrollTop + galleryViewportHeight + overscan;
|
||||
const visibleItems = items.filter(item => item.top + item.renderHeight >= minVisibleTop && item.top <= maxVisibleBottom);
|
||||
const maxVisibleBottom =
|
||||
galleryScrollTop + galleryViewportHeight + overscan;
|
||||
const visibleItems = items.filter(
|
||||
(item) =>
|
||||
item.top + item.renderHeight >= minVisibleTop &&
|
||||
item.top <= maxVisibleBottom
|
||||
);
|
||||
|
||||
return { visibleItems, totalHeight, usedWidth };
|
||||
}, [galleryImages, galleryScrollTop, galleryViewportHeight, galleryViewportWidth]);
|
||||
}, [
|
||||
galleryImages,
|
||||
galleryScrollTop,
|
||||
galleryViewportHeight,
|
||||
galleryViewportWidth,
|
||||
]);
|
||||
|
||||
const galleryBody = (
|
||||
<div ref={galleryScrollRef} className="flex-1 overflow-y-auto overflow-x-hidden p-4">
|
||||
<div
|
||||
ref={galleryScrollRef}
|
||||
className='flex-1 overflow-y-auto overflow-x-hidden p-4'
|
||||
>
|
||||
{galleryLoading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
|
||||
<div className='flex items-center justify-center py-20'>
|
||||
<div className='animate-spin rounded-full h-10 w-10 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!galleryLoading && galleryError && (
|
||||
<div className="text-center py-12 text-red-500 dark:text-red-400">{galleryError}</div>
|
||||
<div className='text-center py-12 text-red-500 dark:text-red-400'>
|
||||
{galleryError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!galleryLoading && !galleryError && galleryImages.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">暂无图片</div>
|
||||
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||
暂无图片
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!galleryLoading && !galleryError && galleryImages.length > 0 && (
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
|
||||
className='relative mx-auto'
|
||||
style={{
|
||||
height: virtualGalleryLayout.totalHeight,
|
||||
width: virtualGalleryLayout.usedWidth || '100%',
|
||||
}}
|
||||
>
|
||||
{virtualGalleryLayout.visibleItems.map((image) => {
|
||||
const imageUrl = getTMDBImageUrl(
|
||||
@@ -1098,7 +1269,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={`${image.imageType}-${image.file_path}-${image.index}`}
|
||||
className="group absolute"
|
||||
className='group absolute'
|
||||
style={{
|
||||
top: image.top,
|
||||
left: image.left,
|
||||
@@ -1107,16 +1278,18 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
className='relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity'
|
||||
onClick={() => handleImageClick(imageUrl)}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={thumbUrl}
|
||||
alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
alt={`${detailData?.title || title}-gallery-${
|
||||
image.index + 1
|
||||
}`}
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white">
|
||||
<div className='absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white'>
|
||||
{image.imageType === 'poster' ? '海报' : '剧照'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1129,49 +1302,55 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
);
|
||||
|
||||
const galleryHeader = (
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className='flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800'>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">照片墙</h3>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
照片墙
|
||||
</h3>
|
||||
{!galleryLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
共 {galleryTotal} 张
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowGallery(false)}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="关闭照片墙"
|
||||
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='关闭照片墙'
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
<X size={20} className='text-gray-500 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const galleryModal = showGallery ? (useDrawer ? (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none">
|
||||
<div className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}>
|
||||
{galleryHeader}
|
||||
{galleryBody}
|
||||
const galleryModal = showGallery ? (
|
||||
useDrawer ? (
|
||||
<div className='fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none'>
|
||||
<div
|
||||
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}
|
||||
>
|
||||
{galleryHeader}
|
||||
{galleryBody}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setShowGallery(false)}
|
||||
/>
|
||||
<div className="relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
|
||||
{galleryHeader}
|
||||
{galleryBody}
|
||||
) : (
|
||||
<div className='fixed inset-0 z-[10000] flex items-center justify-center p-4'>
|
||||
<div
|
||||
className='absolute inset-0 bg-black/60'
|
||||
onClick={() => setShowGallery(false)}
|
||||
/>
|
||||
<div className='relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col'>
|
||||
{galleryHeader}
|
||||
{galleryBody}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : null;
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (!isVisible || !mounted) return null;
|
||||
|
||||
const content = useDrawer ? (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none">
|
||||
<div className='fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none'>
|
||||
{/* 详情面板 - 抽屉模式 */}
|
||||
<div
|
||||
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col transition-transform duration-300 ease-out pointer-events-auto ${
|
||||
@@ -1179,76 +1358,92 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
}`}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">详情</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10'>
|
||||
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||
详情
|
||||
</h2>
|
||||
<div className='flex items-center gap-2'>
|
||||
{externalUrl && (
|
||||
<button
|
||||
onClick={() => window.open(externalUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
||||
title="打开外部页面"
|
||||
aria-label="打开外部页面"
|
||||
onClick={() =>
|
||||
window.open(externalUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
|
||||
title='打开外部页面'
|
||||
aria-label='打开外部页面'
|
||||
>
|
||||
<ExternalLink size={18} className="text-gray-500 dark:text-gray-400" />
|
||||
<ExternalLink
|
||||
size={18}
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
||||
title="关闭"
|
||||
aria-label="关闭"
|
||||
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
|
||||
title='关闭'
|
||||
aria-label='关闭'
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
<X size={20} className='text-gray-500 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
|
||||
<div className='overflow-y-auto max-h-[calc(90vh-4rem)]'>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
|
||||
<div className='flex items-center justify-center py-20'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
||||
<div className='p-6'>
|
||||
<div className='text-center mb-6'>
|
||||
<p className='text-red-500 dark:text-red-400'>{error}</p>
|
||||
</div>
|
||||
|
||||
{/* 数据源显示和切换 - 错误时也显示 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
||||
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center justify-between gap-3 flex-wrap'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
数据来源:
|
||||
</span>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
|
||||
{currentSource === 'douban' && 'Douban'}
|
||||
{currentSource === 'bangumi' && 'Bangumi'}
|
||||
{currentSource === 'cms' && 'CMS'}
|
||||
{currentSource === 'tmdb' && 'TMDB'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{galleryEntryButton}
|
||||
{currentSource !== 'tmdb' && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className='px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' &&
|
||||
originalSource !== 'tmdb' &&
|
||||
originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className='px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
切换回{' '}
|
||||
{originalSource === 'douban'
|
||||
? 'Douban'
|
||||
: originalSource === 'bangumi'
|
||||
? 'Bangumi'
|
||||
: 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1256,47 +1451,48 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
)}
|
||||
|
||||
{!loading && !error && detailData && (
|
||||
<div className="p-6">
|
||||
<div className='p-6'>
|
||||
{/* 海报和基本信息 */}
|
||||
<div className="flex gap-6 mb-6">
|
||||
<div className='flex gap-6 mb-6'>
|
||||
{detailData.poster && (
|
||||
<div className="flex flex-col items-start gap-3 flex-shrink-0">
|
||||
<div className='flex flex-col items-start gap-3 flex-shrink-0'>
|
||||
<div
|
||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
className='relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity'
|
||||
onClick={() => handleImageClick(detailData.poster!)}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={detailData.poster}
|
||||
alt={detailData.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{galleryEntryButton}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2'>
|
||||
{detailData.title}
|
||||
</h3>
|
||||
{detailData.originalTitle && detailData.originalTitle !== detailData.title && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{detailData.originalTitle}
|
||||
</p>
|
||||
)}
|
||||
{detailData.originalTitle &&
|
||||
detailData.originalTitle !== detailData.title && (
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400 mb-3'>
|
||||
{detailData.originalTitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 评分 */}
|
||||
{detailData.rating && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className='flex items-center gap-2 mb-3'>
|
||||
<Star
|
||||
size={20}
|
||||
className="text-yellow-500 fill-yellow-500"
|
||||
className='text-yellow-500 fill-yellow-500'
|
||||
/>
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{detailData.rating.value.toFixed(1)}
|
||||
</span>
|
||||
{detailData.rating.count > 0 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
({detailData.rating.count} 评价)
|
||||
</span>
|
||||
)}
|
||||
@@ -1305,11 +1501,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 类型标签 */}
|
||||
{detailData.genres && detailData.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<div className='flex flex-wrap gap-2 mb-3'>
|
||||
{detailData.genres.map((genre, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||
className='px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
@@ -1318,21 +1514,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* 年份和时长 */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className='flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
{detailData.year && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar size={16} />
|
||||
<span>{detailData.year}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailData.duration && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className='flex items-center gap-1'>
|
||||
<Clock size={16} />
|
||||
<span>{detailData.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailData.episodesCount && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className='flex items-center gap-1'>
|
||||
<Film size={16} />
|
||||
<span>{detailData.episodesCount} 集</span>
|
||||
</div>
|
||||
@@ -1343,11 +1539,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 简介 */}
|
||||
{(detailData.intro || detailData.overview) && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
<div className='mb-6'>
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||
简介
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
<p className='text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap'>
|
||||
{detailData.intro || detailData.overview}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1355,20 +1551,20 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 导演和演员 */}
|
||||
{detailData.directors && detailData.directors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
||||
<div className='mb-4'>
|
||||
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
|
||||
<Users size={16} />
|
||||
导演
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.directors.map((d) => d.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.actors && detailData.actors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
||||
<div className='mb-4'>
|
||||
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
|
||||
<Users size={16} />
|
||||
演员
|
||||
</h4>
|
||||
@@ -1379,47 +1575,61 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
onMouseMove={handleActorsMouseMove}
|
||||
onMouseUp={handleActorsMouseUp}
|
||||
onMouseLeave={handleActorsMouseLeave}
|
||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
||||
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollBehavior: isActorsDragging ? 'auto' : 'smooth'
|
||||
scrollBehavior: isActorsDragging ? 'auto' : 'smooth',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 pb-2">
|
||||
<div className='flex gap-4 pb-2'>
|
||||
{detailData.actors.map((actor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center flex-shrink-0"
|
||||
style={{ pointerEvents: isActorsDragging ? 'none' : 'auto' }}
|
||||
className='flex flex-col items-center flex-shrink-0'
|
||||
style={{
|
||||
pointerEvents: isActorsDragging ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{actor.profile_path ? (
|
||||
<div
|
||||
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||
className='relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity'
|
||||
onClick={() =>
|
||||
handleImageClick(
|
||||
getTMDBImageUrl(
|
||||
actor.profile_path || null,
|
||||
'w185'
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
||||
originalSrc={getTMDBImageUrl(
|
||||
actor.profile_path || null,
|
||||
'w185'
|
||||
)}
|
||||
alt={actor.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center">
|
||||
<Users size={28} className="text-gray-400" />
|
||||
<div className='w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center'>
|
||||
<Users size={28} className='text-gray-400' />
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={`https://baike.baidu.com/item/${encodeURIComponent(actor.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
|
||||
href={`https://baike.baidu.com/item/${encodeURIComponent(
|
||||
actor.name
|
||||
)}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actor.name}
|
||||
</a>
|
||||
{actor.character && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2">
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2'>
|
||||
{actor.character}
|
||||
</p>
|
||||
)}
|
||||
@@ -1428,22 +1638,25 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.actors
|
||||
.slice(0, 10)
|
||||
.map((a) => a.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 制作信息 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className='grid grid-cols-2 gap-4 text-sm'>
|
||||
{detailData.countries && detailData.countries.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||
<Globe size={14} />
|
||||
国家/地区
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.countries.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1451,11 +1664,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{detailData.languages && detailData.languages.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||
<Tag size={14} />
|
||||
语言
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.languages.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1463,28 +1676,34 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{detailData.releaseDate && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||
<Calendar size={14} />
|
||||
上映日期
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">{detailData.releaseDate}</p>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.releaseDate}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.status && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">状态</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">{detailData.status}</p>
|
||||
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1'>
|
||||
状态
|
||||
</h4>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.status}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 季度和集数信息(仅TMDB电视剧) */}
|
||||
{detailData.mediaType === 'tv' && (
|
||||
<div className="mt-6">
|
||||
<div className='mt-6'>
|
||||
{loadingSeasons && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
|
||||
<div className='flex items-center justify-center py-4'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1492,15 +1711,17 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
<>
|
||||
{/* 季度列表 */}
|
||||
{seasonData.seasons.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
<div className='mb-6'>
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||
季度
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 gap-3'>
|
||||
{seasonData.seasons.map((season: any) => (
|
||||
<div
|
||||
key={season.id}
|
||||
onClick={() => handleSeasonChange(season.season_number)}
|
||||
onClick={() =>
|
||||
handleSeasonChange(season.season_number)
|
||||
}
|
||||
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedSeason === season.season_number
|
||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||
@@ -1509,25 +1730,33 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
>
|
||||
{season.poster_path && (
|
||||
<div
|
||||
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
||||
className='relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
|
||||
handleImageClick(
|
||||
getTMDBImageUrl(
|
||||
season.poster_path,
|
||||
'w500'
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
||||
originalSrc={getTMDBImageUrl(
|
||||
season.poster_path,
|
||||
'w92'
|
||||
)}
|
||||
alt={season.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<div className='flex-1 min-w-0'>
|
||||
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||
{season.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
{season.episode_count} 集
|
||||
</p>
|
||||
</div>
|
||||
@@ -1540,8 +1769,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{/* 集数列表 */}
|
||||
{seasonData.episodes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `第${selectedSeason}季`}
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||
{seasonData.seasons.find(
|
||||
(s: any) => s.season_number === selectedSeason
|
||||
)?.name || `第${selectedSeason}季`}
|
||||
</h4>
|
||||
<div
|
||||
ref={episodesScrollRef}
|
||||
@@ -1549,16 +1780,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
||||
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollBehavior: isDragging ? 'auto' : 'smooth'
|
||||
scrollBehavior: isDragging ? 'auto' : 'smooth',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3 py-2">
|
||||
<div className='flex gap-3 py-2'>
|
||||
{seasonData.episodes.map((episode: Episode) => {
|
||||
const isExpanded = expandedEpisodes.has(episode.id);
|
||||
const isCurrentEpisode = currentEpisode === episode.episode_number;
|
||||
const isExpanded = expandedEpisodes.has(
|
||||
episode.id
|
||||
);
|
||||
const isCurrentEpisode =
|
||||
currentEpisode === episode.episode_number;
|
||||
return (
|
||||
<div
|
||||
key={episode.id}
|
||||
@@ -1568,28 +1802,45 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||
: 'bg-gray-50 dark:bg-gray-800'
|
||||
}`}
|
||||
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
|
||||
style={{
|
||||
pointerEvents: isDragging
|
||||
? 'none'
|
||||
: 'auto',
|
||||
}}
|
||||
>
|
||||
{episode.still_path && (
|
||||
<div
|
||||
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
|
||||
className='relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity'
|
||||
onClick={() =>
|
||||
handleImageClick(
|
||||
getTMDBImageUrl(
|
||||
episode.still_path,
|
||||
'w500'
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
||||
originalSrc={getTMDBImageUrl(
|
||||
episode.still_path,
|
||||
'w300'
|
||||
)}
|
||||
alt={episode.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
第{episode.episode_number}集: {episode.name}
|
||||
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 mb-1'>
|
||||
第{episode.episode_number}集:{' '}
|
||||
{episode.name}
|
||||
</p>
|
||||
{episode.overview && (
|
||||
<p
|
||||
onClick={() => {
|
||||
const newExpanded = new Set(expandedEpisodes);
|
||||
const newExpanded = new Set(
|
||||
expandedEpisodes
|
||||
);
|
||||
if (isExpanded) {
|
||||
newExpanded.delete(episode.id);
|
||||
} else {
|
||||
@@ -1597,13 +1848,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
}
|
||||
setExpandedEpisodes(newExpanded);
|
||||
}}
|
||||
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`}
|
||||
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${
|
||||
isExpanded ? '' : 'line-clamp-3'
|
||||
}`}
|
||||
>
|
||||
{episode.overview}
|
||||
</p>
|
||||
)}
|
||||
{episode.air_date && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
|
||||
{episode.air_date}
|
||||
</p>
|
||||
)}
|
||||
@@ -1620,37 +1873,46 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* 数据源显示和切换 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
||||
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center justify-between gap-3 flex-wrap'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
数据来源:
|
||||
</span>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
|
||||
{currentSource === 'douban' && 'Douban'}
|
||||
{currentSource === 'bangumi' && 'Bangumi'}
|
||||
{currentSource === 'cms' && 'CMS'}
|
||||
{currentSource === 'tmdb' && 'TMDB'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{galleryEntryButton}
|
||||
{currentSource !== 'tmdb' && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className='px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' &&
|
||||
originalSource !== 'tmdb' &&
|
||||
originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className='px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
切换回{' '}
|
||||
{originalSource === 'douban'
|
||||
? 'Douban'
|
||||
: originalSource === 'bangumi'
|
||||
? 'Bangumi'
|
||||
: 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1671,7 +1933,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div className='fixed inset-0 z-[9999] flex items-center justify-center p-4'>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${
|
||||
@@ -1686,59 +1948,70 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 详情面板 - 居中模式 */}
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden transition-all duration-200 ease-out"
|
||||
className='relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden transition-all duration-200 ease-out'
|
||||
style={{
|
||||
willChange: 'transform, opacity',
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: isAnimating ? 'scale(1) translateZ(0)' : 'scale(0.95) translateZ(0)',
|
||||
transform: isAnimating
|
||||
? 'scale(1) translateZ(0)'
|
||||
: 'scale(0.95) translateZ(0)',
|
||||
opacity: isAnimating ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">详情</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10'>
|
||||
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||
详情
|
||||
</h2>
|
||||
<div className='flex items-center gap-2'>
|
||||
{externalUrl && (
|
||||
<button
|
||||
onClick={() => window.open(externalUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
||||
title="打开外部页面"
|
||||
aria-label="打开外部页面"
|
||||
onClick={() =>
|
||||
window.open(externalUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
|
||||
title='打开外部页面'
|
||||
aria-label='打开外部页面'
|
||||
>
|
||||
<ExternalLink size={18} className="text-gray-500 dark:text-gray-400" />
|
||||
<ExternalLink
|
||||
size={18}
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
||||
title="关闭"
|
||||
aria-label="关闭"
|
||||
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
|
||||
title='关闭'
|
||||
aria-label='关闭'
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
<X size={20} className='text-gray-500 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
|
||||
<div className='overflow-y-auto max-h-[calc(90vh-4rem)]'>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
|
||||
<div className='flex items-center justify-center py-20'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
||||
<div className='p-6'>
|
||||
<div className='text-center mb-6'>
|
||||
<p className='text-red-500 dark:text-red-400'>{error}</p>
|
||||
</div>
|
||||
|
||||
{/* 数据源显示和切换 - 错误时也显示 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
||||
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
数据来源:
|
||||
</span>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
|
||||
{currentSource === 'douban' && 'Douban'}
|
||||
{currentSource === 'bangumi' && 'Bangumi'}
|
||||
{currentSource === 'cms' && 'CMS'}
|
||||
@@ -1749,67 +2022,75 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className='px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' &&
|
||||
originalSource !== 'tmdb' &&
|
||||
originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className='px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
切换回{' '}
|
||||
{originalSource === 'douban'
|
||||
? 'Douban'
|
||||
: originalSource === 'bangumi'
|
||||
? 'Bangumi'
|
||||
: 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && detailData && (
|
||||
<div className="p-6">
|
||||
<div className='p-6'>
|
||||
{/* 海报和基本信息 */}
|
||||
<div className="flex gap-6 mb-6">
|
||||
<div className='flex gap-6 mb-6'>
|
||||
{detailData.poster && (
|
||||
<div className="flex flex-col items-start gap-3 flex-shrink-0">
|
||||
<div className='flex flex-col items-start gap-3 flex-shrink-0'>
|
||||
<div
|
||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
className='relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity'
|
||||
onClick={() => handleImageClick(detailData.poster!)}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={detailData.poster}
|
||||
alt={detailData.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{galleryEntryButton}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2'>
|
||||
{detailData.title}
|
||||
</h3>
|
||||
{detailData.originalTitle && detailData.originalTitle !== detailData.title && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{detailData.originalTitle}
|
||||
</p>
|
||||
)}
|
||||
{detailData.originalTitle &&
|
||||
detailData.originalTitle !== detailData.title && (
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400 mb-3'>
|
||||
{detailData.originalTitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 评分 */}
|
||||
{detailData.rating && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className='flex items-center gap-2 mb-3'>
|
||||
<Star
|
||||
size={20}
|
||||
className="text-yellow-500 fill-yellow-500"
|
||||
className='text-yellow-500 fill-yellow-500'
|
||||
/>
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{detailData.rating.value.toFixed(1)}
|
||||
</span>
|
||||
{detailData.rating.count > 0 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
({detailData.rating.count} 评价)
|
||||
</span>
|
||||
)}
|
||||
@@ -1818,11 +2099,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 类型标签 */}
|
||||
{detailData.genres && detailData.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<div className='flex flex-wrap gap-2 mb-3'>
|
||||
{detailData.genres.map((genre, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||
className='px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
@@ -1831,21 +2112,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* 年份和时长 */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className='flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
{detailData.year && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar size={16} />
|
||||
<span>{detailData.year}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailData.duration && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className='flex items-center gap-1'>
|
||||
<Clock size={16} />
|
||||
<span>{detailData.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailData.episodesCount && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className='flex items-center gap-1'>
|
||||
<Film size={16} />
|
||||
<span>{detailData.episodesCount} 集</span>
|
||||
</div>
|
||||
@@ -1856,11 +2137,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 简介 */}
|
||||
{(detailData.intro || detailData.overview) && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
<div className='mb-6'>
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||
简介
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
<p className='text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap'>
|
||||
{detailData.intro || detailData.overview}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1868,20 +2149,20 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{/* 导演和演员 */}
|
||||
{detailData.directors && detailData.directors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
||||
<div className='mb-4'>
|
||||
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
|
||||
<Users size={16} />
|
||||
导演
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.directors.map((d) => d.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.actors && detailData.actors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
||||
<div className='mb-4'>
|
||||
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
|
||||
<Users size={16} />
|
||||
演员
|
||||
</h4>
|
||||
@@ -1892,47 +2173,61 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
onMouseMove={handleActorsMouseMove}
|
||||
onMouseUp={handleActorsMouseUp}
|
||||
onMouseLeave={handleActorsMouseLeave}
|
||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
||||
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollBehavior: isActorsDragging ? 'auto' : 'smooth'
|
||||
scrollBehavior: isActorsDragging ? 'auto' : 'smooth',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 pb-2">
|
||||
<div className='flex gap-4 pb-2'>
|
||||
{detailData.actors.map((actor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center flex-shrink-0"
|
||||
style={{ pointerEvents: isActorsDragging ? 'none' : 'auto' }}
|
||||
className='flex flex-col items-center flex-shrink-0'
|
||||
style={{
|
||||
pointerEvents: isActorsDragging ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{actor.profile_path ? (
|
||||
<div
|
||||
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||
className='relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity'
|
||||
onClick={() =>
|
||||
handleImageClick(
|
||||
getTMDBImageUrl(
|
||||
actor.profile_path || null,
|
||||
'w185'
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
|
||||
originalSrc={getTMDBImageUrl(
|
||||
actor.profile_path || null,
|
||||
'w185'
|
||||
)}
|
||||
alt={actor.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center">
|
||||
<Users size={28} className="text-gray-400" />
|
||||
<div className='w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center'>
|
||||
<Users size={28} className='text-gray-400' />
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={`https://baike.baidu.com/item/${encodeURIComponent(actor.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
|
||||
href={`https://baike.baidu.com/item/${encodeURIComponent(
|
||||
actor.name
|
||||
)}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actor.name}
|
||||
</a>
|
||||
{actor.character && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2">
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2'>
|
||||
{actor.character}
|
||||
</p>
|
||||
)}
|
||||
@@ -1941,22 +2236,25 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.actors
|
||||
.slice(0, 10)
|
||||
.map((a) => a.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 制作信息 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className='grid grid-cols-2 gap-4 text-sm'>
|
||||
{detailData.countries && detailData.countries.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||
<Globe size={14} />
|
||||
国家/地区
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.countries.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1964,11 +2262,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{detailData.languages && detailData.languages.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||
<Tag size={14} />
|
||||
语言
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.languages.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1976,28 +2274,34 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
{detailData.releaseDate && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
|
||||
<Calendar size={14} />
|
||||
上映日期
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">{detailData.releaseDate}</p>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.releaseDate}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.status && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">状态</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">{detailData.status}</p>
|
||||
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1'>
|
||||
状态
|
||||
</h4>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
{detailData.status}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 季度和集数信息(仅TMDB电视剧) */}
|
||||
{detailData.mediaType === 'tv' && (
|
||||
<div className="mt-6">
|
||||
<div className='mt-6'>
|
||||
{loadingSeasons && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
|
||||
<div className='flex items-center justify-center py-4'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2005,15 +2309,17 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
<>
|
||||
{/* 季度列表 */}
|
||||
{seasonData.seasons.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
<div className='mb-6'>
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||
季度
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 gap-3'>
|
||||
{seasonData.seasons.map((season: any) => (
|
||||
<div
|
||||
key={season.id}
|
||||
onClick={() => handleSeasonChange(season.season_number)}
|
||||
onClick={() =>
|
||||
handleSeasonChange(season.season_number)
|
||||
}
|
||||
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedSeason === season.season_number
|
||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||
@@ -2022,25 +2328,33 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
>
|
||||
{season.poster_path && (
|
||||
<div
|
||||
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
||||
className='relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
|
||||
handleImageClick(
|
||||
getTMDBImageUrl(
|
||||
season.poster_path,
|
||||
'w500'
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
|
||||
originalSrc={getTMDBImageUrl(
|
||||
season.poster_path,
|
||||
'w92'
|
||||
)}
|
||||
alt={season.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<div className='flex-1 min-w-0'>
|
||||
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||
{season.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
{season.episode_count} 集
|
||||
</p>
|
||||
</div>
|
||||
@@ -2053,8 +2367,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{/* 集数列表 */}
|
||||
{seasonData.episodes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `第${selectedSeason}季`}
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
|
||||
{seasonData.seasons.find(
|
||||
(s: any) => s.season_number === selectedSeason
|
||||
)?.name || `第${selectedSeason}季`}
|
||||
</h4>
|
||||
<div
|
||||
ref={episodesScrollRef}
|
||||
@@ -2062,16 +2378,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
||||
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollBehavior: isDragging ? 'auto' : 'smooth'
|
||||
scrollBehavior: isDragging ? 'auto' : 'smooth',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3 py-2">
|
||||
<div className='flex gap-3 py-2'>
|
||||
{seasonData.episodes.map((episode: Episode) => {
|
||||
const isExpanded = expandedEpisodes.has(episode.id);
|
||||
const isCurrentEpisode = currentEpisode === episode.episode_number;
|
||||
const isExpanded = expandedEpisodes.has(
|
||||
episode.id
|
||||
);
|
||||
const isCurrentEpisode =
|
||||
currentEpisode === episode.episode_number;
|
||||
return (
|
||||
<div
|
||||
key={episode.id}
|
||||
@@ -2081,28 +2400,45 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||
: 'bg-gray-50 dark:bg-gray-800'
|
||||
}`}
|
||||
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
|
||||
style={{
|
||||
pointerEvents: isDragging
|
||||
? 'none'
|
||||
: 'auto',
|
||||
}}
|
||||
>
|
||||
{episode.still_path && (
|
||||
<div
|
||||
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
|
||||
className='relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity'
|
||||
onClick={() =>
|
||||
handleImageClick(
|
||||
getTMDBImageUrl(
|
||||
episode.still_path,
|
||||
'w500'
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<ProxyImage
|
||||
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
|
||||
originalSrc={getTMDBImageUrl(
|
||||
episode.still_path,
|
||||
'w300'
|
||||
)}
|
||||
alt={episode.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
第{episode.episode_number}集: {episode.name}
|
||||
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 mb-1'>
|
||||
第{episode.episode_number}集:{' '}
|
||||
{episode.name}
|
||||
</p>
|
||||
{episode.overview && (
|
||||
<p
|
||||
onClick={() => {
|
||||
const newExpanded = new Set(expandedEpisodes);
|
||||
const newExpanded = new Set(
|
||||
expandedEpisodes
|
||||
);
|
||||
if (isExpanded) {
|
||||
newExpanded.delete(episode.id);
|
||||
} else {
|
||||
@@ -2110,13 +2446,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
}
|
||||
setExpandedEpisodes(newExpanded);
|
||||
}}
|
||||
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`}
|
||||
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${
|
||||
isExpanded ? '' : 'line-clamp-3'
|
||||
}`}
|
||||
>
|
||||
{episode.overview}
|
||||
</p>
|
||||
)}
|
||||
{episode.air_date && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
|
||||
{episode.air_date}
|
||||
</p>
|
||||
)}
|
||||
@@ -2133,11 +2471,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* 数据源显示和切换 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
||||
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
数据来源:
|
||||
</span>
|
||||
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
|
||||
{currentSource === 'douban' && 'Douban'}
|
||||
{currentSource === 'bangumi' && 'Bangumi'}
|
||||
{currentSource === 'cms' && 'CMS'}
|
||||
@@ -2148,20 +2488,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className='px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' &&
|
||||
originalSource !== 'tmdb' &&
|
||||
originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className='px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
切换回{' '}
|
||||
{originalSource === 'douban'
|
||||
? 'Douban'
|
||||
: originalSource === 'bangumi'
|
||||
? 'Bangumi'
|
||||
: 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils';
|
||||
import {
|
||||
processImageUrl,
|
||||
tryApplyBangumiImageFallback,
|
||||
tryApplyDoubanImageFallback,
|
||||
} from '@/lib/utils';
|
||||
|
||||
interface ProxyImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
originalSrc: string;
|
||||
@@ -22,17 +26,49 @@ const ProxyImage: React.FC<ProxyImageProps> = ({
|
||||
src: _src,
|
||||
...props
|
||||
}) => {
|
||||
const initialSrc = useMemo(
|
||||
() => displaySrc || processImageUrl(originalSrc),
|
||||
[displaySrc, originalSrc]
|
||||
);
|
||||
const [currentSrc, setCurrentSrc] = useState(initialSrc);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSrc(initialSrc);
|
||||
}, [initialSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (displaySrc) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img || img.complete || img.dataset.bangumiBackupTried === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tryApplyBangumiImageFallback(img, originalSrc)) {
|
||||
setCurrentSrc(img.src);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentSrc, displaySrc, originalSrc]);
|
||||
|
||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
const img = e.currentTarget;
|
||||
|
||||
if (tryApplyDoubanImageFallback(img, originalSrc)) {
|
||||
if (
|
||||
tryApplyDoubanImageFallback(img, originalSrc) ||
|
||||
tryApplyBangumiImageFallback(img, originalSrc)
|
||||
) {
|
||||
setCurrentSrc(img.src);
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryOnError && !img.dataset.retried) {
|
||||
img.dataset.retried = 'true';
|
||||
window.setTimeout(() => {
|
||||
img.src = displaySrc || processImageUrl(originalSrc);
|
||||
setCurrentSrc(initialSrc);
|
||||
}, retryDelay);
|
||||
}
|
||||
|
||||
@@ -42,7 +78,8 @@ const ProxyImage: React.FC<ProxyImageProps> = ({
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
src={displaySrc || processImageUrl(originalSrc)}
|
||||
ref={imgRef}
|
||||
src={currentSrc}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
onError={handleError}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import { clearAllDanmakuCache, getDanmakuCacheStats } from '@/lib/danmaku/api';
|
||||
import { clearBangumiImageFallbackCache } from '@/lib/utils';
|
||||
import { CURRENT_VERSION } from '@/lib/version';
|
||||
import { UpdateStatus } from '@/lib/version_check';
|
||||
|
||||
@@ -152,6 +153,11 @@ export const UserMenu: React.FC = () => {
|
||||
);
|
||||
const [doubanDataSourceBackup, setDoubanDataSourceBackup] =
|
||||
useState('direct');
|
||||
const [animeDataSource, setAnimeDataSource] = useState('direct');
|
||||
const [animeDataSourceBackup, setAnimeDataSourceBackup] =
|
||||
useState('server-proxy');
|
||||
const [animeCustomBaseUrl, setAnimeCustomBaseUrl] = useState('');
|
||||
const [animeImageBaseUrl, setAnimeImageBaseUrl] = useState('');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState(
|
||||
'cmliussss-cdn-tencent'
|
||||
);
|
||||
@@ -164,6 +170,9 @@ export const UserMenu: React.FC = () => {
|
||||
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
|
||||
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] =
|
||||
useState(false);
|
||||
const [isAnimeDropdownOpen, setIsAnimeDropdownOpen] = useState(false);
|
||||
const [isAnimeBackupDropdownOpen, setIsAnimeBackupDropdownOpen] =
|
||||
useState(false);
|
||||
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
@@ -277,6 +286,12 @@ export const UserMenu: React.FC = () => {
|
||||
{ value: 'custom', label: '自定义代理' },
|
||||
];
|
||||
|
||||
const animeDataSourceOptions = [
|
||||
{ value: 'direct', label: '直连(浏览器直连 Bangumi)' },
|
||||
{ value: 'server-proxy', label: '服务器代理(由服务器访问 Bangumi)' },
|
||||
{ value: 'custom-baseurl', label: '自定义 Base URL' },
|
||||
];
|
||||
|
||||
// 豆瓣图片代理选项
|
||||
const doubanImageProxyTypeOptions = [
|
||||
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
|
||||
@@ -584,6 +599,23 @@ export const UserMenu: React.FC = () => {
|
||||
);
|
||||
setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
|
||||
|
||||
const savedAnimeDataSource = localStorage.getItem('animeDataSource');
|
||||
const defaultAnimeDataSource =
|
||||
(window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE || 'direct';
|
||||
setAnimeDataSource(savedAnimeDataSource || defaultAnimeDataSource);
|
||||
|
||||
const savedAnimeDataSourceBackup = localStorage.getItem(
|
||||
'animeDataSourceBackup'
|
||||
);
|
||||
setAnimeDataSourceBackup(savedAnimeDataSourceBackup || 'server-proxy');
|
||||
|
||||
const savedAnimeCustomBaseUrl =
|
||||
localStorage.getItem('animeCustomBaseUrl');
|
||||
setAnimeCustomBaseUrl(savedAnimeCustomBaseUrl || '');
|
||||
|
||||
const savedAnimeImageBaseUrl = localStorage.getItem('animeImageBaseUrl');
|
||||
setAnimeImageBaseUrl(savedAnimeImageBaseUrl || '');
|
||||
|
||||
const savedDoubanImageProxyType = localStorage.getItem(
|
||||
'doubanImageProxyType'
|
||||
);
|
||||
@@ -973,6 +1005,40 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
}, [isDoubanBackupDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isAnimeDropdownOpen) {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-dropdown="anime-datasource"]')) {
|
||||
setIsAnimeDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isAnimeDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isAnimeDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isAnimeBackupDropdownOpen) {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-dropdown="anime-datasource-backup"]')) {
|
||||
setIsAnimeBackupDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isAnimeBackupDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isAnimeBackupDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isDoubanImageProxyDropdownOpen) {
|
||||
@@ -1325,6 +1391,38 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimeDataSourceChange = (value: string) => {
|
||||
clearBangumiImageFallbackCache();
|
||||
setAnimeDataSource(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('animeDataSource', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimeDataSourceBackupChange = (value: string) => {
|
||||
clearBangumiImageFallbackCache();
|
||||
setAnimeDataSourceBackup(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('animeDataSourceBackup', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimeCustomBaseUrlChange = (value: string) => {
|
||||
clearBangumiImageFallbackCache();
|
||||
setAnimeCustomBaseUrl(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('animeCustomBaseUrl', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimeImageBaseUrlChange = (value: string) => {
|
||||
clearBangumiImageFallbackCache();
|
||||
setAnimeImageBaseUrl(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('animeImageBaseUrl', value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanImageProxyTypeChange = (value: string) => {
|
||||
setDoubanImageProxyType(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1539,6 +1637,10 @@ export const UserMenu: React.FC = () => {
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
const defaultAnimeDataSource =
|
||||
(window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE || 'direct';
|
||||
const defaultAnimeBaseUrl = '';
|
||||
const defaultAnimeImageBaseUrl = '';
|
||||
|
||||
setDefaultAggregateSearch(true);
|
||||
setEnableOptimization(true);
|
||||
@@ -1550,6 +1652,10 @@ export const UserMenu: React.FC = () => {
|
||||
setDoubanDataSource(defaultDoubanProxyType);
|
||||
setDoubanDataSourceBackup('direct');
|
||||
setDoubanProxyUrlBackup('');
|
||||
setAnimeDataSource(defaultAnimeDataSource);
|
||||
setAnimeDataSourceBackup('server-proxy');
|
||||
setAnimeCustomBaseUrl(defaultAnimeBaseUrl);
|
||||
setAnimeImageBaseUrl(defaultAnimeImageBaseUrl);
|
||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
|
||||
setDoubanImageProxyTypeBackup('server');
|
||||
@@ -1581,6 +1687,10 @@ export const UserMenu: React.FC = () => {
|
||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||
localStorage.setItem('doubanDataSourceBackup', 'direct');
|
||||
localStorage.setItem('doubanProxyUrlBackup', '');
|
||||
localStorage.setItem('animeDataSource', defaultAnimeDataSource);
|
||||
localStorage.setItem('animeDataSourceBackup', 'server-proxy');
|
||||
localStorage.setItem('animeCustomBaseUrl', defaultAnimeBaseUrl);
|
||||
localStorage.setItem('animeImageBaseUrl', defaultAnimeImageBaseUrl);
|
||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
|
||||
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
|
||||
@@ -2142,6 +2252,9 @@ export const UserMenu: React.FC = () => {
|
||||
{/* 分割线 */}
|
||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 豆瓣图片代理设置 */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
@@ -2376,6 +2489,185 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 动漫数据源设置 */}
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
动漫数据源
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
用于 Bangumi
|
||||
新番放送和番剧详情;默认主源直连,备用源服务器代理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||
主数据源
|
||||
</label>
|
||||
<div
|
||||
className='relative'
|
||||
data-dropdown='anime-datasource'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setIsAnimeDropdownOpen(!isAnimeDropdownOpen)
|
||||
}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
{
|
||||
animeDataSourceOptions.find(
|
||||
(option) => option.value === animeDataSource
|
||||
)?.label
|
||||
}
|
||||
</button>
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isAnimeDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{isAnimeDropdownOpen && (
|
||||
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||
{animeDataSourceOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleAnimeDataSourceChange(option.value);
|
||||
setIsAnimeDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
animeDataSource === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>
|
||||
{option.label}
|
||||
</span>
|
||||
{animeDataSource === option.value && (
|
||||
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label className='text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||
备用数据源
|
||||
</label>
|
||||
<div
|
||||
className='relative'
|
||||
data-dropdown='anime-datasource-backup'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setIsAnimeBackupDropdownOpen(
|
||||
!isAnimeBackupDropdownOpen
|
||||
)
|
||||
}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
{
|
||||
animeDataSourceOptions.find(
|
||||
(option) =>
|
||||
option.value === animeDataSourceBackup
|
||||
)?.label
|
||||
}
|
||||
</button>
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isAnimeBackupDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{isAnimeBackupDropdownOpen && (
|
||||
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||
{animeDataSourceOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleAnimeDataSourceBackupChange(
|
||||
option.value
|
||||
);
|
||||
setIsAnimeBackupDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
animeDataSourceBackup === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>
|
||||
{option.label}
|
||||
</span>
|
||||
{animeDataSourceBackup === option.value && (
|
||||
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(animeDataSource === 'custom-baseurl' ||
|
||||
animeDataSourceBackup === 'custom-baseurl') && (
|
||||
<div className='space-y-2'>
|
||||
<label className='text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||
动漫自定义 Base URL
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||
placeholder='例如: https://api.bgm.tv 或 https://bangumi-proxy.example.com'
|
||||
value={animeCustomBaseUrl}
|
||||
onChange={(e) =>
|
||||
handleAnimeCustomBaseUrlChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{!animeCustomBaseUrl.trim() && (
|
||||
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
|
||||
未填写时自定义 Base URL 会自动按 Bangumi
|
||||
官方直连处理。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<label className='text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||
动漫图片 Base URL
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||
placeholder='例如: https://proxy.example.com'
|
||||
value={animeImageBaseUrl}
|
||||
onChange={(e) =>
|
||||
handleAnimeImageBaseUrlChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
用于替换 Bangumi
|
||||
图片域名。只需填写基础部分,不需要填写完整图片路径。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+1788
-1429
@@ -1,6 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
|
||||
|
||||
import { Cloud, ExternalLink, Heart, Info, Link, PlayCircleIcon, Radio, Sparkles, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Info,
|
||||
Link,
|
||||
PlayCircleIcon,
|
||||
Radio,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
@@ -10,6 +20,7 @@ import React, {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -24,8 +35,11 @@ import {
|
||||
import { isNetdiskSource } from '@/lib/netdisk/source';
|
||||
import {
|
||||
base58Decode,
|
||||
getBangumiImageFallbackUrl,
|
||||
getDoubanImageFallbackUrl,
|
||||
markBangumiImageFallbackActive,
|
||||
processImageUrl,
|
||||
tryApplyBangumiImageFallback,
|
||||
tryApplyDoubanImageFallback,
|
||||
} from '@/lib/utils';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
@@ -47,7 +61,13 @@ export interface VideoCardProps {
|
||||
source_names?: string[];
|
||||
progress?: number;
|
||||
year?: string;
|
||||
from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb' | 'source-search';
|
||||
from:
|
||||
| 'playrecord'
|
||||
| 'favorite'
|
||||
| 'search'
|
||||
| 'douban'
|
||||
| 'tmdb'
|
||||
| 'source-search';
|
||||
currentEpisode?: number;
|
||||
douban_id?: number;
|
||||
tmdb_id?: number;
|
||||
@@ -78,45 +98,46 @@ export type VideoCardHandle = {
|
||||
setDoubanId: (id?: number) => void;
|
||||
};
|
||||
|
||||
const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard(
|
||||
{
|
||||
id,
|
||||
title = '',
|
||||
query = '',
|
||||
poster = '',
|
||||
episodes,
|
||||
source,
|
||||
source_name,
|
||||
source_names,
|
||||
progress = 0,
|
||||
year,
|
||||
from,
|
||||
currentEpisode,
|
||||
douban_id,
|
||||
tmdb_id,
|
||||
onDelete,
|
||||
rate,
|
||||
type = '',
|
||||
isBangumi = false,
|
||||
isAggregate = false,
|
||||
origin = 'vod',
|
||||
releaseDate,
|
||||
isUpcoming = false,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
orientation = 'vertical',
|
||||
playTime,
|
||||
totalTime,
|
||||
cmsData,
|
||||
onBeforeNavigate,
|
||||
}: VideoCardProps,
|
||||
ref
|
||||
) {
|
||||
const router = useRouter();
|
||||
const actualTitle = title;
|
||||
const actualPoster = poster;
|
||||
const netdiskPosterPlaceholder = useMemo(() => {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(`
|
||||
const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||
function VideoCard(
|
||||
{
|
||||
id,
|
||||
title = '',
|
||||
query = '',
|
||||
poster = '',
|
||||
episodes,
|
||||
source,
|
||||
source_name,
|
||||
source_names,
|
||||
progress = 0,
|
||||
year,
|
||||
from,
|
||||
currentEpisode,
|
||||
douban_id,
|
||||
tmdb_id,
|
||||
onDelete,
|
||||
rate,
|
||||
type = '',
|
||||
isBangumi = false,
|
||||
isAggregate = false,
|
||||
origin = 'vod',
|
||||
releaseDate,
|
||||
isUpcoming = false,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
orientation = 'vertical',
|
||||
playTime,
|
||||
totalTime,
|
||||
cmsData,
|
||||
onBeforeNavigate,
|
||||
}: VideoCardProps,
|
||||
ref
|
||||
) {
|
||||
const router = useRouter();
|
||||
const actualTitle = title;
|
||||
const actualPoster = poster;
|
||||
const netdiskPosterPlaceholder = useMemo(() => {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 600">
|
||||
<rect width="400" height="600" fill="#f3f4f6"/>
|
||||
<g fill="none" stroke="#9ca3af" stroke-width="16" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -124,1232 +145,1092 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
</g>
|
||||
</svg>
|
||||
`)}`;
|
||||
}, []);
|
||||
const processedPoster = useMemo(
|
||||
() =>
|
||||
actualPoster
|
||||
? processImageUrl(actualPoster)
|
||||
: isNetdiskSource(source)
|
||||
}, []);
|
||||
const processedPoster = useMemo(
|
||||
() =>
|
||||
actualPoster
|
||||
? processImageUrl(actualPoster)
|
||||
: isNetdiskSource(source)
|
||||
? netdiskPosterPlaceholder
|
||||
: '',
|
||||
[actualPoster, source, netdiskPosterPlaceholder]
|
||||
);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
||||
const [searchFavorited, setSearchFavorited] = useState<boolean | null>(null); // 搜索结果的收藏状态
|
||||
const [showAIChat, setShowAIChat] = useState(false);
|
||||
const [isAIStreaming, setIsAIStreaming] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示
|
||||
const [displayPoster, setDisplayPoster] = useState(processedPoster);
|
||||
[actualPoster, source, netdiskPosterPlaceholder]
|
||||
);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
||||
const [searchFavorited, setSearchFavorited] = useState<boolean | null>(
|
||||
null
|
||||
); // 搜索结果的收藏状态
|
||||
const [showAIChat, setShowAIChat] = useState(false);
|
||||
const [isAIStreaming, setIsAIStreaming] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] =
|
||||
useState('');
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示
|
||||
const [displayPoster, setDisplayPoster] = useState(processedPoster);
|
||||
|
||||
// 检查AI功能是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled =
|
||||
(window as any).RUNTIME_CONFIG?.AI_ENABLED &&
|
||||
(window as any).RUNTIME_CONFIG?.AI_ENABLE_VIDEOCARD_ENTRY;
|
||||
setAiEnabled(enabled);
|
||||
// 检查AI功能是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled =
|
||||
(window as any).RUNTIME_CONFIG?.AI_ENABLED &&
|
||||
(window as any).RUNTIME_CONFIG?.AI_ENABLE_VIDEOCARD_ENTRY;
|
||||
setAiEnabled(enabled);
|
||||
|
||||
// 加载AI默认消息配置
|
||||
const defaultMsg = (window as any).RUNTIME_CONFIG?.AI_DEFAULT_MESSAGE_WITH_VIDEO;
|
||||
if (defaultMsg) {
|
||||
setAiDefaultMessageWithVideo(defaultMsg);
|
||||
// 加载AI默认消息配置
|
||||
const defaultMsg = (window as any).RUNTIME_CONFIG
|
||||
?.AI_DEFAULT_MESSAGE_WITH_VIDEO;
|
||||
if (defaultMsg) {
|
||||
setAiDefaultMessageWithVideo(defaultMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
// 可外部修改的可控字段
|
||||
const [dynamicEpisodes, setDynamicEpisodes] = useState<number | undefined>(
|
||||
episodes
|
||||
);
|
||||
const [dynamicSourceNames, setDynamicSourceNames] = useState<string[] | undefined>(
|
||||
source_names
|
||||
);
|
||||
const [dynamicDoubanId, setDynamicDoubanId] = useState<number | undefined>(
|
||||
douban_id
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDynamicEpisodes(episodes);
|
||||
}, [episodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setDynamicSourceNames(source_names);
|
||||
}, [source_names]);
|
||||
|
||||
useEffect(() => {
|
||||
setDynamicDoubanId(douban_id);
|
||||
}, [douban_id]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayPoster(processedPoster);
|
||||
}, [processedPoster]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
|
||||
setSourceNames: (names?: string[]) => setDynamicSourceNames(names),
|
||||
setDoubanId: (id?: number) => setDynamicDoubanId(id),
|
||||
}));
|
||||
|
||||
const actualSource = source;
|
||||
const actualId = id;
|
||||
const actualDoubanId = dynamicDoubanId;
|
||||
const actualEpisodes = dynamicEpisodes;
|
||||
const actualYear = year;
|
||||
const actualQuery = query || '';
|
||||
const actualSearchType = type;
|
||||
const isDirectPlaySource = actualSource === 'directplay';
|
||||
const directLinkUrl = useMemo(() => {
|
||||
if (!isDirectPlaySource || !actualId) return '';
|
||||
try {
|
||||
return base58Decode(actualId);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [isDirectPlaySource, actualId]);
|
||||
const displayYear = useMemo(() => {
|
||||
if (!actualYear) return '';
|
||||
const normalized = actualYear.trim();
|
||||
if (!normalized || normalized === 'unknown') return '';
|
||||
const digits = normalized.replace(/\D/g, '');
|
||||
if (!digits) return normalized;
|
||||
return digits.slice(-2).padStart(2, '0');
|
||||
}, [actualYear]);
|
||||
|
||||
// 获取收藏状态(搜索结果页面不检查)
|
||||
useEffect(() => {
|
||||
if (from === 'douban' || from === 'search' || !actualSource || !actualId) return;
|
||||
|
||||
const fetchFavoriteStatus = async () => {
|
||||
try {
|
||||
const fav = await isFavorited(actualSource, actualId);
|
||||
setFavorited(fav);
|
||||
} catch (err) {
|
||||
throw new Error('检查收藏状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
fetchFavoriteStatus();
|
||||
|
||||
// 监听收藏状态更新事件
|
||||
const storageKey = generateStorageKey(actualSource, actualId);
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'favoritesUpdated',
|
||||
(newFavorites: Record<string, any>) => {
|
||||
// 检查当前项目是否在新的收藏列表中
|
||||
const isNowFavorited = !!newFavorites[storageKey];
|
||||
setFavorited(isNowFavorited);
|
||||
}
|
||||
// 可外部修改的可控字段
|
||||
const [dynamicEpisodes, setDynamicEpisodes] = useState<number | undefined>(
|
||||
episodes
|
||||
);
|
||||
const [dynamicSourceNames, setDynamicSourceNames] = useState<
|
||||
string[] | undefined
|
||||
>(source_names);
|
||||
const [dynamicDoubanId, setDynamicDoubanId] = useState<number | undefined>(
|
||||
douban_id
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [from, actualSource, actualId]);
|
||||
useEffect(() => {
|
||||
setDynamicEpisodes(episodes);
|
||||
}, [episodes]);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (from === 'douban' || !actualSource || !actualId) return;
|
||||
useEffect(() => {
|
||||
setDynamicSourceNames(source_names);
|
||||
}, [source_names]);
|
||||
|
||||
try {
|
||||
// 确定当前收藏状态
|
||||
const currentFavorited = from === 'search' ? searchFavorited : favorited;
|
||||
useEffect(() => {
|
||||
setDynamicDoubanId(douban_id);
|
||||
}, [douban_id]);
|
||||
|
||||
if (currentFavorited) {
|
||||
// 如果已收藏,删除收藏
|
||||
await deleteFavorite(actualSource, actualId);
|
||||
if (from === 'search') {
|
||||
setSearchFavorited(false);
|
||||
} else {
|
||||
setFavorited(false);
|
||||
}
|
||||
} else {
|
||||
// 如果未收藏,添加收藏
|
||||
await saveFavorite(actualSource, actualId, {
|
||||
title: actualTitle,
|
||||
source_name: source_name || '',
|
||||
year: actualYear || '',
|
||||
cover: actualPoster,
|
||||
total_episodes: actualEpisodes ?? 1,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
if (from === 'search') {
|
||||
setSearchFavorited(true);
|
||||
} else {
|
||||
setFavorited(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error('切换收藏状态失败');
|
||||
useEffect(() => {
|
||||
setDisplayPoster(processedPoster);
|
||||
}, [processedPoster]);
|
||||
|
||||
const bangumiImageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (bangumiImageTimeoutRef.current) {
|
||||
clearTimeout(bangumiImageTimeoutRef.current);
|
||||
bangumiImageTimeoutRef.current = null;
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
if (!actualPoster) return;
|
||||
|
||||
const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster);
|
||||
if (!bangumiFallbackPoster || displayPoster === bangumiFallbackPoster) {
|
||||
return;
|
||||
}
|
||||
|
||||
bangumiImageTimeoutRef.current = setTimeout(() => {
|
||||
markBangumiImageFallbackActive();
|
||||
setDisplayPoster((current) =>
|
||||
current === bangumiFallbackPoster ? current : bangumiFallbackPoster
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (bangumiImageTimeoutRef.current) {
|
||||
clearTimeout(bangumiImageTimeoutRef.current);
|
||||
bangumiImageTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [actualPoster, displayPoster]);
|
||||
|
||||
const clearBangumiImageTimeout = useCallback(() => {
|
||||
if (bangumiImageTimeoutRef.current) {
|
||||
clearTimeout(bangumiImageTimeoutRef.current);
|
||||
bangumiImageTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
|
||||
setSourceNames: (names?: string[]) => setDynamicSourceNames(names),
|
||||
setDoubanId: (id?: number) => setDynamicDoubanId(id),
|
||||
}));
|
||||
|
||||
const actualSource = source;
|
||||
const actualId = id;
|
||||
const actualDoubanId = dynamicDoubanId;
|
||||
const actualEpisodes = dynamicEpisodes;
|
||||
const actualYear = year;
|
||||
const actualQuery = query || '';
|
||||
const actualSearchType = type;
|
||||
const isDirectPlaySource = actualSource === 'directplay';
|
||||
const directLinkUrl = useMemo(() => {
|
||||
if (!isDirectPlaySource || !actualId) return '';
|
||||
try {
|
||||
return base58Decode(actualId);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [isDirectPlaySource, actualId]);
|
||||
const displayYear = useMemo(() => {
|
||||
if (!actualYear) return '';
|
||||
const normalized = actualYear.trim();
|
||||
if (!normalized || normalized === 'unknown') return '';
|
||||
const digits = normalized.replace(/\D/g, '');
|
||||
if (!digits) return normalized;
|
||||
return digits.slice(-2).padStart(2, '0');
|
||||
}, [actualYear]);
|
||||
|
||||
// 获取收藏状态(搜索结果页面不检查)
|
||||
useEffect(() => {
|
||||
if (from === 'douban' || from === 'search' || !actualSource || !actualId)
|
||||
return;
|
||||
|
||||
const fetchFavoriteStatus = async () => {
|
||||
try {
|
||||
const fav = await isFavorited(actualSource, actualId);
|
||||
setFavorited(fav);
|
||||
} catch (err) {
|
||||
throw new Error('检查收藏状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
fetchFavoriteStatus();
|
||||
|
||||
// 监听收藏状态更新事件
|
||||
const storageKey = generateStorageKey(actualSource, actualId);
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'favoritesUpdated',
|
||||
(newFavorites: Record<string, any>) => {
|
||||
// 检查当前项目是否在新的收藏列表中
|
||||
const isNowFavorited = !!newFavorites[storageKey];
|
||||
setFavorited(isNowFavorited);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [from, actualSource, actualId]);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (from === 'douban' || !actualSource || !actualId) return;
|
||||
|
||||
try {
|
||||
// 确定当前收藏状态
|
||||
const currentFavorited =
|
||||
from === 'search' ? searchFavorited : favorited;
|
||||
|
||||
if (currentFavorited) {
|
||||
// 如果已收藏,删除收藏
|
||||
await deleteFavorite(actualSource, actualId);
|
||||
if (from === 'search') {
|
||||
setSearchFavorited(false);
|
||||
} else {
|
||||
setFavorited(false);
|
||||
}
|
||||
} else {
|
||||
// 如果未收藏,添加收藏
|
||||
await saveFavorite(actualSource, actualId, {
|
||||
title: actualTitle,
|
||||
source_name: source_name || '',
|
||||
year: actualYear || '',
|
||||
cover: actualPoster,
|
||||
total_episodes: actualEpisodes ?? 1,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
if (from === 'search') {
|
||||
setSearchFavorited(true);
|
||||
} else {
|
||||
setFavorited(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error('切换收藏状态失败');
|
||||
}
|
||||
},
|
||||
[
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
actualTitle,
|
||||
source_name,
|
||||
actualYear,
|
||||
actualPoster,
|
||||
actualEpisodes,
|
||||
favorited,
|
||||
searchFavorited,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDeleteRecord = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (from !== 'playrecord' || !actualSource || !actualId) return;
|
||||
try {
|
||||
await deletePlayRecord(actualSource, actualId);
|
||||
onDelete?.();
|
||||
} catch (err) {
|
||||
throw new Error('删除播放记录失败');
|
||||
}
|
||||
},
|
||||
[from, actualSource, actualId, onDelete]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// 即将上映的电影:单击显示上映倒计时提示,不跳转
|
||||
if (isUpcoming) {
|
||||
setShowUpcomingInfo(true);
|
||||
// 2秒后自动隐藏
|
||||
setTimeout(() => {
|
||||
setShowUpcomingInfo(false);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
onBeforeNavigate?.();
|
||||
|
||||
if (origin === 'live' && actualSource && actualId) {
|
||||
// 直播内容跳转到直播页面
|
||||
const url = `/live?source=${actualSource.replace(
|
||||
'live_',
|
||||
''
|
||||
)}&id=${actualId.replace('live_', '')}`;
|
||||
router.push(url);
|
||||
} else if (
|
||||
from === 'douban' ||
|
||||
from === 'tmdb' ||
|
||||
(isAggregate && !actualSource && !actualId)
|
||||
) {
|
||||
// 检测当前是否在 play 页面
|
||||
const isCurrentlyOnPlayPage =
|
||||
typeof window !== 'undefined' && window.location.pathname === '/play';
|
||||
|
||||
let url = `/play?title=${encodeURIComponent(actualTitle.trim())}${
|
||||
actualYear ? `&year=${actualYear}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${
|
||||
isAggregate ? '&prefer=true' : ''
|
||||
}${
|
||||
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}`;
|
||||
|
||||
if (isCurrentlyOnPlayPage) {
|
||||
// 在 play 页面内,添加 _reload 参数强制刷新
|
||||
url += `&_reload=${Date.now()}`;
|
||||
window.location.href = url;
|
||||
} else {
|
||||
// 不在 play 页面,正常跳转
|
||||
router.push(url);
|
||||
}
|
||||
} else if (actualSource && actualId) {
|
||||
// 检测当前是否在 play 页面
|
||||
const isCurrentlyOnPlayPage =
|
||||
typeof window !== 'undefined' && window.location.pathname === '/play';
|
||||
|
||||
let url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||
actualTitle
|
||||
)}${actualYear ? `&year=${actualYear}` : ''}${
|
||||
isAggregate ? '&prefer=true' : ''
|
||||
}${
|
||||
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
||||
|
||||
if (isCurrentlyOnPlayPage) {
|
||||
// 在 play 页面内,添加 _reload 参数强制刷新
|
||||
url += `&_reload=${Date.now()}`;
|
||||
window.location.href = url;
|
||||
} else {
|
||||
// 不在 play 页面,正常跳转
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isUpcoming,
|
||||
origin,
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
router,
|
||||
actualTitle,
|
||||
actualYear,
|
||||
isAggregate,
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
onBeforeNavigate,
|
||||
]);
|
||||
|
||||
// 新标签页播放处理函数
|
||||
const handlePlayInNewTab = useCallback(() => {
|
||||
// 即将上映的电影不跳转
|
||||
if (isUpcoming) {
|
||||
return;
|
||||
}
|
||||
|
||||
onBeforeNavigate?.();
|
||||
|
||||
if (origin === 'live' && actualSource && actualId) {
|
||||
// 直播内容跳转到直播页面
|
||||
const url = `/live?source=${actualSource.replace(
|
||||
'live_',
|
||||
''
|
||||
)}&id=${actualId.replace('live_', '')}`;
|
||||
window.open(url, '_blank');
|
||||
} else if (
|
||||
from === 'douban' ||
|
||||
from === 'tmdb' ||
|
||||
(isAggregate && !actualSource && !actualId)
|
||||
) {
|
||||
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${
|
||||
actualYear ? `&year=${actualYear}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${
|
||||
isAggregate ? '&prefer=true' : ''
|
||||
}${
|
||||
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}`;
|
||||
window.open(url, '_blank');
|
||||
} else if (actualSource && actualId) {
|
||||
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||
actualTitle
|
||||
)}${actualYear ? `&year=${actualYear}` : ''}${
|
||||
isAggregate ? '&prefer=true' : ''
|
||||
}${
|
||||
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}, [
|
||||
isUpcoming,
|
||||
origin,
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
actualTitle,
|
||||
source_name,
|
||||
actualYear,
|
||||
actualPoster,
|
||||
actualEpisodes,
|
||||
favorited,
|
||||
isAggregate,
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
onBeforeNavigate,
|
||||
]);
|
||||
|
||||
// 检查搜索结果的收藏状态
|
||||
const checkSearchFavoriteStatus = useCallback(async () => {
|
||||
if (
|
||||
from === 'search' &&
|
||||
!isAggregate &&
|
||||
actualSource &&
|
||||
actualId &&
|
||||
searchFavorited === null
|
||||
) {
|
||||
try {
|
||||
const fav = await isFavorited(actualSource, actualId);
|
||||
setSearchFavorited(fav);
|
||||
} catch (err) {
|
||||
setSearchFavorited(false);
|
||||
}
|
||||
}
|
||||
}, [from, isAggregate, actualSource, actualId, searchFavorited]);
|
||||
|
||||
// 长按操作
|
||||
const handleLongPress = useCallback(() => {
|
||||
if (!showMobileActions) {
|
||||
// 防止重复触发
|
||||
// 立即显示菜单,避免等待数据加载导致动画卡顿
|
||||
setShowMobileActions(true);
|
||||
|
||||
// 异步检查收藏状态,不阻塞菜单显示
|
||||
if (
|
||||
from === 'search' &&
|
||||
!isAggregate &&
|
||||
actualSource &&
|
||||
actualId &&
|
||||
searchFavorited === null
|
||||
) {
|
||||
checkSearchFavoriteStatus();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
showMobileActions,
|
||||
from,
|
||||
isAggregate,
|
||||
actualSource,
|
||||
actualId,
|
||||
searchFavorited,
|
||||
]
|
||||
);
|
||||
checkSearchFavoriteStatus,
|
||||
]);
|
||||
|
||||
const handleDeleteRecord = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (from !== 'playrecord' || !actualSource || !actualId) return;
|
||||
try {
|
||||
await deletePlayRecord(actualSource, actualId);
|
||||
onDelete?.();
|
||||
} catch (err) {
|
||||
throw new Error('删除播放记录失败');
|
||||
// 长按手势hook
|
||||
const longPressProps = useLongPress({
|
||||
onLongPress: handleLongPress,
|
||||
onClick: handleClick, // 保持点击播放功能
|
||||
longPressDelay: 500,
|
||||
});
|
||||
|
||||
// 计算距离上映的天数(使用本地时区)
|
||||
const daysUntilRelease = useMemo(() => {
|
||||
if (!isUpcoming || !releaseDate) return null;
|
||||
|
||||
// 获取今天的本地日期(午夜)
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(
|
||||
today.getMonth() + 1
|
||||
).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 将日期字符串解析为本地时区的日期对象
|
||||
// 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题
|
||||
const [releaseYear, releaseMonth, releaseDay] = releaseDate
|
||||
.split('-')
|
||||
.map(Number);
|
||||
const release = new Date(releaseYear, releaseMonth - 1, releaseDay);
|
||||
|
||||
const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number);
|
||||
const todayDate = new Date(todayYear, todayMonth - 1, todayDay);
|
||||
|
||||
const diffTime = release.getTime() - todayDate.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return diffDays;
|
||||
}, [isUpcoming, releaseDate]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
const configs = {
|
||||
playrecord: {
|
||||
showSourceName: true,
|
||||
showProgress: true,
|
||||
showPlayButton: true,
|
||||
showHeart: true,
|
||||
showCheckCircle: true,
|
||||
showDoubanLink: false,
|
||||
showRating: false,
|
||||
showYear: false,
|
||||
},
|
||||
favorite: {
|
||||
showSourceName: true,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: true,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: false,
|
||||
showRating: false,
|
||||
showYear: false,
|
||||
},
|
||||
search: {
|
||||
showSourceName: true,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: true, // 移动端菜单中需要显示收藏选项
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: true, // 移动端菜单中显示豆瓣链接
|
||||
showRating: !!rate,
|
||||
showYear: true,
|
||||
},
|
||||
douban: {
|
||||
showSourceName: false,
|
||||
showProgress: false,
|
||||
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
||||
showHeart: false,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: false,
|
||||
showRating: !!rate,
|
||||
showYear: false,
|
||||
},
|
||||
tmdb: {
|
||||
showSourceName: false,
|
||||
showProgress: false,
|
||||
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
||||
showHeart: false,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: false,
|
||||
showRating: !!rate,
|
||||
showYear: false,
|
||||
},
|
||||
'source-search': {
|
||||
showSourceName: false,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: true,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: true,
|
||||
showRating: !!rate,
|
||||
showYear: true,
|
||||
},
|
||||
};
|
||||
return configs[from] || configs.search;
|
||||
}, [from, isAggregate, douban_id, rate, isUpcoming]);
|
||||
|
||||
// 移动端操作菜单配置
|
||||
const mobileActions = useMemo(() => {
|
||||
const actions = [];
|
||||
|
||||
// 播放操作
|
||||
if (config.showPlayButton) {
|
||||
actions.push({
|
||||
id: 'play',
|
||||
label: origin === 'live' ? '观看直播' : '播放',
|
||||
icon: <PlayCircleIcon size={20} />,
|
||||
onClick: handleClick,
|
||||
color: 'primary' as const,
|
||||
});
|
||||
|
||||
// 新标签页播放
|
||||
actions.push({
|
||||
id: 'play-new-tab',
|
||||
label: origin === 'live' ? '新标签页观看' : '新标签页播放',
|
||||
icon: <ExternalLink size={20} />,
|
||||
onClick: handlePlayInNewTab,
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
},
|
||||
[from, actualSource, actualId, onDelete]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// 即将上映的电影:单击显示上映倒计时提示,不跳转
|
||||
if (isUpcoming) {
|
||||
setShowUpcomingInfo(true);
|
||||
// 2秒后自动隐藏
|
||||
setTimeout(() => {
|
||||
setShowUpcomingInfo(false);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
|
||||
|
||||
onBeforeNavigate?.();
|
||||
// 收藏/取消收藏操作
|
||||
if (
|
||||
config.showHeart &&
|
||||
from !== 'douban' &&
|
||||
from !== 'tmdb' &&
|
||||
actualSource &&
|
||||
actualId
|
||||
) {
|
||||
const currentFavorited =
|
||||
from === 'search' ? searchFavorited : favorited;
|
||||
|
||||
if (origin === 'live' && actualSource && actualId) {
|
||||
// 直播内容跳转到直播页面
|
||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||
router.push(url);
|
||||
} else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) {
|
||||
// 检测当前是否在 play 页面
|
||||
const isCurrentlyOnPlayPage = typeof window !== 'undefined' && window.location.pathname === '/play';
|
||||
|
||||
let url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
||||
|
||||
if (isCurrentlyOnPlayPage) {
|
||||
// 在 play 页面内,添加 _reload 参数强制刷新
|
||||
url += `&_reload=${Date.now()}`;
|
||||
window.location.href = url;
|
||||
} else {
|
||||
// 不在 play 页面,正常跳转
|
||||
router.push(url);
|
||||
}
|
||||
} else if (actualSource && actualId) {
|
||||
// 检测当前是否在 play 页面
|
||||
const isCurrentlyOnPlayPage = typeof window !== 'undefined' && window.location.pathname === '/play';
|
||||
|
||||
let url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||
actualTitle
|
||||
)}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : ''
|
||||
}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
||||
|
||||
if (isCurrentlyOnPlayPage) {
|
||||
// 在 play 页面内,添加 _reload 参数强制刷新
|
||||
url += `&_reload=${Date.now()}`;
|
||||
window.location.href = url;
|
||||
} else {
|
||||
// 不在 play 页面,正常跳转
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isUpcoming,
|
||||
origin,
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
router,
|
||||
actualTitle,
|
||||
actualYear,
|
||||
isAggregate,
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
onBeforeNavigate,
|
||||
]);
|
||||
|
||||
// 新标签页播放处理函数
|
||||
const handlePlayInNewTab = useCallback(() => {
|
||||
// 即将上映的电影不跳转
|
||||
if (isUpcoming) {
|
||||
return;
|
||||
}
|
||||
|
||||
onBeforeNavigate?.();
|
||||
|
||||
if (origin === 'live' && actualSource && actualId) {
|
||||
// 直播内容跳转到直播页面
|
||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||
window.open(url, '_blank');
|
||||
} else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) {
|
||||
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
||||
window.open(url, '_blank');
|
||||
} else if (actualSource && actualId) {
|
||||
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||
actualTitle
|
||||
)}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : ''
|
||||
}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}, [
|
||||
isUpcoming,
|
||||
origin,
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
actualTitle,
|
||||
actualYear,
|
||||
isAggregate,
|
||||
actualQuery,
|
||||
actualSearchType,
|
||||
onBeforeNavigate,
|
||||
]);
|
||||
|
||||
// 检查搜索结果的收藏状态
|
||||
const checkSearchFavoriteStatus = useCallback(async () => {
|
||||
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
|
||||
try {
|
||||
const fav = await isFavorited(actualSource, actualId);
|
||||
setSearchFavorited(fav);
|
||||
} catch (err) {
|
||||
setSearchFavorited(false);
|
||||
}
|
||||
}
|
||||
}, [from, isAggregate, actualSource, actualId, searchFavorited]);
|
||||
|
||||
// 长按操作
|
||||
const handleLongPress = useCallback(() => {
|
||||
if (!showMobileActions) { // 防止重复触发
|
||||
// 立即显示菜单,避免等待数据加载导致动画卡顿
|
||||
setShowMobileActions(true);
|
||||
|
||||
// 异步检查收藏状态,不阻塞菜单显示
|
||||
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
|
||||
checkSearchFavoriteStatus();
|
||||
}
|
||||
}
|
||||
}, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]);
|
||||
|
||||
// 长按手势hook
|
||||
const longPressProps = useLongPress({
|
||||
onLongPress: handleLongPress,
|
||||
onClick: handleClick, // 保持点击播放功能
|
||||
longPressDelay: 500,
|
||||
});
|
||||
|
||||
// 计算距离上映的天数(使用本地时区)
|
||||
const daysUntilRelease = useMemo(() => {
|
||||
if (!isUpcoming || !releaseDate) return null;
|
||||
|
||||
// 获取今天的本地日期(午夜)
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 将日期字符串解析为本地时区的日期对象
|
||||
// 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题
|
||||
const [releaseYear, releaseMonth, releaseDay] = releaseDate.split('-').map(Number);
|
||||
const release = new Date(releaseYear, releaseMonth - 1, releaseDay);
|
||||
|
||||
const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number);
|
||||
const todayDate = new Date(todayYear, todayMonth - 1, todayDay);
|
||||
|
||||
const diffTime = release.getTime() - todayDate.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return diffDays;
|
||||
}, [isUpcoming, releaseDate]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
const configs = {
|
||||
playrecord: {
|
||||
showSourceName: true,
|
||||
showProgress: true,
|
||||
showPlayButton: true,
|
||||
showHeart: true,
|
||||
showCheckCircle: true,
|
||||
showDoubanLink: false,
|
||||
showRating: false,
|
||||
showYear: false,
|
||||
},
|
||||
favorite: {
|
||||
showSourceName: true,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: true,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: false,
|
||||
showRating: false,
|
||||
showYear: false,
|
||||
},
|
||||
search: {
|
||||
showSourceName: true,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: true, // 移动端菜单中需要显示收藏选项
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: true, // 移动端菜单中显示豆瓣链接
|
||||
showRating: !!rate,
|
||||
showYear: true,
|
||||
},
|
||||
douban: {
|
||||
showSourceName: false,
|
||||
showProgress: false,
|
||||
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
||||
showHeart: false,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: false,
|
||||
showRating: !!rate,
|
||||
showYear: false,
|
||||
},
|
||||
tmdb: {
|
||||
showSourceName: false,
|
||||
showProgress: false,
|
||||
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
||||
showHeart: false,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: false,
|
||||
showRating: !!rate,
|
||||
showYear: false,
|
||||
},
|
||||
'source-search': {
|
||||
showSourceName: false,
|
||||
showProgress: false,
|
||||
showPlayButton: true,
|
||||
showHeart: true,
|
||||
showCheckCircle: false,
|
||||
showDoubanLink: true,
|
||||
showRating: !!rate,
|
||||
showYear: true,
|
||||
},
|
||||
};
|
||||
return configs[from] || configs.search;
|
||||
}, [from, isAggregate, douban_id, rate, isUpcoming]);
|
||||
|
||||
// 移动端操作菜单配置
|
||||
const mobileActions = useMemo(() => {
|
||||
const actions = [];
|
||||
|
||||
// 播放操作
|
||||
if (config.showPlayButton) {
|
||||
actions.push({
|
||||
id: 'play',
|
||||
label: origin === 'live' ? '观看直播' : '播放',
|
||||
icon: <PlayCircleIcon size={20} />,
|
||||
onClick: handleClick,
|
||||
color: 'primary' as const,
|
||||
});
|
||||
|
||||
// 新标签页播放
|
||||
actions.push({
|
||||
id: 'play-new-tab',
|
||||
label: origin === 'live' ? '新标签页观看' : '新标签页播放',
|
||||
icon: <ExternalLink size={20} />,
|
||||
onClick: handlePlayInNewTab,
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
|
||||
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
|
||||
|
||||
// 收藏/取消收藏操作
|
||||
if (config.showHeart && from !== 'douban' && from !== 'tmdb' && actualSource && actualId) {
|
||||
const currentFavorited = from === 'search' ? searchFavorited : favorited;
|
||||
|
||||
if (from === 'search') {
|
||||
// 搜索结果:根据加载状态显示不同的选项
|
||||
if (searchFavorited !== null) {
|
||||
// 已加载完成,显示实际的收藏状态
|
||||
if (from === 'search') {
|
||||
// 搜索结果:根据加载状态显示不同的选项
|
||||
if (searchFavorited !== null) {
|
||||
// 已加载完成,显示实际的收藏状态
|
||||
actions.push({
|
||||
id: 'favorite',
|
||||
label: currentFavorited ? '取消收藏' : '添加收藏',
|
||||
icon: currentFavorited ? (
|
||||
<Heart size={20} className='fill-red-600 stroke-red-600' />
|
||||
) : (
|
||||
<Heart size={20} className='fill-transparent stroke-red-500' />
|
||||
),
|
||||
onClick: () => {
|
||||
const mockEvent = {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent;
|
||||
handleToggleFavorite(mockEvent);
|
||||
},
|
||||
color: currentFavorited
|
||||
? ('danger' as const)
|
||||
: ('default' as const),
|
||||
});
|
||||
} else {
|
||||
// 正在加载中,显示占位项
|
||||
actions.push({
|
||||
id: 'favorite-loading',
|
||||
label: '收藏加载中...',
|
||||
icon: <Heart size={20} />,
|
||||
onClick: () => {}, // 加载中时不响应点击
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 非搜索结果:直接显示收藏选项
|
||||
actions.push({
|
||||
id: 'favorite',
|
||||
label: currentFavorited ? '取消收藏' : '添加收藏',
|
||||
icon: currentFavorited ? (
|
||||
<Heart size={20} className="fill-red-600 stroke-red-600" />
|
||||
<Heart size={20} className='fill-red-600 stroke-red-600' />
|
||||
) : (
|
||||
<Heart size={20} className="fill-transparent stroke-red-500" />
|
||||
<Heart size={20} className='fill-transparent stroke-red-500' />
|
||||
),
|
||||
onClick: () => {
|
||||
const mockEvent = {
|
||||
preventDefault: () => { },
|
||||
stopPropagation: () => { },
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent;
|
||||
handleToggleFavorite(mockEvent);
|
||||
},
|
||||
color: currentFavorited ? ('danger' as const) : ('default' as const),
|
||||
});
|
||||
} else {
|
||||
// 正在加载中,显示占位项
|
||||
actions.push({
|
||||
id: 'favorite-loading',
|
||||
label: '收藏加载中...',
|
||||
icon: <Heart size={20} />,
|
||||
onClick: () => { }, // 加载中时不响应点击
|
||||
disabled: true,
|
||||
color: currentFavorited
|
||||
? ('danger' as const)
|
||||
: ('default' as const),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 非搜索结果:直接显示收藏选项
|
||||
}
|
||||
|
||||
// 删除播放记录操作
|
||||
if (
|
||||
config.showCheckCircle &&
|
||||
from === 'playrecord' &&
|
||||
actualSource &&
|
||||
actualId
|
||||
) {
|
||||
actions.push({
|
||||
id: 'favorite',
|
||||
label: currentFavorited ? '取消收藏' : '添加收藏',
|
||||
icon: currentFavorited ? (
|
||||
<Heart size={20} className="fill-red-600 stroke-red-600" />
|
||||
) : (
|
||||
<Heart size={20} className="fill-transparent stroke-red-500" />
|
||||
),
|
||||
id: 'delete',
|
||||
label: '删除记录',
|
||||
icon: <Trash2 size={20} />,
|
||||
onClick: () => {
|
||||
const mockEvent = {
|
||||
preventDefault: () => { },
|
||||
stopPropagation: () => { },
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as React.MouseEvent;
|
||||
handleToggleFavorite(mockEvent);
|
||||
handleDeleteRecord(mockEvent);
|
||||
},
|
||||
color: currentFavorited ? ('danger' as const) : ('default' as const),
|
||||
color: 'danger' as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 删除播放记录操作
|
||||
if (config.showCheckCircle && from === 'playrecord' && actualSource && actualId) {
|
||||
actions.push({
|
||||
id: 'delete',
|
||||
label: '删除记录',
|
||||
icon: <Trash2 size={20} />,
|
||||
onClick: () => {
|
||||
const mockEvent = {
|
||||
preventDefault: () => { },
|
||||
stopPropagation: () => { },
|
||||
} as React.MouseEvent;
|
||||
handleDeleteRecord(mockEvent);
|
||||
},
|
||||
color: 'danger' as const,
|
||||
});
|
||||
}
|
||||
// 豆瓣链接操作
|
||||
if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) {
|
||||
actions.push({
|
||||
id: 'douban',
|
||||
label: isBangumi ? 'Bangumi 详情' : '豆瓣详情',
|
||||
icon: <Link size={20} />,
|
||||
onClick: () => {
|
||||
const url = isBangumi
|
||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
|
||||
// 豆瓣链接操作
|
||||
if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) {
|
||||
actions.push({
|
||||
id: 'douban',
|
||||
label: isBangumi ? 'Bangumi 详情' : '豆瓣详情',
|
||||
icon: <Link size={20} />,
|
||||
onClick: () => {
|
||||
const url = isBangumi
|
||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
// 详情页面按钮(直播源不显示详情)
|
||||
if (origin !== 'live') {
|
||||
actions.push({
|
||||
id: 'detail',
|
||||
label: '详情',
|
||||
icon: <Info size={20} />,
|
||||
onClick: () => {
|
||||
setShowMobileActions(false);
|
||||
// 延迟打开 DetailPanel,确保 MobileActionSheet 完全清理完成
|
||||
setTimeout(() => {
|
||||
setShowDetailPanel(true);
|
||||
}, 250);
|
||||
},
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
|
||||
// 详情页面按钮(直播源不显示详情)
|
||||
if (origin !== 'live') {
|
||||
actions.push({
|
||||
id: 'detail',
|
||||
label: '详情',
|
||||
icon: <Info size={20} />,
|
||||
onClick: () => {
|
||||
setShowMobileActions(false);
|
||||
// 延迟打开 DetailPanel,确保 MobileActionSheet 完全清理完成
|
||||
setTimeout(() => {
|
||||
setShowDetailPanel(true);
|
||||
}, 250);
|
||||
},
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
// AI问片功能
|
||||
if (aiEnabled && actualTitle) {
|
||||
actions.push({
|
||||
id: 'ai-chat',
|
||||
label: 'AI问片',
|
||||
icon: <Sparkles size={20} />,
|
||||
onClick: () => {
|
||||
setShowMobileActions(false);
|
||||
// 延迟打开 AIChatPanel,确保 MobileActionSheet 完全清理完成
|
||||
setTimeout(() => {
|
||||
setShowAIChat(true);
|
||||
}, 250);
|
||||
},
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
|
||||
// AI问片功能
|
||||
if (aiEnabled && actualTitle) {
|
||||
actions.push({
|
||||
id: 'ai-chat',
|
||||
label: 'AI问片',
|
||||
icon: <Sparkles size={20} />,
|
||||
onClick: () => {
|
||||
setShowMobileActions(false);
|
||||
// 延迟打开 AIChatPanel,确保 MobileActionSheet 完全清理完成
|
||||
setTimeout(() => {
|
||||
setShowAIChat(true);
|
||||
}, 250);
|
||||
},
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}, [
|
||||
config,
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
favorited,
|
||||
searchFavorited,
|
||||
actualDoubanId,
|
||||
isBangumi,
|
||||
isAggregate,
|
||||
dynamicSourceNames,
|
||||
handleClick,
|
||||
handleToggleFavorite,
|
||||
handleDeleteRecord,
|
||||
handlePlayInNewTab,
|
||||
aiEnabled,
|
||||
actualTitle,
|
||||
]);
|
||||
|
||||
return actions;
|
||||
}, [
|
||||
config,
|
||||
from,
|
||||
actualSource,
|
||||
actualId,
|
||||
favorited,
|
||||
searchFavorited,
|
||||
actualDoubanId,
|
||||
isBangumi,
|
||||
isAggregate,
|
||||
dynamicSourceNames,
|
||||
handleClick,
|
||||
handleToggleFavorite,
|
||||
handleDeleteRecord,
|
||||
handlePlayInNewTab,
|
||||
aiEnabled,
|
||||
actualTitle,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`group relative w-full rounded-lg bg-transparent transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500] ${isUpcoming ? 'cursor-default' : 'cursor-pointer'} ${
|
||||
showUpcomingInfo ? 'scale-[1.05] z-[500]' : ''
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
{...longPressProps}
|
||||
style={{
|
||||
// 禁用所有默认的长按和选择效果
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
touchAction: 'manipulation',
|
||||
// 禁用右键菜单和长按菜单
|
||||
pointerEvents: 'auto',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
// 阻止默认右键菜单
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 右键弹出操作菜单
|
||||
setShowMobileActions(true);
|
||||
|
||||
// 异步检查收藏状态,不阻塞菜单显示
|
||||
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
|
||||
checkSearchFavoriteStatus();
|
||||
}
|
||||
|
||||
return false;
|
||||
}}
|
||||
|
||||
onDragStart={(e) => {
|
||||
// 阻止拖拽
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 海报容器 */}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg ${origin === 'live' ? 'ring-1 ring-gray-300/80 dark:ring-gray-600/80' : ''} ${
|
||||
orientation === 'horizontal'
|
||||
? 'aspect-[3/2]'
|
||||
: 'aspect-[2/3]'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
className={`group relative w-full rounded-lg bg-transparent transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500] ${
|
||||
isUpcoming ? 'cursor-default' : 'cursor-pointer'
|
||||
} ${showUpcomingInfo ? 'scale-[1.05] z-[500]' : ''}`}
|
||||
onClick={handleClick}
|
||||
{...longPressProps}
|
||||
style={
|
||||
{
|
||||
// 禁用所有默认的长按和选择效果
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
touchAction: 'manipulation',
|
||||
// 禁用右键菜单和长按菜单
|
||||
pointerEvents: 'auto',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
// 阻止默认右键菜单
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 右键弹出操作菜单
|
||||
setShowMobileActions(true);
|
||||
|
||||
// 异步检查收藏状态,不阻塞菜单显示
|
||||
if (
|
||||
from === 'search' &&
|
||||
!isAggregate &&
|
||||
actualSource &&
|
||||
actualId &&
|
||||
searchFavorited === null
|
||||
) {
|
||||
checkSearchFavoriteStatus();
|
||||
}
|
||||
|
||||
return false;
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
// 阻止拖拽
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 骨架屏 */}
|
||||
{!isLoading && !isDirectPlaySource && <ImagePlaceholder aspectRatio={orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'} />}
|
||||
{isDirectPlaySource ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'>
|
||||
<Link className='w-8 h-8 text-blue-500' />
|
||||
</div>
|
||||
) : (isNetdiskSource(actualSource) && !actualPoster && displayPoster === netdiskPosterPlaceholder) ? (
|
||||
<div className='absolute inset-0 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400'>
|
||||
<Cloud className='w-10 h-10 opacity-80' />
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src={displayPoster}
|
||||
alt={actualTitle}
|
||||
fill
|
||||
className={origin === 'live' ? 'object-contain' : orientation === 'horizontal' ? 'object-cover object-center' : 'object-cover'}
|
||||
referrerPolicy='no-referrer'
|
||||
loading='lazy'
|
||||
onLoadingComplete={() => setIsLoading(true)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowImageViewer(true);
|
||||
}}
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallbackPoster = getDoubanImageFallbackUrl(actualPoster);
|
||||
if (fallbackPoster && tryApplyDoubanImageFallback(img, actualPoster)) {
|
||||
setDisplayPoster(fallbackPoster);
|
||||
return;
|
||||
}
|
||||
|
||||
// 图片加载失败时的重试机制
|
||||
if (!img.dataset.retried) {
|
||||
img.dataset.retried = 'true';
|
||||
setTimeout(() => {
|
||||
setDisplayPoster(processedPoster);
|
||||
img.src = processedPoster;
|
||||
}, 2000);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
// 禁用图片的默认长按效果
|
||||
{/* 海报容器 */}
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg ${
|
||||
origin === 'live'
|
||||
? 'ring-1 ring-gray-300/80 dark:ring-gray-600/80'
|
||||
: ''
|
||||
} ${
|
||||
orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
pointerEvents: 'auto', // 改为auto以响应点击事件
|
||||
cursor: 'pointer', // 添加指针样式
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 悬浮遮罩 */}
|
||||
<div
|
||||
className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ease-in-out opacity-0 group-hover:opacity-100'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 播放按钮或上映倒计时 */}
|
||||
{isUpcoming && daysUntilRelease !== null ? (
|
||||
<div
|
||||
data-button="true"
|
||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out ${
|
||||
showUpcomingInfo ? 'opacity-100 scale-100' : 'opacity-0 scale-75'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-xs md:text-sm font-medium shadow-lg'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{daysUntilRelease > 0
|
||||
? `${daysUntilRelease}天后上映`
|
||||
: daysUntilRelease === 0
|
||||
? '今日上映'
|
||||
: '已上映'}
|
||||
>
|
||||
{/* 骨架屏 */}
|
||||
{!isLoading && !isDirectPlaySource && (
|
||||
<ImagePlaceholder
|
||||
aspectRatio={
|
||||
orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isDirectPlaySource ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'>
|
||||
<Link className='w-8 h-8 text-blue-500' />
|
||||
</div>
|
||||
</div>
|
||||
) : config.showPlayButton && (
|
||||
<div
|
||||
data-button="true"
|
||||
className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<PlayCircleIcon
|
||||
size={50}
|
||||
strokeWidth={0.8}
|
||||
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
) : isNetdiskSource(actualSource) &&
|
||||
!actualPoster &&
|
||||
displayPoster === netdiskPosterPlaceholder ? (
|
||||
<div className='absolute inset-0 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400'>
|
||||
<Cloud className='w-10 h-10 opacity-80' />
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src={displayPoster}
|
||||
alt={actualTitle}
|
||||
fill
|
||||
className={
|
||||
origin === 'live'
|
||||
? 'object-contain'
|
||||
: orientation === 'horizontal'
|
||||
? 'object-cover object-center'
|
||||
: 'object-cover'
|
||||
}
|
||||
referrerPolicy='no-referrer'
|
||||
loading='lazy'
|
||||
onLoadingComplete={() => {
|
||||
setIsLoading(true);
|
||||
clearBangumiImageTimeout();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowImageViewer(true);
|
||||
}}
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const doubanFallbackPoster =
|
||||
getDoubanImageFallbackUrl(actualPoster);
|
||||
if (
|
||||
doubanFallbackPoster &&
|
||||
tryApplyDoubanImageFallback(img, actualPoster)
|
||||
) {
|
||||
clearBangumiImageTimeout();
|
||||
setDisplayPoster(doubanFallbackPoster);
|
||||
return;
|
||||
}
|
||||
|
||||
const bangumiFallbackPoster =
|
||||
getBangumiImageFallbackUrl(actualPoster);
|
||||
if (
|
||||
bangumiFallbackPoster &&
|
||||
tryApplyBangumiImageFallback(img, actualPoster)
|
||||
) {
|
||||
clearBangumiImageTimeout();
|
||||
setDisplayPoster(bangumiFallbackPoster);
|
||||
return;
|
||||
}
|
||||
|
||||
// 图片加载失败时的重试机制
|
||||
if (!img.dataset.retried) {
|
||||
img.dataset.retried = 'true';
|
||||
setTimeout(() => {
|
||||
setDisplayPoster(processedPoster);
|
||||
img.src = processedPoster;
|
||||
}, 2000);
|
||||
}
|
||||
}}
|
||||
style={
|
||||
{
|
||||
// 禁用图片的默认长按效果
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
pointerEvents: 'auto', // 改为auto以响应点击事件
|
||||
cursor: 'pointer', // 添加指针样式
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 操作按钮 - 继续观看不显示桌面端悬停按钮 */}
|
||||
{(config.showHeart || config.showCheckCircle) && from !== 'playrecord' && (
|
||||
{/* 悬浮遮罩 */}
|
||||
<div
|
||||
data-button="true"
|
||||
className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out sm:group-hover:opacity-100 sm:group-hover:translate-y-0'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{config.showCheckCircle && (
|
||||
<Trash2
|
||||
onClick={handleDeleteRecord}
|
||||
size={20}
|
||||
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{config.showHeart && from !== 'search' && (
|
||||
<Heart
|
||||
onClick={handleToggleFavorite}
|
||||
size={20}
|
||||
className={`transition-all duration-300 ease-out ${favorited
|
||||
? 'fill-red-600 stroke-red-600'
|
||||
: 'fill-transparent stroke-white hover:stroke-red-400'
|
||||
} hover:scale-[1.1]`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 季度徽章 */}
|
||||
{seasonNumber && (
|
||||
<div
|
||||
className="absolute top-2 left-2 bg-blue-500/80 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90"
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
title={seasonName || `第${seasonNumber}季`}
|
||||
>
|
||||
S{seasonNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 徽章 */}
|
||||
{config.showRating && rate && (
|
||||
<div
|
||||
className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{rate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 竖向模式:顶部直链地址显示 */}
|
||||
{orientation === 'vertical' && isDirectPlaySource && directLinkUrl && (
|
||||
<div
|
||||
className='absolute top-1 left-1 right-1 sm:top-2 sm:left-2 sm:right-2 pt-1 px-1 sm:pt-2 sm:px-2'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='text-[9px] sm:text-[10px] text-yellow-400 line-clamp-2 break-all'
|
||||
style={{
|
||||
className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ease-in-out opacity-0 group-hover:opacity-100'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
title={directLinkUrl}
|
||||
>
|
||||
{directLinkUrl}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actualEpisodes && actualEpisodes > 1 && orientation === 'vertical' && (
|
||||
<div
|
||||
className='absolute top-1 right-1 sm:top-2 sm:right-2 flex flex-col gap-0.5 sm:gap-1.5'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 集数显示 */}
|
||||
<div
|
||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{currentEpisode !== undefined && currentEpisode !== null
|
||||
? `${currentEpisode}/${actualEpisodes}`
|
||||
: `共${actualEpisodes}集`}
|
||||
</div>
|
||||
|
||||
{/* 年份显示 */}
|
||||
{displayYear && (
|
||||
<div
|
||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{displayYear}年
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 竖向模式:来源名称显示在海报右下角 */}
|
||||
{orientation === 'vertical' && config.showSourceName && source_name && !cmsData && (
|
||||
<div
|
||||
className='absolute bottom-1 right-1 sm:bottom-2 sm:right-2'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/60 ${
|
||||
actualSource === 'xiaoya' ? 'border-blue-500' : isNetdiskSource(actualSource) ? 'border-purple-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : origin === 'live' ? 'border-red-500' : 'border-white/60'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{origin === 'live' && (
|
||||
<Radio size={8} className="inline-block text-white/90 mr-0.5" />
|
||||
)}
|
||||
{source_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 豆瓣链接 */}
|
||||
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
|
||||
<a
|
||||
href={
|
||||
isBangumi
|
||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
/>
|
||||
|
||||
{/* 播放按钮或上映倒计时 */}
|
||||
{isUpcoming && daysUntilRelease !== null ? (
|
||||
<div
|
||||
className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
size={16}
|
||||
style={{
|
||||
data-button='true'
|
||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out ${
|
||||
showUpcomingInfo
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-75'
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
pointerEvents: 'none',
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 聚合播放源指示器 */}
|
||||
{isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => {
|
||||
const uniqueSources = Array.from(new Set(dynamicSourceNames));
|
||||
const sourceCount = uniqueSources.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-1 right-1 sm:bottom-2 sm:right-2 transition-all duration-300 ease-in-out delay-75 ${
|
||||
from === 'search' ? 'opacity-100' : 'opacity-0 sm:group-hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative group/sources'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className='bg-gray-700 text-white text-xs font-bold w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-gray-600 hover:scale-[1.1] transition-all duration-300 ease-out cursor-pointer'
|
||||
style={{
|
||||
className='bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-xs md:text-sm font-medium shadow-lg'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{daysUntilRelease > 0
|
||||
? `${daysUntilRelease}天后上映`
|
||||
: daysUntilRelease === 0
|
||||
? '今日上映'
|
||||
: '已上映'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
config.showPlayButton && (
|
||||
<div
|
||||
data-button='true'
|
||||
className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<PlayCircleIcon
|
||||
size={50}
|
||||
strokeWidth={0.8}
|
||||
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{sourceCount}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 播放源详情悬浮框 */}
|
||||
{(() => {
|
||||
// 优先显示的播放源(常见的主流平台)
|
||||
const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+'];
|
||||
|
||||
// 按优先级排序播放源
|
||||
const sortedSources = uniqueSources.sort((a, b) => {
|
||||
const aIndex = prioritySources.indexOf(a);
|
||||
const bIndex = prioritySources.indexOf(b);
|
||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const maxDisplayCount = 6; // 最多显示6个
|
||||
const displaySources = sortedSources.slice(0, maxDisplayCount);
|
||||
const hasMore = sortedSources.length > maxDisplayCount;
|
||||
const remainingCount = sortedSources.length - maxDisplayCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute bottom-full mb-2 opacity-0 invisible group-hover/sources:opacity-100 group-hover/sources:visible transition-all duration-200 ease-out delay-100 pointer-events-none z-50 right-0 sm:right-0 -translate-x-0 sm:translate-x-0'
|
||||
style={{
|
||||
{/* 操作按钮 - 继续观看不显示桌面端悬停按钮 */}
|
||||
{(config.showHeart || config.showCheckCircle) &&
|
||||
from !== 'playrecord' && (
|
||||
<div
|
||||
data-button='true'
|
||||
className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out sm:group-hover:opacity-100 sm:group-hover:translate-y-0'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{config.showCheckCircle && (
|
||||
<Trash2
|
||||
onClick={handleDeleteRecord}
|
||||
size={20}
|
||||
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='bg-gray-800/90 backdrop-blur-sm text-white text-xs sm:text-xs rounded-lg shadow-xl border border-white/10 p-1.5 sm:p-2 min-w-[100px] sm:min-w-[120px] max-w-[140px] sm:max-w-[200px] overflow-hidden'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 单列布局 */}
|
||||
<div className='space-y-0.5 sm:space-y-1'>
|
||||
{displaySources.map((sourceName, index) => (
|
||||
<div key={index} className='flex items-center gap-1 sm:gap-1.5'>
|
||||
<div className='w-0.5 h-0.5 sm:w-1 sm:h-1 bg-blue-400 rounded-full flex-shrink-0'></div>
|
||||
<span className='truncate text-[10px] sm:text-xs leading-tight' title={sourceName}>
|
||||
{sourceName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 显示更多提示 */}
|
||||
{hasMore && (
|
||||
<div className='mt-1 sm:mt-2 pt-1 sm:pt-1.5 border-t border-gray-700/50'>
|
||||
<div className='flex items-center justify-center text-gray-400'>
|
||||
<span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} 播放源</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 小箭头 */}
|
||||
<div className='absolute top-full right-2 sm:right-3 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[4px] sm:border-l-[6px] sm:border-r-[6px] sm:border-t-[6px] border-transparent border-t-gray-800/90'></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{config.showHeart && from !== 'search' && (
|
||||
<Heart
|
||||
onClick={handleToggleFavorite}
|
||||
size={20}
|
||||
className={`transition-all duration-300 ease-out ${
|
||||
favorited
|
||||
? 'fill-red-600 stroke-red-600'
|
||||
: 'fill-transparent stroke-white hover:stroke-red-400'
|
||||
} hover:scale-[1.1]`}
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
|
||||
{/* 横向模式:标题和进度条在海报上 */}
|
||||
{orientation === 'horizontal' && (
|
||||
<>
|
||||
{/* 顶部渐变遮罩 - 用于标题背景 */}
|
||||
{/* 季度徽章 */}
|
||||
{seasonNumber && (
|
||||
<div
|
||||
className='absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 via-black/40 to-transparent pt-2 pb-8 px-2'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
className='absolute top-2 left-2 bg-blue-500/80 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
title={seasonName || `第${seasonNumber}季`}
|
||||
>
|
||||
S{seasonNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 徽章 */}
|
||||
{config.showRating && rate && (
|
||||
<div
|
||||
className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
{rate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 竖向模式:顶部直链地址显示 */}
|
||||
{orientation === 'vertical' &&
|
||||
isDirectPlaySource &&
|
||||
directLinkUrl && (
|
||||
<div
|
||||
className='mb-1'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
className='absolute top-1 left-1 right-1 sm:top-2 sm:left-2 sm:right-2 pt-1 px-1 sm:pt-2 sm:px-2'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className='block text-sm font-bold truncate text-white'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
title={actualTitle}
|
||||
>
|
||||
{actualTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 集数信息 - 只有超过1集时才显示 */}
|
||||
{currentEpisode && actualEpisodes && actualEpisodes > 1 && (
|
||||
<div
|
||||
className='text-xs text-white/90'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
第{currentEpisode}集 · 共{actualEpisodes}集
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 直链地址 */}
|
||||
{isDirectPlaySource && directLinkUrl && (
|
||||
<div
|
||||
className='text-[10px] text-white/75 truncate'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
className='text-[9px] sm:text-[10px] text-yellow-400 line-clamp-2 break-all'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
@@ -1358,305 +1239,783 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
>
|
||||
{directLinkUrl}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部渐变遮罩 - 用于进度条背景 */}
|
||||
{actualEpisodes &&
|
||||
actualEpisodes > 1 &&
|
||||
orientation === 'vertical' && (
|
||||
<div
|
||||
className='absolute top-1 right-1 sm:top-2 sm:right-2 flex flex-col gap-0.5 sm:gap-1.5'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 集数显示 */}
|
||||
<div
|
||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{currentEpisode !== undefined && currentEpisode !== null
|
||||
? `${currentEpisode}/${actualEpisodes}`
|
||||
: `共${actualEpisodes}集`}
|
||||
</div>
|
||||
|
||||
{/* 年份显示 */}
|
||||
{displayYear && (
|
||||
<div
|
||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{displayYear}年
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 竖向模式:来源名称显示在海报右下角 */}
|
||||
{orientation === 'vertical' &&
|
||||
config.showSourceName &&
|
||||
source_name &&
|
||||
!cmsData && (
|
||||
<div
|
||||
className='absolute bottom-1 right-1 sm:bottom-2 sm:right-2'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/60 ${
|
||||
actualSource === 'xiaoya'
|
||||
? 'border-blue-500'
|
||||
: isNetdiskSource(actualSource)
|
||||
? 'border-purple-500'
|
||||
: actualSource === 'openlist' ||
|
||||
actualSource === 'emby' ||
|
||||
actualSource?.startsWith('emby_')
|
||||
? 'border-yellow-500'
|
||||
: origin === 'live'
|
||||
? 'border-red-500'
|
||||
: 'border-white/60'
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{origin === 'live' && (
|
||||
<Radio
|
||||
size={8}
|
||||
className='inline-block text-white/90 mr-0.5'
|
||||
/>
|
||||
)}
|
||||
{source_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 豆瓣链接 */}
|
||||
{config.showDoubanLink &&
|
||||
actualDoubanId &&
|
||||
actualDoubanId !== 0 && (
|
||||
<a
|
||||
href={
|
||||
isBangumi
|
||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
|
||||
}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
size={16}
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
pointerEvents: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 聚合播放源指示器 */}
|
||||
{isAggregate &&
|
||||
dynamicSourceNames &&
|
||||
dynamicSourceNames.length > 0 &&
|
||||
(() => {
|
||||
const uniqueSources = Array.from(new Set(dynamicSourceNames));
|
||||
const sourceCount = uniqueSources.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-1 right-1 sm:bottom-2 sm:right-2 transition-all duration-300 ease-in-out delay-75 ${
|
||||
from === 'search'
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 sm:group-hover:opacity-100'
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative group/sources'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className='bg-gray-700 text-white text-xs font-bold w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-gray-600 hover:scale-[1.1] transition-all duration-300 ease-out cursor-pointer'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{sourceCount}
|
||||
</div>
|
||||
|
||||
{/* 播放源详情悬浮框 */}
|
||||
{(() => {
|
||||
// 优先显示的播放源(常见的主流平台)
|
||||
const prioritySources = [
|
||||
'爱奇艺',
|
||||
'腾讯视频',
|
||||
'优酷',
|
||||
'芒果TV',
|
||||
'哔哩哔哩',
|
||||
'Netflix',
|
||||
'Disney+',
|
||||
];
|
||||
|
||||
// 按优先级排序播放源
|
||||
const sortedSources = uniqueSources.sort((a, b) => {
|
||||
const aIndex = prioritySources.indexOf(a);
|
||||
const bIndex = prioritySources.indexOf(b);
|
||||
if (aIndex !== -1 && bIndex !== -1)
|
||||
return aIndex - bIndex;
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const maxDisplayCount = 6; // 最多显示6个
|
||||
const displaySources = sortedSources.slice(
|
||||
0,
|
||||
maxDisplayCount
|
||||
);
|
||||
const hasMore = sortedSources.length > maxDisplayCount;
|
||||
const remainingCount =
|
||||
sortedSources.length - maxDisplayCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute bottom-full mb-2 opacity-0 invisible group-hover/sources:opacity-100 group-hover/sources:visible transition-all duration-200 ease-out delay-100 pointer-events-none z-50 right-0 sm:right-0 -translate-x-0 sm:translate-x-0'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='bg-gray-800/90 backdrop-blur-sm text-white text-xs sm:text-xs rounded-lg shadow-xl border border-white/10 p-1.5 sm:p-2 min-w-[100px] sm:min-w-[120px] max-w-[140px] sm:max-w-[200px] overflow-hidden'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 单列布局 */}
|
||||
<div className='space-y-0.5 sm:space-y-1'>
|
||||
{displaySources.map((sourceName, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='flex items-center gap-1 sm:gap-1.5'
|
||||
>
|
||||
<div className='w-0.5 h-0.5 sm:w-1 sm:h-1 bg-blue-400 rounded-full flex-shrink-0'></div>
|
||||
<span
|
||||
className='truncate text-[10px] sm:text-xs leading-tight'
|
||||
title={sourceName}
|
||||
>
|
||||
{sourceName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 显示更多提示 */}
|
||||
{hasMore && (
|
||||
<div className='mt-1 sm:mt-2 pt-1 sm:pt-1.5 border-t border-gray-700/50'>
|
||||
<div className='flex items-center justify-center text-gray-400'>
|
||||
<span className='text-[10px] sm:text-xs font-medium'>
|
||||
+{remainingCount} 播放源
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 小箭头 */}
|
||||
<div className='absolute top-full right-2 sm:right-3 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[4px] sm:border-l-[6px] sm:border-r-[6px] sm:border-t-[6px] border-transparent border-t-gray-800/90'></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 横向模式:标题和进度条在海报上 */}
|
||||
{orientation === 'horizontal' && (
|
||||
<>
|
||||
{/* 顶部渐变遮罩 - 用于标题背景 */}
|
||||
<div
|
||||
className='absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 via-black/40 to-transparent pt-2 pb-8 px-2'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div
|
||||
className='mb-1'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span
|
||||
className='block text-sm font-bold truncate text-white'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
title={actualTitle}
|
||||
>
|
||||
{actualTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 集数信息 - 只有超过1集时才显示 */}
|
||||
{currentEpisode && actualEpisodes && actualEpisodes > 1 && (
|
||||
<div
|
||||
className='text-xs text-white/90'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
第{currentEpisode}集 · 共{actualEpisodes}集
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 直链地址 */}
|
||||
{isDirectPlaySource && directLinkUrl && (
|
||||
<div
|
||||
className='text-[10px] text-white/75 truncate'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
title={directLinkUrl}
|
||||
>
|
||||
{directLinkUrl}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部渐变遮罩 - 用于进度条背景 */}
|
||||
<div
|
||||
className='absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent pt-8 pb-2 px-2'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
{config.showProgress &&
|
||||
progress !== undefined &&
|
||||
origin !== 'live' && (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 来源和时长显示 - 在进度条上方 */}
|
||||
<div className='flex items-center justify-between mb-1'>
|
||||
{/* 时长显示 - 左侧 */}
|
||||
{from === 'playrecord' &&
|
||||
playTime !== undefined &&
|
||||
totalTime !== undefined && (
|
||||
<div
|
||||
className='text-[10px] text-white/80'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
// 0分钟时不显示分钟
|
||||
if (mins === 0) {
|
||||
return `${secs}秒`;
|
||||
}
|
||||
return `${mins}分${secs}秒`;
|
||||
};
|
||||
return formatTime(playTime);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 来源 - 右侧 */}
|
||||
{config.showSourceName && source_name && !cmsData && (
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||
actualSource === 'xiaoya'
|
||||
? 'border-blue-500'
|
||||
: isNetdiskSource(actualSource)
|
||||
? 'border-purple-500'
|
||||
: actualSource === 'openlist' ||
|
||||
actualSource === 'emby' ||
|
||||
actualSource?.startsWith('emby_')
|
||||
? 'border-yellow-500'
|
||||
: 'border-white/60'
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{source_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className='h-1 w-full bg-white/20 rounded-full overflow-hidden'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='h-full bg-white transition-all duration-500 ease-out'
|
||||
style={
|
||||
{
|
||||
width: `${progress}%`,
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 直播时只显示来源 */}
|
||||
{origin === 'live' &&
|
||||
config.showSourceName &&
|
||||
source_name &&
|
||||
!cmsData && (
|
||||
<div className='flex items-center justify-end'>
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||
origin === 'live'
|
||||
? 'border-red-500'
|
||||
: actualSource === 'openlist' ||
|
||||
actualSource === 'emby' ||
|
||||
actualSource?.startsWith('emby_')
|
||||
? 'border-yellow-500'
|
||||
: 'border-white/60'
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
size={8}
|
||||
className='inline-block text-white/90 mr-0.5'
|
||||
/>
|
||||
{source_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 竖向模式:进度条和标题在海报下方 */}
|
||||
{orientation === 'vertical' && (
|
||||
<>
|
||||
{/* 进度条 */}
|
||||
{config.showProgress && progress !== undefined && (
|
||||
<div
|
||||
className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='h-full bg-green-500 transition-all duration-500 ease-out'
|
||||
style={
|
||||
{
|
||||
width: `${progress}%`,
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标题 */}
|
||||
<div
|
||||
className='absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent pt-8 pb-2 px-2'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
className='mt-2 text-center'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
{config.showProgress && progress !== undefined && origin !== 'live' && (
|
||||
<div
|
||||
style={{
|
||||
<div
|
||||
className='relative'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* 来源和时长显示 - 在进度条上方 */}
|
||||
<div className='flex items-center justify-between mb-1'>
|
||||
{/* 时长显示 - 左侧 */}
|
||||
{from === 'playrecord' && playTime !== undefined && totalTime !== undefined && (
|
||||
<div
|
||||
className='text-[10px] text-white/80'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
// 0分钟时不显示分钟
|
||||
if (mins === 0) {
|
||||
return `${secs}秒`;
|
||||
}
|
||||
return `${mins}分${secs}秒`;
|
||||
};
|
||||
return formatTime(playTime);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 来源 - 右侧 */}
|
||||
{config.showSourceName && source_name && !cmsData && (
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||
actualSource === 'xiaoya' ? 'border-blue-500' : isNetdiskSource(actualSource) ? 'border-purple-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{source_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className='h-1 w-full bg-white/20 rounded-full overflow-hidden'
|
||||
style={{
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span
|
||||
className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='h-full bg-white transition-all duration-500 ease-out'
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{actualTitle}
|
||||
</span>
|
||||
{/* 自定义 tooltip */}
|
||||
<div
|
||||
className='absolute bottom-full left-1/2 z-10 mb-2 w-max max-w-[min(20rem,calc(100vw-2rem))] -translate-x-1/2 rounded-md bg-gray-800 px-3 py-1 text-center text-xs text-white shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-normal break-words pointer-events-none'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{actualTitle}
|
||||
<div
|
||||
className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'
|
||||
style={
|
||||
{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
} as React.CSSProperties
|
||||
}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 直播时只显示来源 */}
|
||||
{origin === 'live' && config.showSourceName && source_name && !cmsData && (
|
||||
<div className='flex items-center justify-end'>
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||
origin === 'live' ? 'border-red-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Radio size={8} className="inline-block text-white/90 mr-0.5" />
|
||||
{source_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 竖向模式:进度条和标题在海报下方 */}
|
||||
{orientation === 'vertical' && (
|
||||
<>
|
||||
{/* 进度条 */}
|
||||
{config.showProgress && progress !== undefined && (
|
||||
<div
|
||||
className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='h-full bg-green-500 transition-all duration-500 ease-out'
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标题 */}
|
||||
<div
|
||||
className='mt-2 text-center'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<span
|
||||
className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{actualTitle}
|
||||
</span>
|
||||
{/* 自定义 tooltip */}
|
||||
<div
|
||||
className='absolute bottom-full left-1/2 z-10 mb-2 w-max max-w-[min(20rem,calc(100vw-2rem))] -translate-x-1/2 rounded-md bg-gray-800 px-3 py-1 text-center text-xs text-white shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-normal break-words pointer-events-none'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{actualTitle}
|
||||
<div
|
||||
className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作菜单 - 支持右键和长按触发 */}
|
||||
<MobileActionSheet
|
||||
isOpen={showMobileActions}
|
||||
onClose={() => setShowMobileActions(false)}
|
||||
title={actualTitle}
|
||||
poster={displayPoster}
|
||||
actions={mobileActions}
|
||||
sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined}
|
||||
isAggregate={isAggregate}
|
||||
sourceName={cmsData ? undefined : source_name}
|
||||
directLinkUrl={directLinkUrl || undefined}
|
||||
currentEpisode={currentEpisode}
|
||||
totalEpisodes={actualEpisodes}
|
||||
origin={origin}
|
||||
onPosterClick={() => {
|
||||
setShowImageViewer(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* AI问片面板 - 只在打开或正在流式响应时渲染 */}
|
||||
{aiEnabled && (showAIChat || isAIStreaming) && (
|
||||
<AIChatPanel
|
||||
isOpen={showAIChat}
|
||||
onClose={() => setShowAIChat(false)}
|
||||
onStreamingChange={setIsAIStreaming}
|
||||
context={{
|
||||
title: actualTitle,
|
||||
year: actualYear,
|
||||
douban_id: actualDoubanId,
|
||||
tmdb_id,
|
||||
type: actualSearchType as 'movie' | 'tv',
|
||||
currentEpisode,
|
||||
}}
|
||||
welcomeMessage={aiDefaultMessageWithVideo ? aiDefaultMessageWithVideo.replace('{title}', actualTitle || '') : `想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 详情面板 */}
|
||||
{showDetailPanel && (
|
||||
<DetailPanel
|
||||
isOpen={showDetailPanel}
|
||||
onClose={() => setShowDetailPanel(false)}
|
||||
{/* 操作菜单 - 支持右键和长按触发 */}
|
||||
<MobileActionSheet
|
||||
isOpen={showMobileActions}
|
||||
onClose={() => setShowMobileActions(false)}
|
||||
title={actualTitle}
|
||||
poster={displayPoster}
|
||||
doubanId={actualDoubanId}
|
||||
bangumiId={isBangumi ? actualDoubanId : undefined}
|
||||
isBangumi={isBangumi}
|
||||
tmdbId={tmdb_id}
|
||||
type={actualSearchType as 'movie' | 'tv'}
|
||||
seasonNumber={seasonNumber}
|
||||
actions={mobileActions}
|
||||
sources={
|
||||
isAggregate && dynamicSourceNames
|
||||
? Array.from(new Set(dynamicSourceNames))
|
||||
: undefined
|
||||
}
|
||||
isAggregate={isAggregate}
|
||||
sourceName={cmsData ? undefined : source_name}
|
||||
directLinkUrl={directLinkUrl || undefined}
|
||||
currentEpisode={currentEpisode}
|
||||
cmsData={cmsData}
|
||||
sourceId={id}
|
||||
source={source}
|
||||
totalEpisodes={actualEpisodes}
|
||||
origin={origin}
|
||||
onPosterClick={() => {
|
||||
setShowImageViewer(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片查看器 */}
|
||||
{showImageViewer && (
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
imageUrl={actualPoster}
|
||||
alt={actualTitle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
{/* AI问片面板 - 只在打开或正在流式响应时渲染 */}
|
||||
{aiEnabled && (showAIChat || isAIStreaming) && (
|
||||
<AIChatPanel
|
||||
isOpen={showAIChat}
|
||||
onClose={() => setShowAIChat(false)}
|
||||
onStreamingChange={setIsAIStreaming}
|
||||
context={{
|
||||
title: actualTitle,
|
||||
year: actualYear,
|
||||
douban_id: actualDoubanId,
|
||||
tmdb_id,
|
||||
type: actualSearchType as 'movie' | 'tv',
|
||||
currentEpisode,
|
||||
}}
|
||||
welcomeMessage={
|
||||
aiDefaultMessageWithVideo
|
||||
? aiDefaultMessageWithVideo.replace(
|
||||
'{title}',
|
||||
actualTitle || ''
|
||||
)
|
||||
: `想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 详情面板 */}
|
||||
{showDetailPanel && (
|
||||
<DetailPanel
|
||||
isOpen={showDetailPanel}
|
||||
onClose={() => setShowDetailPanel(false)}
|
||||
title={actualTitle}
|
||||
poster={displayPoster}
|
||||
doubanId={actualDoubanId}
|
||||
bangumiId={isBangumi ? actualDoubanId : undefined}
|
||||
isBangumi={isBangumi}
|
||||
tmdbId={tmdb_id}
|
||||
type={actualSearchType as 'movie' | 'tv'}
|
||||
seasonNumber={seasonNumber}
|
||||
currentEpisode={currentEpisode}
|
||||
cmsData={cmsData}
|
||||
sourceId={id}
|
||||
source={source}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片查看器 */}
|
||||
{showImageViewer && (
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
imageUrl={actualPoster}
|
||||
alt={actualTitle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default memo(VideoCard);
|
||||
|
||||
Reference in New Issue
Block a user