feat: play page && other refactor

This commit is contained in:
shinya
2025-06-19 02:40:39 +08:00
parent 3d199261a3
commit d01bc68d75
12 changed files with 1408 additions and 75 deletions

View File

@@ -20,8 +20,10 @@
"dependencies": {
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"artplayer": "^5.2.3",
"clsx": "^2.0.0",
"framer-motion": "^12.18.1",
"hls.js": "^1.6.5",
"lucide-react": "^0.438.0",
"next": "^14.2.23",
"react": "^18.2.0",

25
pnpm-lock.yaml generated
View File

@@ -14,12 +14,18 @@ importers:
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@18.3.1)
artplayer:
specifier: ^5.2.3
version: 5.2.3
clsx:
specifier: ^2.0.0
version: 2.1.1
framer-motion:
specifier: ^12.18.1
version: 12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
hls.js:
specifier: ^1.6.5
version: 1.6.5
lucide-react:
specifier: ^0.438.0
version: 0.438.0(react@18.3.1)
@@ -1670,6 +1676,9 @@ packages:
resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==}
engines: {node: '>=0.10.0'}
artplayer@5.2.3:
resolution: {integrity: sha512-WaOZQrpZn/L+GgI2f0TEsoAL3Wb+v16Mu0JmWh7qKFYuvr11WNt3dWhWeIaCfoHy3NtkCWM9jTP+xwwsxdElZQ==}
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
@@ -2594,6 +2603,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hls.js@1.6.5:
resolution: {integrity: sha512-KMn5n7JBK+olC342740hDPHnGWfE8FiHtGMOdJPfUjRdARTWj9OB+8c13fnsf9sk1VtpuU2fKSgUjHvg4rNbzQ==}
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -3392,6 +3404,9 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
option-validator@2.0.6:
resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -6288,6 +6303,10 @@ snapshots:
arrify@1.0.1: {}
artplayer@5.2.3:
dependencies:
option-validator: 2.0.6
ast-types-flow@0.0.8: {}
astral-regex@2.0.0: {}
@@ -7405,6 +7424,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
hls.js@1.6.5: {}
hosted-git-info@2.8.9: {}
hosted-git-info@4.1.0:
@@ -8470,6 +8491,10 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
option-validator@2.0.6:
dependencies:
kind-of: 6.0.3
optionator@0.9.4:
dependencies:
deep-is: 0.1.4

View File

