添加即将上映
This commit is contained in:
@@ -296,6 +296,7 @@ interface SiteConfig {
|
|||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
|
TMDBApiKey?: string;
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
EnableRegistration?: boolean;
|
EnableRegistration?: boolean;
|
||||||
RegistrationRequireTurnstile?: boolean;
|
RegistrationRequireTurnstile?: boolean;
|
||||||
@@ -4592,6 +4593,7 @@ const SiteConfigComponent = ({
|
|||||||
FluidSearch: true,
|
FluidSearch: true,
|
||||||
DanmakuApiBase: 'http://localhost:9321',
|
DanmakuApiBase: 'http://localhost:9321',
|
||||||
DanmakuApiToken: '87654321',
|
DanmakuApiToken: '87654321',
|
||||||
|
TMDBApiKey: '',
|
||||||
EnableComments: false,
|
EnableComments: false,
|
||||||
EnableRegistration: false,
|
EnableRegistration: false,
|
||||||
RegistrationRequireTurnstile: false,
|
RegistrationRequireTurnstile: false,
|
||||||
@@ -4674,6 +4676,7 @@ const SiteConfigComponent = ({
|
|||||||
DanmakuApiBase:
|
DanmakuApiBase:
|
||||||
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
||||||
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
||||||
|
TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
|
||||||
EnableComments: config.SiteConfig.EnableComments || false,
|
EnableComments: config.SiteConfig.EnableComments || false,
|
||||||
EnableRegistration: config.SiteConfig.EnableRegistration || false,
|
EnableRegistration: config.SiteConfig.EnableRegistration || false,
|
||||||
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
||||||
@@ -5235,6 +5238,43 @@ const SiteConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* TMDB 配置 */}
|
||||||
|
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
TMDB 配置
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* TMDB API Key */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
TMDB API Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='请输入 TMDB API Key'
|
||||||
|
value={siteSettings.TMDBApiKey}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
TMDBApiKey: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
配置后首页将显示 TMDB 即将上映电影。获取 API Key 请访问{' '}
|
||||||
|
<a
|
||||||
|
href='https://www.themoviedb.org/settings/api'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
|
||||||
|
>
|
||||||
|
TMDB API 设置页面
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 评论功能配置 */}
|
{/* 评论功能配置 */}
|
||||||
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export async function POST(request: NextRequest) {
|
|||||||
FluidSearch,
|
FluidSearch,
|
||||||
DanmakuApiBase,
|
DanmakuApiBase,
|
||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
|
TMDBApiKey,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
@@ -72,6 +73,7 @@ export async function POST(request: NextRequest) {
|
|||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
|
TMDBApiKey?: string;
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
CustomAdFilterCode?: string;
|
CustomAdFilterCode?: string;
|
||||||
CustomAdFilterVersion?: number;
|
CustomAdFilterVersion?: number;
|
||||||
@@ -106,6 +108,7 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof FluidSearch !== 'boolean' ||
|
typeof FluidSearch !== 'boolean' ||
|
||||||
typeof DanmakuApiBase !== 'string' ||
|
typeof DanmakuApiBase !== 'string' ||
|
||||||
typeof DanmakuApiToken !== 'string' ||
|
typeof DanmakuApiToken !== 'string' ||
|
||||||
|
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
||||||
typeof EnableComments !== 'boolean' ||
|
typeof EnableComments !== 'boolean' ||
|
||||||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
||||||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
||||||
@@ -155,6 +158,7 @@ export async function POST(request: NextRequest) {
|
|||||||
FluidSearch,
|
FluidSearch,
|
||||||
DanmakuApiBase,
|
DanmakuApiBase,
|
||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
|
TMDBApiKey,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
|
|||||||
+3
-1
@@ -61,6 +61,7 @@ export default async function RootLayout({
|
|||||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||||
let enableComments = false;
|
let enableComments = false;
|
||||||
|
let tmdbApiKey = '';
|
||||||
let customCategories = [] as {
|
let customCategories = [] as {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv';
|
||||||
@@ -85,6 +86,7 @@ export default async function RootLayout({
|
|||||||
}));
|
}));
|
||||||
fluidSearch = config.SiteConfig.FluidSearch;
|
fluidSearch = config.SiteConfig.FluidSearch;
|
||||||
enableComments = config.SiteConfig.EnableComments;
|
enableComments = config.SiteConfig.EnableComments;
|
||||||
|
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||||
@@ -128,7 +130,7 @@ export default async function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<SiteProvider siteName={siteName} announcement={announcement}>
|
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
|
||||||
<WatchRoomProvider>
|
<WatchRoomProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<DanmakuCacheCleanup />
|
<DanmakuCacheCleanup />
|
||||||
|
|||||||
+57
-3
@@ -18,6 +18,11 @@ import {
|
|||||||
subscribeToDataUpdates,
|
subscribeToDataUpdates,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import { getDoubanCategories } from '@/lib/douban.client';
|
import { getDoubanCategories } from '@/lib/douban.client';
|
||||||
|
import {
|
||||||
|
getTMDBUpcomingContent,
|
||||||
|
getTMDBImageUrl,
|
||||||
|
TMDBItem,
|
||||||
|
} from '@/lib/tmdb.client';
|
||||||
import { DoubanItem } from '@/lib/types';
|
import { DoubanItem } from '@/lib/types';
|
||||||
|
|
||||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||||
@@ -32,11 +37,12 @@ function HomeClient() {
|
|||||||
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
||||||
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
||||||
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
||||||
|
const [upcomingContent, setUpcomingContent] = useState<TMDBItem[]>([]);
|
||||||
const [bangumiCalendarData, setBangumiCalendarData] = useState<
|
const [bangumiCalendarData, setBangumiCalendarData] = useState<
|
||||||
BangumiCalendarData[]
|
BangumiCalendarData[]
|
||||||
>([]);
|
>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { announcement } = useSite();
|
const { announcement, tmdbApiKey } = useSite();
|
||||||
|
|
||||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||||
|
|
||||||
@@ -73,7 +79,7 @@ function HomeClient() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 并行获取热门电影、热门剧集和热门综艺
|
// 并行获取热门电影、热门剧集、热门综艺和番剧日历
|
||||||
const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] =
|
const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getDoubanCategories({
|
getDoubanCategories({
|
||||||
@@ -99,6 +105,20 @@ function HomeClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBangumiCalendarData(bangumiCalendarData);
|
setBangumiCalendarData(bangumiCalendarData);
|
||||||
|
|
||||||
|
// 如果配置了 TMDB API Key,则获取即将上映/播出内容
|
||||||
|
if (tmdbApiKey) {
|
||||||
|
const tmdbData = await getTMDBUpcomingContent(tmdbApiKey);
|
||||||
|
if (tmdbData.code === 200) {
|
||||||
|
// 按上映/播出日期升序排序(最近的排在前面)
|
||||||
|
const sortedContent = [...tmdbData.list].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.release_date || '9999-12-31').getTime();
|
||||||
|
const dateB = new Date(b.release_date || '9999-12-31').getTime();
|
||||||
|
return dateA - dateB;
|
||||||
|
});
|
||||||
|
setUpcomingContent(sortedContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取推荐数据失败:', error);
|
console.error('获取推荐数据失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -107,7 +127,7 @@ function HomeClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchRecommendData();
|
fetchRecommendData();
|
||||||
}, []);
|
}, [tmdbApiKey]);
|
||||||
|
|
||||||
// 处理收藏数据更新的函数
|
// 处理收藏数据更新的函数
|
||||||
const updateFavoriteItems = useCallback(
|
const updateFavoriteItems = useCallback(
|
||||||
@@ -447,6 +467,40 @@ function HomeClient() {
|
|||||||
))}
|
))}
|
||||||
</ScrollableRow>
|
</ScrollableRow>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 即将上映/播出 (TMDB) */}
|
||||||
|
{tmdbApiKey && upcomingContent.length > 0 && (
|
||||||
|
<section className='mb-8'>
|
||||||
|
<div className='mb-4 flex items-center justify-between'>
|
||||||
|
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
即将上映
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<ScrollableRow>
|
||||||
|
{upcomingContent.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.media_type}-${item.id}`}
|
||||||
|
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||||
|
>
|
||||||
|
<VideoCard
|
||||||
|
title={item.title}
|
||||||
|
poster={getTMDBImageUrl(item.poster_path)}
|
||||||
|
year={item.release_date?.split('-')?.[0] || ''}
|
||||||
|
rate={
|
||||||
|
item.vote_average && item.vote_average > 0
|
||||||
|
? item.vote_average.toFixed(1)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
type={item.media_type === 'tv' ? 'tv' : 'movie'}
|
||||||
|
from='douban'
|
||||||
|
releaseDate={item.release_date}
|
||||||
|
isUpcoming={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollableRow>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
import { createContext, ReactNode, useContext } from 'react';
|
import { createContext, ReactNode, useContext } from 'react';
|
||||||
|
|
||||||
const SiteContext = createContext<{ siteName: string; announcement?: string }>({
|
const SiteContext = createContext<{
|
||||||
|
siteName: string;
|
||||||
|
announcement?: string;
|
||||||
|
tmdbApiKey?: string;
|
||||||
|
}>({
|
||||||
// 默认值
|
// 默认值
|
||||||
siteName: 'MoonTV',
|
siteName: 'MoonTV',
|
||||||
announcement:
|
announcement:
|
||||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||||
|
tmdbApiKey: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useSite = () => useContext(SiteContext);
|
export const useSite = () => useContext(SiteContext);
|
||||||
@@ -15,13 +20,15 @@ export function SiteProvider({
|
|||||||
children,
|
children,
|
||||||
siteName,
|
siteName,
|
||||||
announcement,
|
announcement,
|
||||||
|
tmdbApiKey,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
announcement?: string;
|
announcement?: string;
|
||||||
|
tmdbApiKey?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SiteContext.Provider value={{ siteName, announcement }}>
|
<SiteContext.Provider value={{ siteName, announcement, tmdbApiKey }}>
|
||||||
{children}
|
{children}
|
||||||
</SiteContext.Provider>
|
</SiteContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface VideoCardProps {
|
|||||||
isBangumi?: boolean;
|
isBangumi?: boolean;
|
||||||
isAggregate?: boolean;
|
isAggregate?: boolean;
|
||||||
origin?: 'vod' | 'live';
|
origin?: 'vod' | 'live';
|
||||||
|
releaseDate?: string; // 上映日期,格式:YYYY-MM-DD
|
||||||
|
isUpcoming?: boolean; // 是否为即将上映
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VideoCardHandle = {
|
export type VideoCardHandle = {
|
||||||
@@ -76,6 +78,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
isBangumi = false,
|
isBangumi = false,
|
||||||
isAggregate = false,
|
isAggregate = false,
|
||||||
origin = 'vod',
|
origin = 'vod',
|
||||||
|
releaseDate,
|
||||||
|
isUpcoming = false,
|
||||||
}: VideoCardProps,
|
}: VideoCardProps,
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -223,6 +227,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
|
// 即将上映的电影不跳转
|
||||||
|
if (isUpcoming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (origin === 'live' && actualSource && actualId) {
|
if (origin === 'live' && actualSource && actualId) {
|
||||||
// 直播内容跳转到直播页面
|
// 直播内容跳转到直播页面
|
||||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||||
@@ -240,6 +249,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
isUpcoming,
|
||||||
origin,
|
origin,
|
||||||
from,
|
from,
|
||||||
actualSource,
|
actualSource,
|
||||||
@@ -254,6 +264,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
// 新标签页播放处理函数
|
// 新标签页播放处理函数
|
||||||
const handlePlayInNewTab = useCallback(() => {
|
const handlePlayInNewTab = useCallback(() => {
|
||||||
|
// 即将上映的电影不跳转
|
||||||
|
if (isUpcoming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (origin === 'live' && actualSource && actualId) {
|
if (origin === 'live' && actualSource && actualId) {
|
||||||
// 直播内容跳转到直播页面
|
// 直播内容跳转到直播页面
|
||||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||||
@@ -270,6 +285,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
isUpcoming,
|
||||||
origin,
|
origin,
|
||||||
from,
|
from,
|
||||||
actualSource,
|
actualSource,
|
||||||
@@ -295,6 +311,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
|
|
||||||
// 长按操作
|
// 长按操作
|
||||||
const handleLongPress = useCallback(() => {
|
const handleLongPress = useCallback(() => {
|
||||||
|
// 即将上映的电影不显示操作菜单
|
||||||
|
if (isUpcoming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!showMobileActions) { // 防止重复触发
|
if (!showMobileActions) { // 防止重复触发
|
||||||
// 立即显示菜单,避免等待数据加载导致动画卡顿
|
// 立即显示菜单,避免等待数据加载导致动画卡顿
|
||||||
setShowMobileActions(true);
|
setShowMobileActions(true);
|
||||||
@@ -304,7 +325,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
checkSearchFavoriteStatus();
|
checkSearchFavoriteStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]);
|
}, [isUpcoming, showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]);
|
||||||
|
|
||||||
// 长按手势hook
|
// 长按手势hook
|
||||||
const longPressProps = useLongPress({
|
const longPressProps = useLongPress({
|
||||||
@@ -313,6 +334,28 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
longPressDelay: 500,
|
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 config = useMemo(() => {
|
||||||
const configs = {
|
const configs = {
|
||||||
playrecord: {
|
playrecord: {
|
||||||
@@ -348,7 +391,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
douban: {
|
douban: {
|
||||||
showSourceName: false,
|
showSourceName: false,
|
||||||
showProgress: false,
|
showProgress: false,
|
||||||
showPlayButton: true,
|
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
||||||
showHeart: false,
|
showHeart: false,
|
||||||
showCheckCircle: false,
|
showCheckCircle: false,
|
||||||
showDoubanLink: true,
|
showDoubanLink: true,
|
||||||
@@ -357,7 +400,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
return configs[from] || configs.search;
|
return configs[from] || configs.search;
|
||||||
}, [from, isAggregate, douban_id, rate]);
|
}, [from, isAggregate, douban_id, rate, isUpcoming]);
|
||||||
|
|
||||||
// 移动端操作菜单配置
|
// 移动端操作菜单配置
|
||||||
const mobileActions = useMemo(() => {
|
const mobileActions = useMemo(() => {
|
||||||
@@ -495,7 +538,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]'
|
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'}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
{...longPressProps}
|
{...longPressProps}
|
||||||
style={{
|
style={{
|
||||||
@@ -513,6 +556,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 即将上映的电影不显示操作菜单
|
||||||
|
if (isUpcoming) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 右键弹出操作菜单
|
// 右键弹出操作菜单
|
||||||
setShowMobileActions(true);
|
setShowMobileActions(true);
|
||||||
|
|
||||||
@@ -595,8 +643,37 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 播放按钮 */}
|
{/* 播放按钮或上映倒计时 */}
|
||||||
{config.showPlayButton && (
|
{isUpcoming && daysUntilRelease !== null ? (
|
||||||
|
<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'
|
||||||
|
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-sm font-medium shadow-lg'
|
||||||
|
style={{
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitTouchCallout: 'none',
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{daysUntilRelease > 0
|
||||||
|
? `${daysUntilRelease}天后上映`
|
||||||
|
: daysUntilRelease === 0
|
||||||
|
? '今日上映'
|
||||||
|
: '已上映'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : config.showPlayButton && (
|
||||||
<div
|
<div
|
||||||
data-button="true"
|
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'
|
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'
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface AdminConfig {
|
|||||||
// 弹幕配置
|
// 弹幕配置
|
||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
|
// TMDB配置
|
||||||
|
TMDBApiKey?: string;
|
||||||
// 评论功能开关
|
// 评论功能开关
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
// 自定义去广告代码
|
// 自定义去广告代码
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
// 弹幕配置
|
// 弹幕配置
|
||||||
DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321',
|
DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321',
|
||||||
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
||||||
|
// TMDB配置
|
||||||
|
TMDBApiKey: '',
|
||||||
// 评论功能开关
|
// 评论功能开关
|
||||||
EnableComments: false,
|
EnableComments: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
|
export interface TMDBMovie {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
poster_path: string | null;
|
||||||
|
release_date: string;
|
||||||
|
overview: string;
|
||||||
|
vote_average: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TMDBTVShow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
poster_path: string | null;
|
||||||
|
first_air_date: string;
|
||||||
|
overview: string;
|
||||||
|
vote_average: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一的类型,用于显示
|
||||||
|
export interface TMDBItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
poster_path: string | null;
|
||||||
|
release_date: string;
|
||||||
|
overview: string;
|
||||||
|
vote_average: number;
|
||||||
|
media_type: 'movie' | 'tv';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TMDBUpcomingResponse {
|
||||||
|
results: TMDBMovie[];
|
||||||
|
page: number;
|
||||||
|
total_pages: number;
|
||||||
|
total_results: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TMDBTVAiringTodayResponse {
|
||||||
|
results: TMDBTVShow[];
|
||||||
|
page: number;
|
||||||
|
total_pages: number;
|
||||||
|
total_results: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取即将上映的电影
|
||||||
|
* @param apiKey - TMDB API Key
|
||||||
|
* @param page - 页码
|
||||||
|
* @param region - 地区代码,默认 CN (中国)
|
||||||
|
* @returns 即将上映的电影列表
|
||||||
|
*/
|
||||||
|
export async function getTMDBUpcomingMovies(
|
||||||
|
apiKey: string,
|
||||||
|
page: number = 1,
|
||||||
|
region: string = 'CN'
|
||||||
|
): Promise<{ code: number; list: TMDBMovie[] }> {
|
||||||
|
try {
|
||||||
|
if (!apiKey) {
|
||||||
|
return { code: 400, list: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('TMDB API 请求失败:', response.status, response.statusText);
|
||||||
|
return { code: response.status, list: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TMDBUpcomingResponse = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
list: data.results,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 TMDB 即将上映电影失败:', error);
|
||||||
|
return { code: 500, list: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取正在播出的电视剧
|
||||||
|
* @param apiKey - TMDB API Key
|
||||||
|
* @param page - 页码
|
||||||
|
* @returns 正在播出的电视剧列表
|
||||||
|
*/
|
||||||
|
export async function getTMDBUpcomingTVShows(
|
||||||
|
apiKey: string,
|
||||||
|
page: number = 1
|
||||||
|
): Promise<{ code: number; list: TMDBTVShow[] }> {
|
||||||
|
try {
|
||||||
|
if (!apiKey) {
|
||||||
|
return { code: 400, list: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 on_the_air 接口获取正在播出的电视剧
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('TMDB TV API 请求失败:', response.status, response.statusText);
|
||||||
|
return { code: response.status, list: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TMDBTVAiringTodayResponse = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
list: data.results,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 TMDB 正在播出电视剧失败:', error);
|
||||||
|
return { code: 500, list: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取即将上映/播出的内容(电影+电视剧)
|
||||||
|
* @param apiKey - TMDB API Key
|
||||||
|
* @returns 统一格式的即将上映/播出列表
|
||||||
|
*/
|
||||||
|
export async function getTMDBUpcomingContent(
|
||||||
|
apiKey: string
|
||||||
|
): Promise<{ code: number; list: TMDBItem[] }> {
|
||||||
|
try {
|
||||||
|
if (!apiKey) {
|
||||||
|
return { code: 400, list: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并行获取电影和电视剧数据
|
||||||
|
const [moviesResult, tvShowsResult] = await Promise.all([
|
||||||
|
getTMDBUpcomingMovies(apiKey),
|
||||||
|
getTMDBUpcomingTVShows(apiKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 获取今天的日期(本地时区)
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// 转换电影数据为统一格式,并过滤掉已上映的
|
||||||
|
const movies: TMDBItem[] = moviesResult.list
|
||||||
|
.filter((movie) => {
|
||||||
|
// 只保留未来上映的电影
|
||||||
|
return movie.release_date && movie.release_date >= todayStr;
|
||||||
|
})
|
||||||
|
.map((movie) => ({
|
||||||
|
id: movie.id,
|
||||||
|
title: movie.title,
|
||||||
|
poster_path: movie.poster_path,
|
||||||
|
release_date: movie.release_date,
|
||||||
|
overview: movie.overview,
|
||||||
|
vote_average: movie.vote_average,
|
||||||
|
media_type: 'movie' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 转换电视剧数据为统一格式,并过滤掉已播出的
|
||||||
|
const tvShows: TMDBItem[] = tvShowsResult.list
|
||||||
|
.filter((tv) => {
|
||||||
|
// 只保留未来播出的电视剧
|
||||||
|
return tv.first_air_date && tv.first_air_date >= todayStr;
|
||||||
|
})
|
||||||
|
.map((tv) => ({
|
||||||
|
id: tv.id,
|
||||||
|
title: tv.name,
|
||||||
|
poster_path: tv.poster_path,
|
||||||
|
release_date: tv.first_air_date,
|
||||||
|
overview: tv.overview,
|
||||||
|
vote_average: tv.vote_average,
|
||||||
|
media_type: 'tv' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 合并并返回
|
||||||
|
const allContent = [...movies, ...tvShows];
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
list: allContent,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 TMDB 即将上映内容失败:', error);
|
||||||
|
return { code: 500, list: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 TMDB 图片完整 URL
|
||||||
|
* @param path - 图片路径
|
||||||
|
* @param size - 图片尺寸,默认 w500
|
||||||
|
* @returns 完整的图片 URL
|
||||||
|
*/
|
||||||
|
export function getTMDBImageUrl(
|
||||||
|
path: string | null,
|
||||||
|
size: string = 'w500'
|
||||||
|
): string {
|
||||||
|
if (!path) return '';
|
||||||
|
return `https://image.tmdb.org/t/p/${size}${path}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user