diff --git a/README.md b/README.md index 2a2a60f..4606867 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# KVideo +# 视频聚合平台 (KVideo) ![KVideo Banner](public/icon.png) @@ -46,6 +46,7 @@ ### 🎬 豆瓣集成 +- **电影 & 电视剧分类**:支持在电影和电视剧之间无缝切换,方便查找不同类型的影视资源 - **详细影视信息**:自动获取豆瓣评分、演员阵容、剧情简介等详细信息 - **推荐系统**:基于豆瓣数据的相关推荐 - **专业评价**:展示豆瓣用户评价和专业影评 @@ -125,9 +126,9 @@ docker run -d -p 3000:3000 -e ACCESS_PASSWORD=your_secret_password --name kvideo | 变量名 | 说明 | 默认值 | |--------|------|--------| -| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` | -| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `Multi-source video aggregation platform with beautiful Liquid Glass UI` | -| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` | +| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `视频聚合平台 - KVideo` | +| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `专属视频聚合播放平台,具备美观的 Liquid Glass UI` | +| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `视频聚合平台` | ### 配置示例: diff --git a/app/api/douban/tags/route.ts b/app/api/douban/tags/route.ts new file mode 100644 index 0000000..bfd8baf --- /dev/null +++ b/app/api/douban/tags/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const type = searchParams.get('type') || 'movie'; // movie or tv + + try { + const url = `https://movie.douban.com/j/search_tags?type=${type}&source=index`; + + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + 'Referer': 'https://movie.douban.com/', + }, + next: { revalidate: 86400 }, // Cache tags for 24 hours + }); + + if (!response.ok) { + throw new Error(`Douban API returned ${response.status}`); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Douban Tags API error:', error); + return NextResponse.json( + { tags: [], error: 'Failed to fetch tags' }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index fa7b9df..73e9ca1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -32,7 +32,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + @@ -47,7 +47,7 @@ export function ThemeSwitcher() { : 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]' } `} - aria-label="Set dark theme" + aria-label="设为深色主题" > @@ -66,7 +66,7 @@ export function ThemeSwitcher() { : 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]' } `} - aria-label="Set system theme" + aria-label="设为系统主题" > diff --git a/components/home/PopularFeatures.tsx b/components/home/PopularFeatures.tsx index 18b156e..6cd68c9 100644 --- a/components/home/PopularFeatures.tsx +++ b/components/home/PopularFeatures.tsx @@ -18,9 +18,11 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) { const { tags, selectedTag, + contentType, newTagInput, showTagManager, justAddedTag, + setContentType, setSelectedTag, setNewTagInput, setShowTagManager, @@ -29,6 +31,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) { handleDeleteTag, handleRestoreDefaults, handleDragEnd, + isLoadingTags, } = useTagManager(); const { @@ -37,7 +40,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) { hasMore, prefetchRef, loadMoreRef, - } = usePopularMovies(selectedTag, tags); + } = usePopularMovies(selectedTag, tags, contentType); const handleMovieClick = (movie: any) => { if (onSearch) { @@ -47,6 +50,33 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) { return (
+ {/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */} +
+
+ {/* Sliding Indicator */} +
+ + + +
+
setJustAddedTag(false)} + isLoadingTags={isLoadingTags} /> void; onDragEnd: (event: DragEndEvent) => void; onJustAddedTagHandled: () => void; + isLoadingTags?: boolean; } export function TagManager({ @@ -34,6 +35,7 @@ export function TagManager({ onAddTag, onDragEnd, onJustAddedTagHandled, + isLoadingTags, }: TagManagerProps) { return ( <> @@ -67,16 +69,23 @@ export function TagManager({ )} {/* Tag Filter */} - + {isLoadingTags ? ( +
+ + 正在加载标签... +
+ ) : ( + + )} ); } diff --git a/components/home/hooks/usePopularMovies.ts b/components/home/hooks/usePopularMovies.ts index 54b4f75..89d7163 100644 --- a/components/home/hooks/usePopularMovies.ts +++ b/components/home/hooks/usePopularMovies.ts @@ -11,7 +11,7 @@ interface DoubanMovie { const PAGE_LIMIT = 20; -export function usePopularMovies(selectedTag: string, tags: any[]) { +export function usePopularMovies(selectedTag: string, tags: any[], contentType: 'movie' | 'tv' = 'movie') { const [movies, setMovies] = useState([]); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); @@ -24,7 +24,7 @@ export function usePopularMovies(selectedTag: string, tags: any[]) { try { const tagValue = tags.find(t => t.id === tag)?.value || '热门'; const response = await fetch( - `/api/douban/recommend?tag=${encodeURIComponent(tagValue)}&page_limit=${PAGE_LIMIT}&page_start=${pageStart}` + `/api/douban/recommend?type=${contentType}&tag=${encodeURIComponent(tagValue)}&page_limit=${PAGE_LIMIT}&page_start=${pageStart}` ); if (!response.ok) throw new Error('Failed to fetch'); @@ -40,14 +40,14 @@ export function usePopularMovies(selectedTag: string, tags: any[]) { } finally { setLoading(false); } - }, [loading, tags]); + }, [loading, tags, contentType]); useEffect(() => { setPage(0); setMovies([]); setHasMore(true); loadMovies(selectedTag, 0, false); - }, [selectedTag]); // eslint-disable-line react-hooks/exhaustive-deps + }, [selectedTag, contentType]); // eslint-disable-line react-hooks/exhaustive-deps const { prefetchRef, loadMoreRef } = useInfiniteScroll({ hasMore, diff --git a/components/home/hooks/useTagManager.ts b/components/home/hooks/useTagManager.ts index 3df8f54..270f7ff 100644 --- a/components/home/hooks/useTagManager.ts +++ b/components/home/hooks/useTagManager.ts @@ -2,50 +2,72 @@ import { useState, useEffect } from 'react'; import { DragEndEvent } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; -const DEFAULT_TAGS = [ - { id: 'popular', label: '热门', value: '热门' }, - { id: 'latest', label: '最新', value: '最新' }, - { id: 'classic', label: '经典', value: '经典' }, - { id: 'highscore', label: '豆瓣高分', value: '豆瓣高分' }, - { id: 'underrated', label: '冷门佳片', value: '冷门佳片' }, - { id: 'chinese', label: '华语', value: '华语' }, - { id: 'western', label: '欧美', value: '欧美' }, - { id: 'korean', label: '韩国', value: '韩国' }, - { id: 'japanese', label: '日本', value: '日本' }, - { id: 'action', label: '动作', value: '动作' }, - { id: 'comedy', label: '喜剧', value: '喜剧' }, - { id: 'variety', label: '综艺', value: '综艺' }, - { id: 'romance', label: '爱情', value: '爱情' }, - { id: 'scifi', label: '科幻', value: '科幻' }, - { id: 'thriller', label: '悬疑', value: '悬疑' }, - { id: 'horror', label: '恐怖', value: '恐怖' }, - { id: 'healing', label: '治愈', value: '治愈' }, -]; +const DEFAULT_TAG = { id: 'popular', label: '热门', value: '热门' }; -const STORAGE_KEY = 'kvideo_custom_tags'; +const STORAGE_KEY_PREFIX = 'kvideo_custom_tags_'; export function useTagManager() { - const [selectedTag, setSelectedTag] = useState('popular'); - const [tags, setTags] = useState(DEFAULT_TAGS); + const [contentType, setContentType] = useState<'movie' | 'tv'>('movie'); + const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.value); + const [tags, setTags] = useState([]); + const [isLoadingTags, setIsLoadingTags] = useState(false); const [newTagInput, setNewTagInput] = useState(''); const [showTagManager, setShowTagManager] = useState(false); const [justAddedTag, setJustAddedTag] = useState(false); - // Load custom tags from localStorage - useEffect(() => { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - try { - setTags(JSON.parse(saved)); - } catch (e) { - console.error('Failed to parse saved tags', e); - } - } - }, []); + const storageKey = `${STORAGE_KEY_PREFIX}${contentType}`; - const saveTags = (newTags: typeof DEFAULT_TAGS) => { + // Load custom tags or fetch from Douban + useEffect(() => { + const loadTags = async () => { + const saved = localStorage.getItem(storageKey); + if (saved) { + try { + setTags(JSON.parse(saved)); + return; + } catch (e) { + console.error('Failed to parse saved tags', e); + } + } + + // If no saved tags, fetch from Douban + setIsLoadingTags(true); + try { + const response = await fetch(`/api/douban/tags?type=${contentType}`); + const data = await response.json(); + if (data.tags && Array.isArray(data.tags)) { + const mappedTags = data.tags.map((label: string) => ({ + id: label === '热门' ? 'popular' : `tag_${label}`, + label, + value: label, + })); + + // If "热门" isn't in the list, add it to the front + if (!mappedTags.some((t: any) => t.value === '热门')) { + mappedTags.unshift(DEFAULT_TAG); + } + + setTags(mappedTags); + // Also save to localStorage to avoid repeated fetches if desired + // Actually, let's just keep them in memory for now unless they customize + } else { + setTags([DEFAULT_TAG]); + } + } catch (error) { + console.error('Fetch tags error:', error); + setTags([DEFAULT_TAG]); + } finally { + setIsLoadingTags(false); + } + }; + + loadTags(); + setSelectedTag(DEFAULT_TAG.value); + }, [contentType, storageKey]); + + const saveTags = (newTags: any[]) => { setTags(newTags); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newTags)); + localStorage.setItem(storageKey, JSON.stringify(newTags)); }; const handleAddTag = () => { @@ -67,9 +89,32 @@ export function useTagManager() { } }; - const handleRestoreDefaults = () => { - saveTags(DEFAULT_TAGS); - setSelectedTag('popular'); + const handleRestoreDefaults = async () => { + localStorage.removeItem(storageKey); + // Refresh by re-fetching + setIsLoadingTags(true); + try { + const response = await fetch(`/api/douban/tags?type=${contentType}`); + const data = await response.json(); + if (data.tags && Array.isArray(data.tags)) { + const mappedTags = data.tags.map((label: string) => ({ + id: label === '热门' ? 'popular' : `tag_${label}`, + label, + value: label, + })); + if (!mappedTags.some((t: any) => t.value === '热门')) { + mappedTags.unshift(DEFAULT_TAG); + } + setTags(mappedTags); + } else { + setTags([DEFAULT_TAG]); + } + } catch (error) { + setTags([DEFAULT_TAG]); + } finally { + setIsLoadingTags(false); + } + setSelectedTag(DEFAULT_TAG.value); setShowTagManager(false); }; @@ -86,9 +131,12 @@ export function useTagManager() { return { tags, selectedTag, + contentType, newTagInput, showTagManager, justAddedTag, + isLoadingTags, + setContentType, setSelectedTag, setNewTagInput, setShowTagManager, diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index b1e1c5d..704f6f9 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -48,7 +48,7 @@ export function Navbar({ onReset, isSecretMode = false }: NavbarProps) { target="_blank" rel="noopener noreferrer" className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer hidden sm:flex" - aria-label="GitHub" + aria-label="GitHub 仓库" > diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx index b1817cf..f736b5c 100644 --- a/components/player/desktop/DesktopMoreMenu.tsx +++ b/components/player/desktop/DesktopMoreMenu.tsx @@ -236,7 +236,7 @@ export function DesktopMoreMenu({ onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className="group flex items-center justify-center w-12 h-12 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95" - aria-label="More options" + aria-label="更多选项" title="更多选项" > diff --git a/components/player/desktop/DesktopOverlay.tsx b/components/player/desktop/DesktopOverlay.tsx index c2d2ba1..024c3d2 100644 --- a/components/player/desktop/DesktopOverlay.tsx +++ b/components/player/desktop/DesktopOverlay.tsx @@ -146,7 +146,7 @@ export function DesktopOverlay({ onSkipBackward(); }} className="group flex items-center justify-center w-10 h-10 md:w-16 md:h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95" - aria-label="Skip Backward 10s" + aria-label="后退 10 秒" > @@ -164,7 +164,7 @@ export function DesktopOverlay({ onSkipForward(); }} className="group flex items-center justify-center w-10 h-10 md:w-16 md:h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95" - aria-label="Skip Forward 10s" + aria-label="前进 10 秒" > @@ -176,7 +176,7 @@ export function DesktopOverlay({ diff --git a/components/player/desktop/DesktopRightControls.tsx b/components/player/desktop/DesktopRightControls.tsx index 165af2a..71be67d 100644 --- a/components/player/desktop/DesktopRightControls.tsx +++ b/components/player/desktop/DesktopRightControls.tsx @@ -34,7 +34,7 @@ export function DesktopRightControls({ diff --git a/components/player/desktop/DesktopSpeedMenu.tsx b/components/player/desktop/DesktopSpeedMenu.tsx index 4d44e64..ed2e9ba 100644 --- a/components/player/desktop/DesktopSpeedMenu.tsx +++ b/components/player/desktop/DesktopSpeedMenu.tsx @@ -85,7 +85,7 @@ export function DesktopSpeedMenu({ onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className="group flex items-center justify-center w-12 h-12 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95 text-white/90 font-medium text-sm" - aria-label="Playback speed" + aria-label="播放速度" > {playbackRate}x diff --git a/components/settings/SourceManager.tsx b/components/settings/SourceManager.tsx index 3de238f..e76f603 100644 --- a/components/settings/SourceManager.tsx +++ b/components/settings/SourceManager.tsx @@ -47,7 +47,7 @@ export function SourceManager({