@@ -4,6 +4,17 @@ import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
// 清理 HTML 标签的工具函数
function cleanHtmlTags(text: string): string {
if (!text) return '';
return text
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
.trim(); // 去掉首尾空格
}
export interface VideoDetail {
code: number;
episodes: string[];
@@ -71,9 +82,7 @@ async function handleSpecialSourceDetail(
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch
? descMatch[1].replace(/<[^>]+>/g, ' ').trim()
: '';
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
@@ -158,7 +167,7 @@ async function getDetailFromApi(
videoInfo: {
title: videoDetail.vod_name,
cover: videoDetail.vod_pic,
desc: videoDetail.vod_content,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year,
area: videoDetail.vod_area,

View File

@@ -4,7 +4,7 @@ import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
import { getVideoDetail } from '../detail/route';
interface SearchResult {
export interface SearchResult {
id: string;
title: string;
poster: string;

View File

@@ -45,6 +45,7 @@ export default function DetailPage() {
return (
<PageLayout activePath='/detail'>
<div className='px-10 py-8 overflow-visible'>
{/* 顶部返回按钮已移入右侧信息容器 */}
{loading ? (
<div className='flex items-center justify-center min-h-[60vh]'>
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
@@ -65,7 +66,34 @@ export default function DetailPage() {
) : (
<div className='max-w-[95%] mx-auto'>
{/* 主信息区:左图右文 */}
<div className='flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6'>
<div className='relative flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6 md:items-start'>
{/* 返回按钮放置在主信息区左上角 */}
<button
onClick={() => {
const from = searchParams.get('from');
if (from === 'search') {
window.history.back();
} else {
window.location.href = '/';
}
}}
className='absolute top-0 left-0 -translate-x-[180%] -translate-y-1/2 p-2 rounded transition-colors'
>
<svg
className='h-5 w-5 text-gray-500 hover:text-green-600 transition-colors'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M15 19l-7-7 7-7'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
</button>
{/* 封面 */}
<div className='flex-shrink-0 w-full md:w-64'>
<Image
@@ -79,55 +107,64 @@ export default function DetailPage() {
/>
</div>
{/* 右侧信息 */}
<div className='flex-1 flex flex-col justify-between'>
<div>
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center'>
{detail.videoInfo.title}
</h1>
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80'>
{detail.videoInfo.remarks && (
<span className='text-red-500 font-semibold'>
{detail.videoInfo.remarks}
</span>
)}
{detail.videoInfo.year && (
<span>{detail.videoInfo.year}</span>
)}
{detail.videoInfo.source_name && (
<span>{detail.videoInfo.source_name}</span>
)}
{detail.videoInfo.type && (
<span>{detail.videoInfo.type}</span>
)}
</div>
<div className='flex items-center gap-4 mb-4'>
<button className='flex items-center justify-center gap-2 px-6 py-2 bg-gray-500/40 hover:bg-[#22c55e] rounded-lg transition-colors text-white'>
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
<span></span>
</button>
<button className='flex items-center justify-center w-10 h-10 bg-gray-500/40 hover:bg-[#22c55e] rounded-full transition-colors'>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-5 w-5 text-white'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z'
/>
</svg>
</button>
</div>
{detail.videoInfo.desc && (
<div className='mt-4 text-base leading-relaxed opacity-90 max-h-40 overflow-y-auto pr-2'>
{detail.videoInfo.desc}
</div>
<div
className='flex-1 flex flex-col min-h-0'
style={{ height: '384px' }}
>
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0'>
{detail.videoInfo.title}
</h1>
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
{detail.videoInfo.remarks && (
<span className='text-green-600 font-semibold'>
{detail.videoInfo.remarks}
</span>
)}
{detail.videoInfo.year && (
<span>{detail.videoInfo.year}</span>
)}
{detail.videoInfo.source_name && (
<span>{detail.videoInfo.source_name}</span>
)}
{detail.videoInfo.type && (
<span>{detail.videoInfo.type}</span>
)}
</div>
<div className='flex items-center gap-4 mb-4 flex-shrink-0'>
<a
href={`/play?source=${searchParams.get(
'source'
)}&id=${searchParams.get('id')}`}
className='flex items-center justify-center gap-2 px-6 py-2 bg-green-500 hover:bg-green-600 rounded-lg transition-colors text-white'
>
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
<span></span>
</a>
<button className='flex items-center justify-center w-10 h-10 bg-pink-400 hover:bg-pink-500 rounded-full transition-colors'>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-5 w-5 text-white'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z'
/>
</svg>
</button>
</div>
{detail.videoInfo.desc && (
<div
className='mt-4 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
style={{ whiteSpace: 'pre-line' }}
>
{detail.videoInfo.desc}
</div>
)}
</div>
</div>
{/* 选集按钮区 */}
@@ -139,14 +176,14 @@ export default function DetailPage() {
{detail.episodes.length}
</div>
</div>
<div className='flex flex-wrap gap-3'>
<div className='flex flex-wrap gap-4'>
{detail.episodes.map((episode, idx) => (
<a
key={idx}
href={episode}
target='_blank'
rel='noopener noreferrer'
className='bg-gray-500/40 hover:bg-[#22c55e] text-white px-5 py-2 rounded-lg shadow transition-colors text-base font-medium w-24 text-center'
href={`/play?source=${searchParams.get(
'source'
)}&id=${searchParams.get('id')}&index=${idx + 1}`}
className='bg-gray-500/80 hover:bg-green-500 text-white px-5 py-2 rounded-lg transition-colors text-base font-medium w-24 text-center'
>
{idx + 1}
</a>

View File

@@ -1,8 +1,18 @@
'use client';
import {
Film,
MessageCircleHeart,
MountainSnow,
Star,
Swords,
Tv,
VenetianMask,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import CollectionCard from '@/components/CollectionCard';
import DemoCard from '@/components/DemoCard';
import PageLayout from '@/components/layout/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
@@ -92,6 +102,29 @@ const mockData = {
],
};
// 合集数据
const collections = [
{
icon: Film,
title: '热门电影',
href: '/douban?type=movie&tag=热门&title=热门电影',
},
{
icon: Tv,
title: '热门剧集',
href: '/douban?type=tv&tag=热门&title=热门剧集',
},
{
icon: Star,
title: '豆瓣 Top250',
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
},
{ icon: Swords, title: '美剧', href: '/douban?type=tv&tag=美剧' },
{ icon: MessageCircleHeart, title: '韩剧', href: '/douban?type=tv&tag=韩剧' },
{ icon: MountainSnow, title: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: VenetianMask, title: '日漫', href: '/douban?type=tv&tag=日本动画' },
];
export default function Home() {
const [activeTab, setActiveTab] = useState('home');
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
@@ -142,6 +175,24 @@ export default function Home() {
</div>
<div className='max-w-[95%] mx-auto'>
{/* 推荐 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow scrollDistance={800}>
{collections.map((collection) => (
<div key={collection.title} className='min-w-[280px] w-72'>
<CollectionCard
title={collection.title}
icon={collection.icon}
href={collection.href}
/>
</div>
))}
</ScrollableRow>
</section>
{/* 继续观看 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>

1094
src/app/play/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -104,7 +104,7 @@ export default function SearchPage() {
<div className='grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-x-8 gap-y-20 px-4'>
{searchResults.map((item) => (
<div key={item.id} className='w-44'>
<VideoCard {...item} />
<VideoCard {...item} from='search' />
</div>
))}
{searchResults.length === 0 && (

View File

@@ -0,0 +1,40 @@
import { LucideIcon } from 'lucide-react';
import Link from 'next/link';
interface CollectionCardProps {
title: string;
icon: LucideIcon;
href: string;
}
export default function CollectionCard({
title,
icon: Icon,
href,
}: CollectionCardProps) {
return (
<Link href={href} className='group block'>
<div className='relative w-full'>
{/* 长方形容器 - 调整宽高比和背景色 */}
<div className='relative aspect-[5/3] w-full overflow-hidden rounded-xl bg-gray-200 border border-gray-300/50'>
{/* 图标容器 */}
<div className='absolute inset-0 flex items-center justify-center'>
<Icon className='h-14 w-14 text-gray-600' />
</div>
{/* Hover 蒙版效果 - 参考 DemoCard */}
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200'></div>
</div>
{/* 标题 - absolute 定位,类似 DemoCard */}
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
<div className='flex flex-col items-center justify-center'>
<h3 className='text-sm font-medium text-gray-800 truncate w-full text-center'>
{title}
</h3>
</div>
</div>
</div>
</Link>
);
}

View File

@@ -3,9 +3,13 @@ import { useEffect, useRef, useState } from 'react';
interface ScrollableRowProps {
children: React.ReactNode;
scrollDistance?: number;
}
export default function ScrollableRow({ children }: ScrollableRowProps) {
export default function ScrollableRow({
children,
scrollDistance = 1000,
}: ScrollableRowProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [showLeftScroll, setShowLeftScroll] = useState(false);
const [showRightScroll, setShowRightScroll] = useState(false);
@@ -70,13 +74,19 @@ export default function ScrollableRow({ children }: ScrollableRowProps) {
const handleScrollRightClick = () => {
if (containerRef.current) {
containerRef.current.scrollBy({ left: 1000, behavior: 'smooth' });
containerRef.current.scrollBy({
left: scrollDistance,
behavior: 'smooth',
});
}
};
const handleScrollLeftClick = () => {
if (containerRef.current) {
containerRef.current.scrollBy({ left: -1000, behavior: 'smooth' });
containerRef.current.scrollBy({
left: -scrollDistance,
behavior: 'smooth',
});
}
};

View File

@@ -1,6 +1,7 @@
import { Heart } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
interface VideoCardProps {
@@ -11,6 +12,7 @@ interface VideoCardProps {
episodes?: number;
source_name: string;
progress?: number;
from?: string;
}
function CheckCircleCustom() {
@@ -73,11 +75,15 @@ export default function VideoCard({
source,
source_name,
progress,
from,
}: VideoCardProps) {
const [playHover, setPlayHover] = useState(false);
const router = useRouter();
return (
<Link href={`/detail?source=${source}&id=${id}`}>
<Link
href={`/detail?source=${source}&id=${id}${from ? `&from=${from}` : ''}`}
>
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
{/* 海报图片 - 2:3 比例 */}
<div className='relative aspect-[2/3] w-full overflow-hidden'>
@@ -87,12 +93,17 @@ export default function VideoCard({
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group'>
<div className='absolute inset-0 flex items-center justify-center'>
<div
onMouseEnter={() => setPlayHover(true)}
onMouseLeave={() => setPlayHover(false)}
className={`transition-all duration-200 ${
playHover ? 'scale-110' : ''
}`}
style={{ cursor: 'pointer' }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
router.push(`/play?source=${source}&id=${id}`);
}}
onMouseEnter={() => setPlayHover(true)}
onMouseLeave={() => setPlayHover(false)}
>
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
</div>

View File

@@ -1,4 +1,15 @@
import { Film, Folder, Home, Menu, Search, Star, Tv } from 'lucide-react';
import {
Film,
Home,
Menu,
MessageCircleHeart,
MountainSnow,
Search,
Star,
Swords,
Tv,
VenetianMask,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
@@ -6,6 +17,7 @@ import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react';
@@ -36,15 +48,49 @@ interface SidebarProps {
activePath?: string;
}
// 在浏览器环境下通过全局变量缓存折叠状态,避免组件重新挂载时出现初始值闪烁
declare global {
interface Window {
__sidebarCollapsed?: boolean;
}
}
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window === 'undefined') return false;
const saved = localStorage.getItem('sidebarCollapsed');
return saved !== null ? JSON.parse(saved) : false;
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (
typeof window !== 'undefined' &&
typeof window.__sidebarCollapsed === 'boolean'
) {
return window.__sidebarCollapsed;
}
return false; // 默认展开
});
// 首次挂载时读取 localStorage以便刷新后仍保持上次的折叠状态
useLayoutEffect(() => {
const saved = localStorage.getItem('sidebarCollapsed');
if (saved !== null) {
const val = JSON.parse(saved);
setIsCollapsed(val);
window.__sidebarCollapsed = val;
}
}, []);
// 当折叠状态变化时,同步到 <html> data 属性,供首屏 CSS 使用
useLayoutEffect(() => {
if (typeof document !== 'undefined') {
if (isCollapsed) {
document.documentElement.dataset.sidebarCollapsed = 'true';
} else {
delete document.documentElement.dataset.sidebarCollapsed;
}
}
}, [isCollapsed]);
const [active, setActive] = useState(activePath);
useEffect(() => {
@@ -66,6 +112,9 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const newState = !isCollapsed;
setIsCollapsed(newState);
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
if (typeof window !== 'undefined') {
window.__sidebarCollapsed = newState;
}
onToggle?.(newState);
}, [isCollapsed, onToggle]);
@@ -93,16 +142,21 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
label: '豆瓣 Top250',
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
},
{ icon: Folder, label: '美剧', href: '/douban?type=tv&tag=美剧' },
{ icon: Folder, label: '韩剧', href: '/douban?type=tv&tag=韩剧' },
{ icon: Folder, label: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: Folder, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
{ icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' },
{
icon: MessageCircleHeart,
label: '韩剧',
href: '/douban?type=tv&tag=韩剧',
},
{ icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
];
return (
<SidebarContext.Provider value={contextValue}>
<div className='flex'>
<aside
data-sidebar
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg ${
isCollapsed ? 'w-16' : 'w-64'
}`}
@@ -226,7 +280,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
</div>
</aside>
<div
className={`transition-all duration-300 ${
className={`transition-all duration-300 sidebar-offset ${
isCollapsed ? 'w-16' : 'w-64'
}`}
></div>