From 58a8d4910e0e1531e18c80871867834837fdb7b4 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 21 Nov 2025 19:00:23 +0800 Subject: [PATCH] feat: Implement comprehensive search functionality, enhance video player controls for desktop and mobile, and refactor settings and history UI. --- app/api/search-parallel/route.ts | 28 +- app/globals.css | 7 +- app/page.tsx | 110 +------- app/player/page.tsx | 36 +-- app/settings/hooks/useSettingsPage.ts | 89 +++++++ app/settings/page.tsx | 221 +++------------- app/styles/components.css | 245 ------------------ app/styles/effects.css | 85 ++++++ app/styles/glass.css | 65 +++++ app/styles/search-history.css | 86 ++++++ app/styles/{keyframes.css => transitions.css} | 98 ------- app/styles/video-player.css | 92 +++++++ components/history/HistoryFooter.tsx | 24 ++ components/history/HistoryHeader.tsx | 28 ++ components/history/HistoryItem.tsx | 60 +---- components/history/HistoryList.tsx | 39 +++ components/history/PosterImage.tsx | 50 ++++ components/history/WatchHistorySidebar.tsx | 74 +----- components/home/PopularFeatures.tsx | 144 ++-------- components/home/SearchResults.tsx | 67 +++++ components/home/hooks/usePopularMovies.ts | 69 +++++ components/home/hooks/useTagManager.ts | 84 ++++++ components/layout/Navbar.tsx | 57 ++++ components/player/DesktopVideoPlayer.tsx | 108 +------- components/player/MobileVideoPlayer.tsx | 108 ++------ components/player/PlayerNavbar.tsx | 44 ++++ .../player/desktop/DesktopControlsWrapper.tsx | 106 ++++++++ .../player/desktop/DesktopOverlayWrapper.tsx | 39 +++ .../hooks/desktop/useDesktopShortcuts.ts | 124 +++++++++ .../hooks/desktop/useFullscreenControls.ts | 4 +- .../hooks/desktop/usePlaybackControls.ts | 14 +- .../hooks/desktop/useProgressControls.ts | 4 +- .../player/hooks/desktop/useSkipControls.ts | 2 +- .../player/hooks/desktop/useVolumeControls.ts | 4 +- .../hooks/mobile/mobile-player-params.ts | 139 ++++++++++ .../player/hooks/useDesktopPlayerLogic.ts | 131 +++------- components/player/hooks/useMobileGestures.ts | 55 ++++ .../player/hooks/useMobilePlayerLogic.ts | 102 +++----- components/player/mobile/FullControls.tsx | 170 +++--------- .../player/mobile/MobileControlsWrapper.tsx | 90 +++++++ .../player/mobile/controls/LeftControls.tsx | 109 ++++++++ .../player/mobile/controls/RightControls.tsx | 106 ++++++++ components/search/ResultsHeader.tsx | 3 +- components/search/SearchBox.tsx | 123 +++++++++ components/search/SearchForm.tsx | 160 +----------- components/search/SearchHistoryDropdown.tsx | 80 +----- components/search/SearchHistoryEmptyState.tsx | 14 + components/search/SearchHistoryHeader.tsx | 33 +++ components/search/SearchHistoryListItem.tsx | 64 +++++ components/search/SourceBadges.tsx | 23 +- components/search/TypeBadges.tsx | 20 +- components/search/VideoCard.tsx | 13 +- components/search/VideoGrid.tsx | 15 +- .../search/hooks/useSearchBoxHandlers.ts | 102 ++++++++ components/settings/AddSourceModal.tsx | 89 ++----- components/settings/DataSettings.tsx | 44 ++++ components/settings/SettingsHeader.tsx | 30 +++ components/settings/SortSettings.tsx | 31 +++ components/settings/SourceSettings.tsx | 56 ++++ components/settings/hooks/useAddSourceForm.ts | 74 ++++++ components/ui/ModalBackdrop.tsx | 18 ++ components/ui/ModalHeader.tsx | 27 ++ lib/api/client.ts | 171 +----------- lib/api/detail-api.ts | 76 ++++++ lib/api/search-api.ts | 88 +++++++ lib/hooks/mobile/useDeviceDetection.ts | 42 +++ lib/hooks/mobile/useDoubleTap.ts | 88 +++++++ lib/hooks/mobile/useScreenOrientation.ts | 47 ++++ lib/hooks/useMobilePlayer.ts | 178 +------------ lib/hooks/useParallelSearch.ts | 203 +++------------ lib/hooks/useSearchAction.ts | 127 +++++++++ lib/hooks/useSearchState.ts | 50 ++++ lib/hooks/useSourceBadges.ts | 15 +- lib/hooks/useTypeBadges.ts | 16 +- lib/types/index.ts | 19 +- lib/utils/format-utils.ts | 33 +++ lib/utils/search-stream.ts | 68 +++++ lib/utils/sort.ts | 38 +-- lib/utils/sorted-insert.ts | 5 +- 79 files changed, 3259 insertions(+), 2341 deletions(-) create mode 100644 app/settings/hooks/useSettingsPage.ts delete mode 100644 app/styles/components.css create mode 100644 app/styles/effects.css create mode 100644 app/styles/glass.css create mode 100644 app/styles/search-history.css rename app/styles/{keyframes.css => transitions.css} (50%) create mode 100644 app/styles/video-player.css create mode 100644 components/history/HistoryFooter.tsx create mode 100644 components/history/HistoryHeader.tsx create mode 100644 components/history/HistoryList.tsx create mode 100644 components/history/PosterImage.tsx create mode 100644 components/home/SearchResults.tsx create mode 100644 components/home/hooks/usePopularMovies.ts create mode 100644 components/home/hooks/useTagManager.ts create mode 100644 components/layout/Navbar.tsx create mode 100644 components/player/PlayerNavbar.tsx create mode 100644 components/player/desktop/DesktopControlsWrapper.tsx create mode 100644 components/player/desktop/DesktopOverlayWrapper.tsx create mode 100644 components/player/hooks/desktop/useDesktopShortcuts.ts create mode 100644 components/player/hooks/mobile/mobile-player-params.ts create mode 100644 components/player/hooks/useMobileGestures.ts create mode 100644 components/player/mobile/MobileControlsWrapper.tsx create mode 100644 components/player/mobile/controls/LeftControls.tsx create mode 100644 components/player/mobile/controls/RightControls.tsx create mode 100644 components/search/SearchBox.tsx create mode 100644 components/search/SearchHistoryEmptyState.tsx create mode 100644 components/search/SearchHistoryHeader.tsx create mode 100644 components/search/SearchHistoryListItem.tsx create mode 100644 components/search/hooks/useSearchBoxHandlers.ts create mode 100644 components/settings/DataSettings.tsx create mode 100644 components/settings/SettingsHeader.tsx create mode 100644 components/settings/SortSettings.tsx create mode 100644 components/settings/SourceSettings.tsx create mode 100644 components/settings/hooks/useAddSourceForm.ts create mode 100644 components/ui/ModalBackdrop.tsx create mode 100644 components/ui/ModalHeader.tsx create mode 100644 lib/api/detail-api.ts create mode 100644 lib/api/search-api.ts create mode 100644 lib/hooks/mobile/useDeviceDetection.ts create mode 100644 lib/hooks/mobile/useDoubleTap.ts create mode 100644 lib/hooks/mobile/useScreenOrientation.ts create mode 100644 lib/hooks/useSearchAction.ts create mode 100644 lib/hooks/useSearchState.ts create mode 100644 lib/utils/format-utils.ts create mode 100644 lib/utils/search-stream.ts diff --git a/app/api/search-parallel/route.ts b/app/api/search-parallel/route.ts index 77cb22b..e425eb6 100644 --- a/app/api/search-parallel/route.ts +++ b/app/api/search-parallel/route.ts @@ -7,6 +7,7 @@ import { NextRequest } from 'next/server'; import { searchVideos } from '@/lib/api/client'; import { getSourceById } from '@/lib/api/video-sources'; +import { getSourceName } from '@/lib/utils/source-names'; export async function POST(request: NextRequest) { const encoder = new TextEncoder(); @@ -76,7 +77,7 @@ export async function POST(request: NextRequest) { type: 'videos', videos: videos.map((video: any) => ({ ...video, - sourceDisplayName: getSourceDisplayName(source.id), + sourceDisplayName: getSourceName(source.id), latency, // Add latency to each video })), source: source.id, @@ -144,27 +145,4 @@ export async function POST(request: NextRequest) { }); } -/** - * Get display name for source - */ -function getSourceDisplayName(sourceId: string): string { - const sourceNames: Record = { - 'dytt': '电影天堂', - 'ruyi': '如意', - 'baofeng': '暴风', - 'tianya': '天涯', - 'feifan': '非凡影视', - 'sanliuling': '360', - 'wolong': '卧龙', - 'jisu': '极速', - 'mozhua': '魔爪', - 'modu': '魔都', - 'zuida': '最大', - 'yinghua': '樱花', - 'baiduyun': '百度云', - 'wujin': '无尽', - 'wangwang': '旺旺', - 'ikun': 'iKun', - }; - return sourceNames[sourceId] || sourceId; -} + diff --git a/app/globals.css b/app/globals.css index 25d0e40..ad547f4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,5 +2,8 @@ @import "./scroll-optimization.css"; @import "./styles/variables.css"; @import "./styles/base.css"; -@import "./styles/animations.css"; -@import "./styles/components.css"; \ No newline at end of file +@import './styles/transitions.css'; +@import './styles/effects.css'; +@import './styles/glass.css'; +@import "./styles/video-player.css"; +@import "./styles/search-history.css"; \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index f7f6d3e..ee68730 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,22 +2,15 @@ import { useState, useRef, useEffect, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import Image from 'next/image'; -import { ThemeSwitcher } from '@/components/ThemeSwitcher'; import { SearchForm } from '@/components/search/SearchForm'; -import { VideoGrid } from '@/components/search/VideoGrid'; import { NoResults } from '@/components/search/NoResults'; -import { ResultsHeader } from '@/components/search/ResultsHeader'; -import { TypeBadges } from '@/components/search/TypeBadges'; -import { SourceBadges } from '@/components/search/SourceBadges'; import { PopularFeatures } from '@/components/home/PopularFeatures'; import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar'; import { useSearchCache } from '@/lib/hooks/useSearchCache'; import { useParallelSearch } from '@/lib/hooks/useParallelSearch'; -import { useTypeBadges } from '@/lib/hooks/useTypeBadges'; -import { useSourceBadges } from '@/lib/hooks/useSourceBadges'; import { settingsStore } from '@/lib/store/settings-store'; +import { Navbar } from '@/components/layout/Navbar'; +import { SearchResults } from '@/components/home/SearchResults'; function HomePage() { const router = useRouter(); @@ -36,11 +29,9 @@ function HomePage() { availableSources, completedSources, totalSources, - totalVideosFound, performSearch, resetSearch, loadCachedResults, - applySorting, } = useParallelSearch( saveToCache, (q: string) => router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false }) @@ -52,22 +43,6 @@ function HomePage() { setCurrentSortBy(settings.sortBy); }, []); - // Source badges hook - filters by video source - const { - selectedSources, - filteredVideos: sourceFilteredVideos, - toggleSource, - } = useSourceBadges(results, availableSources); - - // Type badges hook - auto-collects and filters by type_name - // Apply on source-filtered results for combined filtering - const { - typeBadges, - selectedTypes, - filteredVideos: finalFilteredVideos, - toggleType, - } = useTypeBadges(sourceFilteredVideos); - // Load cached results on mount useEffect(() => { if (hasLoadedCache.current) return; @@ -105,51 +80,7 @@ function HomePage() { return (
{/* Glass Navbar */} - + {/* Search Form - Separate from navbar */}
{/* Results Section */} {(results.length >= 1 || (!loading && results.length > 0)) && ( -
- - - {/* Source Badges - Clickable video source filtering */} - {availableSources.length > 0 && ( - - )} - - {/* Type Badges - Auto-collected from search results */} - {typeBadges.length > 0 && ( - - )} - - {/* Display filtered videos (both source and type filters applied) */} - -
+ )} {/* Popular Features - Homepage */} diff --git a/app/player/page.tsx b/app/player/page.tsx index dc6eeb5..21c507b 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -3,8 +3,6 @@ import { Suspense, useEffect } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { Button } from '@/components/ui/Button'; -import { ThemeSwitcher } from '@/components/ThemeSwitcher'; -import { Icons } from '@/components/ui/Icon'; import { VideoPlayer } from '@/components/player/VideoPlayer'; import { VideoMetadata } from '@/components/player/VideoMetadata'; import { EpisodeList } from '@/components/player/EpisodeList'; @@ -12,6 +10,7 @@ import { PlayerError } from '@/components/player/PlayerError'; import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer'; import { useHistoryStore } from '@/lib/store/history-store'; import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar'; +import { PlayerNavbar } from '@/components/player/PlayerNavbar'; import Image from 'next/image'; function PlayerContent() { @@ -80,38 +79,7 @@ function PlayerContent() { return (
{/* Glass Navbar */} - +
{loading ? ( diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts new file mode 100644 index 0000000..39926a1 --- /dev/null +++ b/app/settings/hooks/useSettingsPage.ts @@ -0,0 +1,89 @@ +import { useState, useEffect } from 'react'; +import { settingsStore, getDefaultSources, type SortOption } from '@/lib/store/settings-store'; +import type { VideoSource } from '@/lib/types'; + +export function useSettingsPage() { + const [sources, setSources] = useState([]); + const [sortBy, setSortBy] = useState('default'); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); + const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false); + + useEffect(() => { + const settings = settingsStore.getSettings(); + setSources(settings.sources || []); + setSortBy(settings.sortBy); + }, []); + + const handleSourcesChange = (newSources: VideoSource[]) => { + setSources(newSources); + settingsStore.saveSettings({ sources: newSources, sortBy, searchHistory: true, watchHistory: true }); + }; + + const handleAddSource = (source: VideoSource) => { + const updated = [...sources, source]; + handleSourcesChange(updated); + }; + + const handleSortChange = (newSort: SortOption) => { + setSortBy(newSort); + settingsStore.saveSettings({ sources, sortBy: newSort, searchHistory: true, watchHistory: true }); + }; + + const handleExport = (includeSearchHistory: boolean, includeWatchHistory: boolean) => { + const data = settingsStore.exportSettings(includeSearchHistory || includeWatchHistory); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `kvideo-settings-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleImport = (jsonString: string): boolean => { + const success = settingsStore.importSettings(jsonString); + if (success) { + const settings = settingsStore.getSettings(); + setSources(settings.sources); + setSortBy(settings.sortBy); + } + return success; + }; + + const handleRestoreDefaults = () => { + const defaults = getDefaultSources(); + handleSourcesChange(defaults); + setIsRestoreDefaultsDialogOpen(false); + }; + + const handleResetAll = () => { + settingsStore.resetToDefaults(); + setIsResetDialogOpen(false); + window.location.reload(); + }; + + return { + sources, + sortBy, + isAddModalOpen, + isExportModalOpen, + isImportModalOpen, + isResetDialogOpen, + isRestoreDefaultsDialogOpen, + setIsAddModalOpen, + setIsExportModalOpen, + setIsImportModalOpen, + setIsResetDialogOpen, + setIsRestoreDefaultsDialogOpen, + handleSourcesChange, + handleAddSource, + handleSortChange, + handleExport, + handleImport, + handleRestoreDefaults, + handleResetAll, + }; +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 4799d90..d1f8ab7 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,200 +1,64 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { settingsStore, sortOptions, getDefaultSources, type SortOption } from '@/lib/store/settings-store'; -import type { VideoSource } from '@/lib/types'; -import { SourceManager } from '@/components/settings/SourceManager'; import { AddSourceModal } from '@/components/settings/AddSourceModal'; import { ExportModal } from '@/components/settings/ExportModal'; import { ImportModal } from '@/components/settings/ImportModal'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { SourceSettings } from '@/components/settings/SourceSettings'; +import { SortSettings } from '@/components/settings/SortSettings'; +import { DataSettings } from '@/components/settings/DataSettings'; +import { SettingsHeader } from '@/components/settings/SettingsHeader'; +import { useSettingsPage } from './hooks/useSettingsPage'; export default function SettingsPage() { - const router = useRouter(); - const [sources, setSources] = useState([]); - const [sortBy, setSortBy] = useState('default'); - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [isExportModalOpen, setIsExportModalOpen] = useState(false); - const [isImportModalOpen, setIsImportModalOpen] = useState(false); - const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); - const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false); - const [showAllSources, setShowAllSources] = useState(false); - - useEffect(() => { - const settings = settingsStore.getSettings(); - setSources(settings.sources || []); - setSortBy(settings.sortBy); - }, []); - - const handleSourcesChange = (newSources: VideoSource[]) => { - setSources(newSources); - settingsStore.saveSettings({ sources: newSources, sortBy, searchHistory: true, watchHistory: true }); - }; - - const handleAddSource = (source: VideoSource) => { - const updated = [...sources, source]; - handleSourcesChange(updated); - }; - - const handleSortChange = (newSort: SortOption) => { - setSortBy(newSort); - settingsStore.saveSettings({ sources, sortBy: newSort, searchHistory: true, watchHistory: true }); - }; - - const handleExport = (includeSearchHistory: boolean, includeWatchHistory: boolean) => { - const data = settingsStore.exportSettings(includeSearchHistory || includeWatchHistory); - const blob = new Blob([data], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `kvideo-settings-${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); - }; - - const handleImport = (jsonString: string): boolean => { - const success = settingsStore.importSettings(jsonString); - if (success) { - const settings = settingsStore.getSettings(); - setSources(settings.sources); - setSortBy(settings.sortBy); - } - return success; - }; - - const handleRestoreDefaults = () => { - const defaults = getDefaultSources(); - handleSourcesChange(defaults); - setIsRestoreDefaultsDialogOpen(false); - }; - - const handleResetAll = () => { - settingsStore.resetToDefaults(); - setIsResetDialogOpen(false); - window.location.reload(); - }; + const { + sources, + sortBy, + isAddModalOpen, + isExportModalOpen, + isImportModalOpen, + isResetDialogOpen, + isRestoreDefaultsDialogOpen, + setIsAddModalOpen, + setIsExportModalOpen, + setIsImportModalOpen, + setIsResetDialogOpen, + setIsRestoreDefaultsDialogOpen, + handleSourcesChange, + handleAddSource, + handleSortChange, + handleExport, + handleImport, + handleRestoreDefaults, + handleResetAll, + } = useSettingsPage(); return (
{/* Header */} -
- -
-
- - - -
-
-

设置

-

管理应用程序配置

-
-
-
+ {/* Source Management */} -
-
-

视频源管理

-
- - -
-
-

- 管理视频来源,调整优先级和启用状态 -

- - {sources.length > 10 && ( - - )} -
+ setIsRestoreDefaultsDialogOpen(true)} + onAddSource={() => setIsAddModalOpen(true)} + /> {/* Sort Options */} -
-

搜索结果排序

-

- 选择搜索结果的默认排序方式 -

-
- {(Object.keys(sortOptions) as SortOption[]).map((option) => ( - - ))} -
-
+ {/* Data Management */} -
-

数据管理

-
- - - - - -
-
+ setIsExportModalOpen(true)} + onImport={() => setIsImportModalOpen(true)} + onReset={() => setIsResetDialogOpen(true)} + />
{/* Modals */} @@ -240,3 +104,4 @@ export default function SettingsPage() {
); } + diff --git a/app/styles/components.css b/app/styles/components.css deleted file mode 100644 index 2af98b4..0000000 --- a/app/styles/components.css +++ /dev/null @@ -1,245 +0,0 @@ -/* Glass Components */ -.glass-card { - background: var(--bg-color); - opacity: 1; - border-radius: var(--radius-2xl); - box-shadow: var(--shadow-sm); - border: 1px solid var(--glass-border); - transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; - transform: translateZ(0); - will-change: transform; -} - -.glass-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -.glass-input { - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: var(--radius-2xl); - color: var(--text-color); - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.glass-input:focus { - outline: none; - border-color: var(--accent-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 30%, transparent); -} - -.glass-button { - background: var(--accent-color); - color: white; - border: none; - border-radius: var(--radius-2xl); - padding: 0.75rem 1.25rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: var(--shadow-sm); -} - -.glass-button:hover { - transform: translateY(-2px); - filter: brightness(1.1); - box-shadow: var(--shadow-md); -} - -.glass-button:active { - transform: translateY(0) scale(0.98); - filter: brightness(0.95); -} - -.glass-badge { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.25rem 0.75rem; - border-radius: var(--radius-full); - font-size: 0.8rem; - font-weight: 600; - background-color: var(--accent-color); - color: white; -} - -/* Video Player Components */ -.spinner { - width: 48px; - height: 48px; - border: 5px solid color-mix(in srgb, var(--glass-bg) 50%, transparent); - border-top-color: var(--accent-color); - border-radius: var(--radius-full); - animation: spin 1s linear infinite; -} - -.btn-icon { - display: flex; - align-items: center; - justify-content: center; - min-width: 2.5rem; - height: 2.5rem; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: var(--radius-2xl); - color: white; - cursor: pointer; - transition: all 0.2s ease; - -webkit-tap-highlight-color: transparent; - touch-action: manipulation; - user-select: none; - -webkit-user-select: none; - position: relative; - z-index: 10; -} - -.btn-icon:hover { - background: rgba(255, 255, 255, 0.2); - transform: scale(1.05); -} - -.btn-icon:active { - transform: scale(0.95); -} - -.slider-track { - position: relative; - width: 100%; - height: 8px; - background: rgba(255, 255, 255, 0.3); - border-radius: var(--radius-full); - cursor: pointer; - overflow: visible; - user-select: none; - -webkit-user-select: none; -} - -.slider-track.h-1 { - height: 4px; -} - -.slider-range { - position: absolute; - left: 0; - top: 0; - height: 100%; - background-color: var(--accent-color); - border-radius: var(--radius-full); - pointer-events: none; -} - -.slider-thumb { - position: absolute; - top: 50%; - width: 16px; - height: 16px; - background-color: white; - border-radius: var(--radius-full); - transform: translate(-50%, -50%); - cursor: grab; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - pointer-events: none; -} - -.slider-track.h-1 .slider-thumb { - width: 12px; - height: 12px; -} - -.slider-track:hover .slider-thumb { - transform: translate(-50%, -50%) scale(1.2); -} - -.slider-thumb:active, -.slider-track:active .slider-thumb { - transform: translate(-50%, -50%) scale(1.3); - cursor: grabbing; -} - -/* Search History Dropdown */ -.search-history-dropdown { - max-height: 400px; - overflow-y: auto; - background: var(--bg-color); - opacity: 0.98; - border-radius: var(--radius-2xl); - box-shadow: var(--shadow-md); - border: 1px solid var(--glass-border); - padding: 0.75rem; - opacity: 0; - transform: translateY(-10px) scale(0.95); - animation: search-dropdown-appear 0.2s ease-out forwards; - transform-origin: top center; - user-select: none; - -webkit-user-select: none; - will-change: transform, opacity; -} - -.search-history-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 0.75rem; - margin-bottom: 0.5rem; -} - -.search-history-divider { - height: 1px; - width: 100%; - background: var(--glass-border); - margin: 0.5rem 0; -} - -.search-history-list { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.search-history-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - border-radius: var(--radius-2xl); - padding: 0.75rem 1rem; - cursor: pointer; - transition: background-color 0.2s ease; - background: transparent; -} - -.search-history-item:hover { - background: color-mix(in srgb, var(--accent-color) 10%, transparent); -} - -.search-history-item.highlighted { - background: color-mix(in srgb, var(--accent-color) 15%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-color) 30%, transparent); -} - -.search-history-remove { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: var(--radius-full); - background: transparent; - border: none; - color: var(--text-color-secondary); - cursor: pointer; - flex-shrink: 0; - transition: all 0.2s ease; - z-index: 1; -} - -.search-history-remove:hover { - background: color-mix(in srgb, var(--text-color-secondary) 20%, transparent); - color: var(--text-color); - transform: scale(1.1); -} - -.search-history-remove:active { - transform: scale(0.95); -} \ No newline at end of file diff --git a/app/styles/effects.css b/app/styles/effects.css new file mode 100644 index 0000000..3a49490 --- /dev/null +++ b/app/styles/effects.css @@ -0,0 +1,85 @@ +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes spin-reverse { + from { + transform: rotate(360deg); + } + + to { + transform: rotate(0deg); + } +} + +@keyframes bounce-subtle { + + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-5px); + } +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} + +@keyframes float { + + 0%, + 100% { + transform: translateY(0) translateX(0); + opacity: 0.3; + } + + 50% { + transform: translateY(-20px) translateX(10px); + opacity: 0.8; + } +} + +@keyframes gradient-x { + + 0%, + 100% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } +} \ No newline at end of file diff --git a/app/styles/glass.css b/app/styles/glass.css new file mode 100644 index 0000000..2ead853 --- /dev/null +++ b/app/styles/glass.css @@ -0,0 +1,65 @@ +/* Glass Components */ +.glass-card { + background: var(--bg-color); + opacity: 1; + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-sm); + border: 1px solid var(--glass-border); + transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; + transform: translateZ(0); + will-change: transform; +} + +.glass-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.glass-input { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-2xl); + color: var(--text-color); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.glass-input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 30%, transparent); +} + +.glass-button { + background: var(--accent-color); + color: white; + border: none; + border-radius: var(--radius-2xl); + padding: 0.75rem 1.25rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: var(--shadow-sm); +} + +.glass-button:hover { + transform: translateY(-2px); + filter: brightness(1.1); + box-shadow: var(--shadow-md); +} + +.glass-button:active { + transform: translateY(0) scale(0.98); + filter: brightness(0.95); +} + +.glass-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); + font-size: 0.8rem; + font-weight: 600; + background-color: var(--accent-color); + color: white; +} diff --git a/app/styles/search-history.css b/app/styles/search-history.css new file mode 100644 index 0000000..f785b47 --- /dev/null +++ b/app/styles/search-history.css @@ -0,0 +1,86 @@ +/* Search History Dropdown */ +.search-history-dropdown { + max-height: 400px; + overflow-y: auto; + background: var(--bg-color); + opacity: 0.98; + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-md); + border: 1px solid var(--glass-border); + padding: 0.75rem; + opacity: 0; + transform: translateY(-10px) scale(0.95); + animation: search-dropdown-appear 0.2s ease-out forwards; + transform-origin: top center; + user-select: none; + -webkit-user-select: none; + will-change: transform, opacity; +} + +.search-history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; +} + +.search-history-divider { + height: 1px; + width: 100%; + background: var(--glass-border); + margin: 0.5rem 0; +} + +.search-history-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.search-history-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-radius: var(--radius-2xl); + padding: 0.75rem 1rem; + cursor: pointer; + transition: background-color 0.2s ease; + background: transparent; +} + +.search-history-item:hover { + background: color-mix(in srgb, var(--accent-color) 10%, transparent); +} + +.search-history-item.highlighted { + background: color-mix(in srgb, var(--accent-color) 15%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-color) 30%, transparent); +} + +.search-history-remove { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-full); + background: transparent; + border: none; + color: var(--text-color-secondary); + cursor: pointer; + flex-shrink: 0; + transition: all 0.2s ease; + z-index: 1; +} + +.search-history-remove:hover { + background: color-mix(in srgb, var(--text-color-secondary) 20%, transparent); + color: var(--text-color); + transform: scale(1.1); +} + +.search-history-remove:active { + transform: scale(0.95); +} \ No newline at end of file diff --git a/app/styles/keyframes.css b/app/styles/transitions.css similarity index 50% rename from app/styles/keyframes.css rename to app/styles/transitions.css index 2b9a4aa..9c156c5 100644 --- a/app/styles/keyframes.css +++ b/app/styles/transitions.css @@ -22,66 +22,6 @@ } } -@keyframes pulse { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.5; - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@keyframes spin-slow { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -@keyframes spin-reverse { - from { - transform: rotate(360deg); - } - - to { - transform: rotate(0deg); - } -} - -@keyframes bounce-subtle { - - 0%, - 100% { - transform: translateY(0); - } - - 50% { - transform: translateY(-5px); - } -} - -@keyframes shimmer { - 0% { - transform: translateX(-100%); - } - - 100% { - transform: translateX(100%); - } -} - @keyframes scale-in { 0% { opacity: 0; @@ -106,32 +46,6 @@ } } -@keyframes float { - - 0%, - 100% { - transform: translateY(0) translateX(0); - opacity: 0.3; - } - - 50% { - transform: translateY(-20px) translateX(10px); - opacity: 0.8; - } -} - -@keyframes gradient-x { - - 0%, - 100% { - background-position: 0% 50%; - } - - 50% { - background-position: 100% 50%; - } -} - @keyframes slideIn { from { opacity: 0; @@ -174,16 +88,4 @@ opacity: 1; transform: translateY(0); } -} - -@keyframes search-dropdown-appear { - from { - opacity: 0; - transform: translateY(-10px) scale(0.95); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } } \ No newline at end of file diff --git a/app/styles/video-player.css b/app/styles/video-player.css new file mode 100644 index 0000000..f3ad52d --- /dev/null +++ b/app/styles/video-player.css @@ -0,0 +1,92 @@ +/* Video Player Components */ +.spinner { + width: 48px; + height: 48px; + border: 5px solid color-mix(in srgb, var(--glass-bg) 50%, transparent); + border-top-color: var(--accent-color); + border-radius: var(--radius-full); + animation: spin 1s linear infinite; +} + +.btn-icon { + display: flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--radius-2xl); + color: white; + cursor: pointer; + transition: all 0.2s ease; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; + position: relative; + z-index: 10; +} + +.btn-icon:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.05); +} + +.btn-icon:active { + transform: scale(0.95); +} + +.slider-track { + position: relative; + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.3); + border-radius: var(--radius-full); + cursor: pointer; + overflow: visible; + user-select: none; + -webkit-user-select: none; +} + +.slider-track.h-1 { + height: 4px; +} + +.slider-range { + position: absolute; + left: 0; + top: 0; + height: 100%; + background-color: var(--accent-color); + border-radius: var(--radius-full); + pointer-events: none; +} + +.slider-thumb { + position: absolute; + top: 50%; + width: 16px; + height: 16px; + background-color: white; + border-radius: var(--radius-full); + transform: translate(-50%, -50%); + cursor: grab; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + pointer-events: none; +} + +.slider-track.h-1 .slider-thumb { + width: 12px; + height: 12px; +} + +.slider-track:hover .slider-thumb { + transform: translate(-50%, -50%) scale(1.2); +} + +.slider-thumb:active, +.slider-track:active .slider-thumb { + transform: translate(-50%, -50%) scale(1.3); + cursor: grabbing; +} \ No newline at end of file diff --git a/components/history/HistoryFooter.tsx b/components/history/HistoryFooter.tsx new file mode 100644 index 0000000..abae619 --- /dev/null +++ b/components/history/HistoryFooter.tsx @@ -0,0 +1,24 @@ +import { Icons } from '@/components/ui/Icon'; +import { Button } from '@/components/ui/Button'; + +interface HistoryFooterProps { + hasHistory: boolean; + onClearAll: () => void; +} + +export function HistoryFooter({ hasHistory, onClearAll }: HistoryFooterProps) { + if (!hasHistory) return null; + + return ( +
+ +
+ ); +} diff --git a/components/history/HistoryHeader.tsx b/components/history/HistoryHeader.tsx new file mode 100644 index 0000000..1fb25aa --- /dev/null +++ b/components/history/HistoryHeader.tsx @@ -0,0 +1,28 @@ +import { Icons } from '@/components/ui/Icon'; + +interface HistoryHeaderProps { + onClose: () => void; +} + +export function HistoryHeader({ onClose }: HistoryHeaderProps) { + return ( +
+
+ +

+ 观看历史 +

+
+ +
+ ); +} diff --git a/components/history/HistoryItem.tsx b/components/history/HistoryItem.tsx index 716ccb1..563476f 100644 --- a/components/history/HistoryItem.tsx +++ b/components/history/HistoryItem.tsx @@ -5,6 +5,8 @@ import Image from 'next/image'; import { Icons } from '@/components/ui/Icon'; +import { formatTime, formatDate } from '@/lib/utils/format-utils'; +import { PosterImage } from './PosterImage'; interface HistoryItemProps { videoId: string | number; @@ -31,30 +33,6 @@ export function HistoryItem({ timestamp, onRemove, }: HistoryItemProps) { - const formatTime = (seconds: number): string => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - return `${minutes}:${secs.toString().padStart(2, '0')}`; - }; - - const formatDate = (ts: number): string => { - const date = new Date(ts); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - - if (days === 0) return '今天'; - if (days === 1) return '昨天'; - if (days < 7) return `${days}天前`; - - return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); - }; - const getVideoUrl = (): string => { const params = new URLSearchParams({ id: videoId.toString(), @@ -95,39 +73,7 @@ export function HistoryItem({ >
{/* Poster */} -
- {poster ? ( - {title} { - const target = e.currentTarget as HTMLImageElement; - target.style.display = 'none'; - const parent = target.parentElement; - if (parent) { - const fallback = document.createElement('div'); - fallback.className = 'w-full h-full flex items-center justify-center'; - fallback.innerHTML = ''; - parent.appendChild(fallback); - } - }} - /> - ) : ( -
- -
- )} - {/* Progress overlay */} -
-
-
-
+ {/* Info */}
diff --git a/components/history/HistoryList.tsx b/components/history/HistoryList.tsx new file mode 100644 index 0000000..5e29c17 --- /dev/null +++ b/components/history/HistoryList.tsx @@ -0,0 +1,39 @@ +import { HistoryItem } from './HistoryItem'; +import { HistoryEmptyState } from './HistoryEmptyState'; +import type { VideoHistoryItem } from '@/lib/types'; + +interface HistoryListProps { + history: VideoHistoryItem[]; + onRemove: (videoId: string | number, source: string) => void; +} + +export function HistoryList({ history, onRemove }: HistoryListProps) { + return ( +
+ {history.length === 0 ? ( + + ) : ( +
+ {history.map((item) => ( + onRemove(item.videoId, item.source)} + /> + ))} +
+ )} +
+ ); +} diff --git a/components/history/PosterImage.tsx b/components/history/PosterImage.tsx new file mode 100644 index 0000000..84059b5 --- /dev/null +++ b/components/history/PosterImage.tsx @@ -0,0 +1,50 @@ +/** + * PosterImage - Video poster with fallback handling + */ + +import Image from 'next/image'; +import { Icons } from '@/components/ui/Icon'; + +interface PosterImageProps { + poster?: string; + title: string; + progress: number; +} + +export function PosterImage({ poster, title, progress }: PosterImageProps) { + return ( +
+ {poster ? ( + {title} { + const target = e.currentTarget as HTMLImageElement; + target.style.display = 'none'; + const parent = target.parentElement; + if (parent) { + const fallback = document.createElement('div'); + fallback.className = 'w-full h-full flex items-center justify-center'; + fallback.innerHTML = ''; + parent.appendChild(fallback); + } + }} + /> + ) : ( +
+ +
+ )} + {/* Progress overlay */} +
+
+
+
+ ); +} diff --git a/components/history/WatchHistorySidebar.tsx b/components/history/WatchHistorySidebar.tsx index cb90e68..d0b3725 100644 --- a/components/history/WatchHistorySidebar.tsx +++ b/components/history/WatchHistorySidebar.tsx @@ -8,10 +8,10 @@ import { useState, useEffect, useRef } from 'react'; import { useHistoryStore } from '@/lib/store/history-store'; import { Icons } from '@/components/ui/Icon'; -import { Button } from '@/components/ui/Button'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; -import { HistoryItem } from './HistoryItem'; -import { HistoryEmptyState } from './HistoryEmptyState'; +import { HistoryHeader } from './HistoryHeader'; +import { HistoryList } from './HistoryList'; +import { HistoryFooter } from './HistoryFooter'; import { trapFocus } from '@/lib/accessibility/focus-management'; export function WatchHistorySidebar() { @@ -110,67 +110,17 @@ export function WatchHistorySidebar() { }} className={`fixed top-0 right-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-250 ease-out`} > - {/* Header */} -
-
- -

- 观看历史 -

-
- -
+ setIsOpen(false)} /> - {/* Content */} -
- {viewingHistory.length === 0 ? ( - - ) : ( -
- {viewingHistory.map((item) => ( - handleDeleteItem(item.videoId, item.source)} - /> - ))} -
- )} -
+ - {/* Footer */} - {viewingHistory.length > 0 && ( -
- -
- )} + 0} + onClearAll={handleClearAll} + /> {/* Confirm Dialog */} diff --git a/components/home/PopularFeatures.tsx b/components/home/PopularFeatures.tsx index 8565187..e9b77f1 100644 --- a/components/home/PopularFeatures.tsx +++ b/components/home/PopularFeatures.tsx @@ -5,145 +5,43 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; import { TagManager } from './TagManager'; import { MovieGrid } from './MovieGrid'; -import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll'; - -interface DoubanMovie { - id: string; - title: string; - cover: string; - rate: string; - url: string; -} +import { useTagManager } from './hooks/useTagManager'; +import { usePopularMovies } from './hooks/usePopularMovies'; interface PopularFeaturesProps { onSearch?: (query: string) => void; } -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 STORAGE_KEY = 'kvideo_custom_tags'; -const PAGE_LIMIT = 20; - export function PopularFeatures({ onSearch }: PopularFeaturesProps) { - const [selectedTag, setSelectedTag] = useState('popular'); - const [tags, setTags] = useState(DEFAULT_TAGS); - const [movies, setMovies] = useState([]); - const [loading, setLoading] = useState(false); - const [hasMore, setHasMore] = useState(true); - const [page, setPage] = useState(0); - const [newTagInput, setNewTagInput] = useState(''); - const [showTagManager, setShowTagManager] = useState(false); + const { + tags, + selectedTag, + newTagInput, + showTagManager, + setSelectedTag, + setNewTagInput, + setShowTagManager, + handleAddTag, + handleDeleteTag, + handleRestoreDefaults, + } = useTagManager(); - // 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 saveTags = (newTags: typeof DEFAULT_TAGS) => { - setTags(newTags); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newTags)); - }; - - const loadMovies = useCallback(async (tag: string, pageStart: number, append = false) => { - if (loading) return; - - setLoading(true); - 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}` - ); - - if (!response.ok) throw new Error('Failed to fetch'); - - const data = await response.json(); - const newMovies = data.subjects || []; - - setMovies(prev => append ? [...prev, ...newMovies] : newMovies); - setHasMore(newMovies.length === PAGE_LIMIT); - } catch (error) { - console.error('Failed to load movies:', error); - setHasMore(false); - } finally { - setLoading(false); - } - }, [loading, tags]); - - useEffect(() => { - setPage(0); - setMovies([]); - setHasMore(true); - loadMovies(selectedTag, 0, false); - }, [selectedTag]); - - const { prefetchRef, loadMoreRef } = useInfiniteScroll({ - hasMore, + const { + movies, loading, - page, - onLoadMore: (nextPage) => { - setPage(nextPage); - loadMovies(selectedTag, nextPage * PAGE_LIMIT, true); - }, - }); + hasMore, + prefetchRef, + loadMoreRef, + } = usePopularMovies(selectedTag, tags); - const handleMovieClick = (movie: DoubanMovie) => { + const handleMovieClick = (movie: any) => { if (onSearch) { onSearch(movie.title); } }; - const handleAddTag = () => { - if (!newTagInput.trim()) return; - const newTag = { - id: `custom_${Date.now()}`, - label: newTagInput.trim(), - value: newTagInput.trim(), - }; - saveTags([...tags, newTag]); - setNewTagInput(''); - }; - - const handleDeleteTag = (tagId: string) => { - saveTags(tags.filter(t => t.id !== tagId)); - if (selectedTag === tagId) { - setSelectedTag('popular'); - } - }; - - const handleRestoreDefaults = () => { - saveTags(DEFAULT_TAGS); - setSelectedTag('popular'); - setShowTagManager(false); - }; - return (
+ + + {/* Source Badges - Clickable video source filtering */} + {availableSources.length > 0 && ( + + )} + + {/* Type Badges - Auto-collected from search results */} + {typeBadges.length > 0 && ( + + )} + + {/* Display filtered videos (both source and type filters applied) */} + +
+ ); +} diff --git a/components/home/hooks/usePopularMovies.ts b/components/home/hooks/usePopularMovies.ts new file mode 100644 index 0000000..54b4f75 --- /dev/null +++ b/components/home/hooks/usePopularMovies.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll'; + +interface DoubanMovie { + id: string; + title: string; + cover: string; + rate: string; + url: string; +} + +const PAGE_LIMIT = 20; + +export function usePopularMovies(selectedTag: string, tags: any[]) { + const [movies, setMovies] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(0); + + const loadMovies = useCallback(async (tag: string, pageStart: number, append = false) => { + if (loading) return; + + setLoading(true); + 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}` + ); + + if (!response.ok) throw new Error('Failed to fetch'); + + const data = await response.json(); + const newMovies = data.subjects || []; + + setMovies(prev => append ? [...prev, ...newMovies] : newMovies); + setHasMore(newMovies.length === PAGE_LIMIT); + } catch (error) { + console.error('Failed to load movies:', error); + setHasMore(false); + } finally { + setLoading(false); + } + }, [loading, tags]); + + useEffect(() => { + setPage(0); + setMovies([]); + setHasMore(true); + loadMovies(selectedTag, 0, false); + }, [selectedTag]); // eslint-disable-line react-hooks/exhaustive-deps + + const { prefetchRef, loadMoreRef } = useInfiniteScroll({ + hasMore, + loading, + page, + onLoadMore: (nextPage) => { + setPage(nextPage); + loadMovies(selectedTag, nextPage * PAGE_LIMIT, true); + }, + }); + + return { + movies, + loading, + hasMore, + prefetchRef, + loadMoreRef, + }; +} diff --git a/components/home/hooks/useTagManager.ts b/components/home/hooks/useTagManager.ts new file mode 100644 index 0000000..2b5803b --- /dev/null +++ b/components/home/hooks/useTagManager.ts @@ -0,0 +1,84 @@ +import { useState, useEffect } from 'react'; + +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 STORAGE_KEY = 'kvideo_custom_tags'; + +export function useTagManager() { + const [selectedTag, setSelectedTag] = useState('popular'); + const [tags, setTags] = useState(DEFAULT_TAGS); + const [newTagInput, setNewTagInput] = useState(''); + const [showTagManager, setShowTagManager] = 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 saveTags = (newTags: typeof DEFAULT_TAGS) => { + setTags(newTags); + localStorage.setItem(STORAGE_KEY, JSON.stringify(newTags)); + }; + + const handleAddTag = () => { + if (!newTagInput.trim()) return; + const newTag = { + id: `custom_${Date.now()}`, + label: newTagInput.trim(), + value: newTagInput.trim(), + }; + saveTags([...tags, newTag]); + setNewTagInput(''); + }; + + const handleDeleteTag = (tagId: string) => { + saveTags(tags.filter(t => t.id !== tagId)); + if (selectedTag === tagId) { + setSelectedTag('popular'); + } + }; + + const handleRestoreDefaults = () => { + saveTags(DEFAULT_TAGS); + setSelectedTag('popular'); + setShowTagManager(false); + }; + + return { + tags, + selectedTag, + newTagInput, + showTagManager, + setSelectedTag, + setNewTagInput, + setShowTagManager, + handleAddTag, + handleDeleteTag, + handleRestoreDefaults, + }; +} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx new file mode 100644 index 0000000..799bf27 --- /dev/null +++ b/components/layout/Navbar.tsx @@ -0,0 +1,57 @@ +import Link from 'next/link'; +import Image from 'next/image'; +import { ThemeSwitcher } from '@/components/ThemeSwitcher'; + +interface NavbarProps { + onReset: () => void; +} + +export function Navbar({ onReset }: NavbarProps) { + return ( + + ); +} diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index d699a19..e58bc8b 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -2,8 +2,8 @@ import { useDesktopPlayerState } from './hooks/useDesktopPlayerState'; import { useDesktopPlayerLogic } from './hooks/useDesktopPlayerLogic'; -import { DesktopControls } from './desktop/DesktopControls'; -import { DesktopOverlay } from './desktop/DesktopOverlay'; +import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper'; +import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper'; interface DesktopVideoPlayerProps { src: string; @@ -24,37 +24,12 @@ export function DesktopVideoPlayer({ const { videoRef, containerRef, - progressBarRef, - volumeBarRef, - moreMenuTimeoutRef } = refs; const { isPlaying, - currentTime, - duration, - volume, - isMuted, - isFullscreen, - showControls, - isLoading, - playbackRate, - showSpeedMenu, - isPiPSupported, - isAirPlaySupported, - skipForwardAmount, - skipBackwardAmount, - showSkipForwardIndicator, - showSkipBackwardIndicator, - isSkipForwardAnimatingOut, - isSkipBackwardAnimatingOut, - showVolumeBar, - toastMessage, - showToast, - showMoreMenu, setShowControls, setIsLoading, - setShowMoreMenu } = state; const logic = useDesktopPlayerLogic({ @@ -74,25 +49,8 @@ export function DesktopVideoPlayer({ handleTimeUpdateEvent, handleLoadedMetadata, handleVideoError, - handleProgressClick, - handleProgressMouseDown, - toggleMute, - handleVolumeChange, - handleVolumeMouseDown, - toggleFullscreen, - togglePictureInPicture, - showAirPlayMenu, - skipForward, - skipBackward, - changePlaybackSpeed, - handleCopyLink, - startSpeedMenuTimeout, - clearSpeedMenuTimeout, - formatTime } = logic; - const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; - return (
- - state.setShowSpeedMenu(!showSpeedMenu)} - onToggleMoreMenu={() => setShowMoreMenu(!showMoreMenu)} - onSpeedChange={changePlaybackSpeed} - onCopyLink={handleCopyLink} - onProgressClick={handleProgressClick} - onProgressMouseDown={handleProgressMouseDown} - onSpeedMenuMouseEnter={clearSpeedMenuTimeout} - onSpeedMenuMouseLeave={startSpeedMenuTimeout} - onMoreMenuMouseEnter={() => { - if (moreMenuTimeoutRef.current) { - clearTimeout(moreMenuTimeoutRef.current); - } - }} - onMoreMenuMouseLeave={() => { - moreMenuTimeoutRef.current = setTimeout(() => { - setShowMoreMenu(false); - }, 300); - }} - formatTime={formatTime} - speeds={speeds} +
); diff --git a/components/player/MobileVideoPlayer.tsx b/components/player/MobileVideoPlayer.tsx index 6afb461..f8a76c8 100644 --- a/components/player/MobileVideoPlayer.tsx +++ b/components/player/MobileVideoPlayer.tsx @@ -1,10 +1,11 @@ 'use client'; import { useEffect } from 'react'; -import { useDoubleTap, useScreenOrientation } from '@/lib/hooks/useMobilePlayer'; +import { useScreenOrientation } from '@/lib/hooks/useMobilePlayer'; import { useMobilePlayerState } from './hooks/useMobilePlayerState'; import { useMobilePlayerLogic } from './hooks/useMobilePlayerLogic'; -import { MobileControls } from './mobile/MobileControls'; +import { useMobileGestures } from './hooks/useMobileGestures'; +import { MobileControlsWrapper } from './mobile/MobileControlsWrapper'; import { MobileOverlay } from './mobile/MobileOverlay'; import { MobileSkipIndicator } from './mobile/MobileSkipIndicator'; @@ -27,30 +28,19 @@ export function MobileVideoPlayer({ const { videoRef, containerRef, - progressBarRef, controlsTimeoutRef } = refs; const { isPlaying, - currentTime, - duration, - volume, - isMuted, isFullscreen, showControls, isLoading, - playbackRate, - showSpeedMenu, - showVolumeMenu, - showMoreMenu, - isPiPSupported, + showSkipIndicator, skipAmount, skipSide, - showSkipIndicator, toastMessage, showToast, - viewportWidth, setShowControls, setIsLoading } = state; @@ -72,55 +62,23 @@ export function MobileVideoPlayer({ handleTimeUpdateEvent, handleLoadedMetadata, handleVideoError, - handleProgressTouchStart, - handleProgressTouchMove, - handleProgressTouchEnd, - handleProgressClick, - toggleMute, - toggleFullscreen, - togglePictureInPicture, - changePlaybackSpeed, - handleCopyLink, - formatTime } = logic; // Screen orientation management useScreenOrientation(isFullscreen); // Double tap handler - const { handleTap } = useDoubleTap({ - onDoubleTapLeft: () => skipVideo(10, 'left'), - onDoubleTapRight: () => skipVideo(10, 'right'), - onSkipContinueLeft: () => skipVideo(10, 'left'), - onSkipContinueRight: () => skipVideo(10, 'right'), - isSkipModeActive: showSkipIndicator, - onSingleTap: () => { - if (!showControls) { - setShowControls(true); - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - if (isPlaying) { - controlsTimeoutRef.current = setTimeout(() => { - setShowControls(false); - }, 3000); - } - } else { - togglePlay(); - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - } - if (isPlaying) { - controlsTimeoutRef.current = setTimeout(() => { - setShowControls(false); - }, 3000); - } - } - }, + const { handleTap } = useMobileGestures({ + skipVideo, + showSkipIndicator, + showControls, + setShowControls, + controlsTimeoutRef, + isPlaying, + togglePlay, }); - const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; - const isCompactLayout = viewportWidth < 640; + return (
- state.setShowMoreMenu(!showMoreMenu)} - onToggleVolumeMenu={() => state.setShowVolumeMenu(!showVolumeMenu)} - onToggleSpeedMenu={() => state.setShowSpeedMenu(!showSpeedMenu)} - onTogglePiP={togglePictureInPicture} - onVolumeChange={(v) => { - state.setVolume(v); - if (videoRef.current) videoRef.current.volume = v; - state.setIsMuted(v === 0); - }} - onSpeedChange={changePlaybackSpeed} - onCopyLink={handleCopyLink} - onProgressClick={handleProgressClick} - onProgressTouchStart={handleProgressTouchStart} - onProgressTouchMove={handleProgressTouchMove} - onProgressTouchEnd={handleProgressTouchEnd} - formatTime={formatTime} - speeds={speeds} +
); diff --git a/components/player/PlayerNavbar.tsx b/components/player/PlayerNavbar.tsx new file mode 100644 index 0000000..b168f97 --- /dev/null +++ b/components/player/PlayerNavbar.tsx @@ -0,0 +1,44 @@ +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { Button } from '@/components/ui/Button'; +import { ThemeSwitcher } from '@/components/ThemeSwitcher'; +import { Icons } from '@/components/ui/Icon'; + +export function PlayerNavbar() { + const router = useRouter(); + + return ( + + ); +} diff --git a/components/player/desktop/DesktopControlsWrapper.tsx b/components/player/desktop/DesktopControlsWrapper.tsx new file mode 100644 index 0000000..d53ed00 --- /dev/null +++ b/components/player/desktop/DesktopControlsWrapper.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { DesktopControls } from './DesktopControls'; +import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState'; +import { useDesktopPlayerLogic } from '../hooks/useDesktopPlayerLogic'; + +interface DesktopControlsWrapperProps { + state: ReturnType['state']; + logic: ReturnType; + refs: ReturnType['refs']; +} + +export function DesktopControlsWrapper({ state, logic, refs }: DesktopControlsWrapperProps) { + const { + isPlaying, + currentTime, + duration, + volume, + isMuted, + isFullscreen, + showControls, + playbackRate, + showSpeedMenu, + showMoreMenu, + showVolumeBar, + isPiPSupported, + isAirPlaySupported, + setShowSpeedMenu, + setShowMoreMenu, + } = state; + + const { + togglePlay, + skipForward, + skipBackward, + toggleMute, + handleVolumeChange, + handleVolumeMouseDown, + toggleFullscreen, + togglePictureInPicture, + showAirPlayMenu, + changePlaybackSpeed, + handleCopyLink, + handleProgressClick, + handleProgressMouseDown, + startSpeedMenuTimeout, + clearSpeedMenuTimeout, + formatTime, + } = logic; + + const { + progressBarRef, + volumeBarRef, + moreMenuTimeoutRef, + } = refs; + + const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; + + return ( + setShowSpeedMenu(!showSpeedMenu)} + onToggleMoreMenu={() => setShowMoreMenu(!showMoreMenu)} + onSpeedChange={changePlaybackSpeed} + onCopyLink={handleCopyLink} + onProgressClick={handleProgressClick} + onProgressMouseDown={handleProgressMouseDown} + onSpeedMenuMouseEnter={clearSpeedMenuTimeout} + onSpeedMenuMouseLeave={startSpeedMenuTimeout} + onMoreMenuMouseEnter={() => { + if (moreMenuTimeoutRef.current) { + clearTimeout(moreMenuTimeoutRef.current); + } + }} + onMoreMenuMouseLeave={() => { + moreMenuTimeoutRef.current = setTimeout(() => { + setShowMoreMenu(false); + }, 300); + }} + formatTime={formatTime} + speeds={speeds} + /> + ); +} diff --git a/components/player/desktop/DesktopOverlayWrapper.tsx b/components/player/desktop/DesktopOverlayWrapper.tsx new file mode 100644 index 0000000..862cbd3 --- /dev/null +++ b/components/player/desktop/DesktopOverlayWrapper.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { DesktopOverlay } from './DesktopOverlay'; +import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState'; + +interface DesktopOverlayWrapperProps { + state: ReturnType['state']; + onTogglePlay: () => void; +} + +export function DesktopOverlayWrapper({ state, onTogglePlay }: DesktopOverlayWrapperProps) { + const { + isLoading, + isPlaying, + showSkipForwardIndicator, + showSkipBackwardIndicator, + skipForwardAmount, + skipBackwardAmount, + isSkipForwardAnimatingOut, + isSkipBackwardAnimatingOut, + showToast, + toastMessage, + } = state; + + return ( + + ); +} diff --git a/components/player/hooks/desktop/useDesktopShortcuts.ts b/components/player/hooks/desktop/useDesktopShortcuts.ts new file mode 100644 index 0000000..f9a5f2b --- /dev/null +++ b/components/player/hooks/desktop/useDesktopShortcuts.ts @@ -0,0 +1,124 @@ +import { useEffect } from 'react'; + +interface UseDesktopShortcutsProps { + videoRef: React.RefObject; + isPlaying: boolean; + volume: number; + isPiPSupported: boolean; + togglePlay: () => void; + toggleMute: () => void; + toggleFullscreen: () => void; + togglePictureInPicture: () => void; + skipForward: () => void; + skipBackward: () => void; + showVolumeBarTemporarily: () => void; + setShowControls: (show: boolean) => void; + setVolume: (volume: number) => void; + setIsMuted: (muted: boolean) => void; + controlsTimeoutRef: React.MutableRefObject; +} + +export function useDesktopShortcuts({ + videoRef, + isPlaying, + volume, + isPiPSupported, + togglePlay, + toggleMute, + toggleFullscreen, + togglePictureInPicture, + skipForward, + skipBackward, + showVolumeBarTemporarily, + setShowControls, + setVolume, + setIsMuted, + controlsTimeoutRef, +}: UseDesktopShortcutsProps) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore shortcuts if typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + // Show controls on any key press + setShowControls(true); + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + if (isPlaying) { + controlsTimeoutRef.current = setTimeout(() => { + setShowControls(false); + }, 3000); + } + + switch (e.key.toLowerCase()) { + case ' ': + case 'k': + e.preventDefault(); + togglePlay(); + break; + case 'f': + e.preventDefault(); + toggleFullscreen(); + break; + case 'm': + e.preventDefault(); + toggleMute(); + break; + case 'p': + if (isPiPSupported) { + e.preventDefault(); + togglePictureInPicture(); + } + break; + case 'arrowright': + case 'l': + e.preventDefault(); + skipForward(); + break; + case 'arrowleft': + case 'j': + e.preventDefault(); + skipBackward(); + break; + case 'arrowup': + e.preventDefault(); + const newVolUp = Math.min(1, volume + 0.1); + setVolume(newVolUp); + if (videoRef.current) videoRef.current.volume = newVolUp; + setIsMuted(newVolUp === 0); + showVolumeBarTemporarily(); + break; + case 'arrowdown': + e.preventDefault(); + const newVolDown = Math.max(0, volume - 0.1); + setVolume(newVolDown); + if (videoRef.current) videoRef.current.volume = newVolDown; + setIsMuted(newVolDown === 0); + showVolumeBarTemporarily(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + videoRef, + isPlaying, + volume, + isPiPSupported, + togglePlay, + toggleMute, + toggleFullscreen, + togglePictureInPicture, + skipForward, + skipBackward, + showVolumeBarTemporarily, + setShowControls, + setVolume, + setIsMuted, + controlsTimeoutRef, + ]); +} diff --git a/components/player/hooks/desktop/useFullscreenControls.ts b/components/player/hooks/desktop/useFullscreenControls.ts index 56dde6a..b441d31 100644 --- a/components/player/hooks/desktop/useFullscreenControls.ts +++ b/components/player/hooks/desktop/useFullscreenControls.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect } from 'react'; interface UseFullscreenControlsProps { - containerRef: React.RefObject; - videoRef: React.RefObject; + containerRef: React.RefObject; + videoRef: React.RefObject; isFullscreen: boolean; setIsFullscreen: (fullscreen: boolean) => void; isPiPSupported: boolean; diff --git a/components/player/hooks/desktop/usePlaybackControls.ts b/components/player/hooks/desktop/usePlaybackControls.ts index 8d0318f..d578593 100644 --- a/components/player/hooks/desktop/usePlaybackControls.ts +++ b/components/player/hooks/desktop/usePlaybackControls.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; interface UsePlaybackControlsProps { - videoRef: React.RefObject; + videoRef: React.RefObject; isPlaying: boolean; setIsPlaying: (playing: boolean) => void; setIsLoading: (loading: boolean) => void; @@ -11,6 +11,9 @@ interface UsePlaybackControlsProps { onTimeUpdate?: (currentTime: number, duration: number) => void; onError?: (error: string) => void; isDraggingProgressRef: React.MutableRefObject; + speedMenuTimeoutRef: React.MutableRefObject; + setPlaybackRate: (rate: number) => void; + setShowSpeedMenu: (show: boolean) => void; } export function usePlaybackControls({ @@ -23,7 +26,10 @@ export function usePlaybackControls({ setCurrentTime, onTimeUpdate, onError, - isDraggingProgressRef + isDraggingProgressRef, + speedMenuTimeoutRef, + setPlaybackRate, + setShowSpeedMenu }: UsePlaybackControlsProps) { const togglePlay = useCallback(() => { if (!videoRef.current) return; @@ -67,7 +73,7 @@ export function usePlaybackControls({ } }, [setIsLoading, onError]); - const changePlaybackSpeed = useCallback((speed: number, speedMenuTimeoutRef: React.MutableRefObject, setPlaybackRate: (rate: number) => void, setShowSpeedMenu: (show: boolean) => void) => { + const changePlaybackSpeed = useCallback((speed: number) => { if (!videoRef.current) return; videoRef.current.playbackRate = speed; setPlaybackRate(speed); @@ -75,7 +81,7 @@ export function usePlaybackControls({ if (speedMenuTimeoutRef.current) { clearTimeout(speedMenuTimeoutRef.current); } - }, [videoRef]); + }, [videoRef, setPlaybackRate, setShowSpeedMenu, speedMenuTimeoutRef]); const formatTime = useCallback((seconds: number) => { if (isNaN(seconds)) return '0:00:00'; diff --git a/components/player/hooks/desktop/useProgressControls.ts b/components/player/hooks/desktop/useProgressControls.ts index daf6a13..51b30c4 100644 --- a/components/player/hooks/desktop/useProgressControls.ts +++ b/components/player/hooks/desktop/useProgressControls.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect } from 'react'; interface UseProgressControlsProps { - videoRef: React.RefObject; - progressBarRef: React.RefObject; + videoRef: React.RefObject; + progressBarRef: React.RefObject; duration: number; setCurrentTime: (time: number) => void; isDraggingProgressRef: React.MutableRefObject; diff --git a/components/player/hooks/desktop/useSkipControls.ts b/components/player/hooks/desktop/useSkipControls.ts index 07597cb..bbf46cd 100644 --- a/components/player/hooks/desktop/useSkipControls.ts +++ b/components/player/hooks/desktop/useSkipControls.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; interface UseSkipControlsProps { - videoRef: React.RefObject; + videoRef: React.RefObject; duration: number; setCurrentTime: (time: number) => void; showSkipForwardIndicator: boolean; diff --git a/components/player/hooks/desktop/useVolumeControls.ts b/components/player/hooks/desktop/useVolumeControls.ts index 33146c6..31c11ee 100644 --- a/components/player/hooks/desktop/useVolumeControls.ts +++ b/components/player/hooks/desktop/useVolumeControls.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect } from 'react'; interface UseVolumeControlsProps { - videoRef: React.RefObject; - volumeBarRef: React.RefObject; + videoRef: React.RefObject; + volumeBarRef: React.RefObject; volume: number; isMuted: boolean; setVolume: (volume: number) => void; diff --git a/components/player/hooks/mobile/mobile-player-params.ts b/components/player/hooks/mobile/mobile-player-params.ts new file mode 100644 index 0000000..d06c685 --- /dev/null +++ b/components/player/hooks/mobile/mobile-player-params.ts @@ -0,0 +1,139 @@ +/** + * Parameter builder utilities for mobile player hooks + */ + +export function buildPlaybackParams(props: any) { + const { + videoRef, + isPlaying, + setIsPlaying, + setIsLoading, + initialTime, + setDuration, + setCurrentTime, + setPlaybackRate, + setShowMoreMenu, + setShowVolumeMenu, + setShowSpeedMenu, + onTimeUpdate, + onError, + isDraggingProgressRef, + isTogglingRef, + } = props; + + return { + videoRef, + isPlaying, + setIsPlaying, + setIsLoading, + initialTime, + setDuration, + setCurrentTime, + setPlaybackRate, + setShowMoreMenu, + setShowVolumeMenu, + setShowSpeedMenu, + onTimeUpdate, + onError, + isDraggingProgressRef, + isTogglingRef, + }; +} + +export function buildProgressParams(props: any) { + const { videoRef, progressBarRef, duration, setCurrentTime, isDraggingProgressRef } = props; + return { videoRef, progressBarRef, duration, setCurrentTime, isDraggingProgressRef }; +} + +export function buildSkipParams(props: any) { + const { + videoRef, + duration, + setCurrentTime, + skipAmount, + skipSide, + setSkipAmount, + setSkipSide, + setShowSkipIndicator, + skipTimeoutRef, + } = props; + + return { + videoRef, + duration, + setCurrentTime, + skipAmount, + skipSide, + setSkipAmount, + setSkipSide, + setShowSkipIndicator, + skipTimeoutRef, + }; +} + +export function buildFullscreenParams(props: any) { + const { containerRef, videoRef, isFullscreen, setIsFullscreen, isPiPSupported, setIsPiPSupported } = props; + return { containerRef, videoRef, isFullscreen, setIsFullscreen, isPiPSupported, setIsPiPSupported }; +} + +export function buildUtilitiesParams(props: any) { + const { + src, + volume, + isMuted, + videoRef, + setVolume, + setIsMuted, + setViewportWidth, + setToastMessage, + setShowToast, + toastTimeoutRef, + } = props; + + return { + src, + volume, + isMuted, + videoRef, + setVolume, + setIsMuted, + setViewportWidth, + setToastMessage, + setShowToast, + toastTimeoutRef, + }; +} + +export function buildMenuParams(props: any) { + const { + videoRef, + isPlaying, + showMoreMenu, + showVolumeMenu, + showSpeedMenu, + wasPlayingBeforeMenu, + setShowControls, + setShowMoreMenu, + setShowVolumeMenu, + setShowSpeedMenu, + setWasPlayingBeforeMenu, + controlsTimeoutRef, + menuIdleTimeoutRef, + } = props; + + return { + videoRef, + isPlaying, + showMoreMenu, + showVolumeMenu, + showSpeedMenu, + wasPlayingBeforeMenu, + setShowControls, + setShowMoreMenu, + setShowVolumeMenu, + setShowSpeedMenu, + setWasPlayingBeforeMenu, + controlsTimeoutRef, + menuIdleTimeoutRef, + }; +} diff --git a/components/player/hooks/useDesktopPlayerLogic.ts b/components/player/hooks/useDesktopPlayerLogic.ts index 2cd1a48..69e2ee5 100644 --- a/components/player/hooks/useDesktopPlayerLogic.ts +++ b/components/player/hooks/useDesktopPlayerLogic.ts @@ -3,17 +3,20 @@ import { useVolumeControls } from './desktop/useVolumeControls'; import { useProgressControls } from './desktop/useProgressControls'; import { useSkipControls } from './desktop/useSkipControls'; import { useFullscreenControls } from './desktop/useFullscreenControls'; -import { useKeyboardShortcuts } from './desktop/useKeyboardShortcuts'; import { useControlsVisibility } from './desktop/useControlsVisibility'; import { useUtilities } from './desktop/useUtilities'; +import { useDesktopShortcuts } from './desktop/useDesktopShortcuts'; +import { useDesktopPlayerState } from './useDesktopPlayerState'; + +type DesktopPlayerState = ReturnType; interface UseDesktopPlayerLogicProps { src: string; initialTime: number; onError?: (error: string) => void; onTimeUpdate?: (currentTime: number, duration: number) => void; - refs: any; - state: any; + refs: DesktopPlayerState['refs']; + state: DesktopPlayerState['state']; } export function useDesktopPlayerLogic({ @@ -25,19 +28,10 @@ export function useDesktopPlayerLogic({ state }: UseDesktopPlayerLogicProps) { const { - videoRef, - containerRef, - progressBarRef, - volumeBarRef, - controlsTimeoutRef, - speedMenuTimeoutRef, - skipForwardTimeoutRef, - skipBackwardTimeoutRef, - volumeBarTimeoutRef, - isDraggingProgressRef, - isDraggingVolumeRef, - mouseMoveThrottleRef, - toastTimeoutRef + videoRef, containerRef, progressBarRef, volumeBarRef, + controlsTimeoutRef, speedMenuTimeoutRef, skipForwardTimeoutRef, + skipBackwardTimeoutRef, volumeBarTimeoutRef, isDraggingProgressRef, + isDraggingVolumeRef, mouseMoveThrottleRef, toastTimeoutRef } = refs; const { @@ -57,102 +51,54 @@ export function useDesktopPlayerLogic({ skipBackwardAmount, setSkipBackwardAmount, showSkipForwardIndicator, setShowSkipForwardIndicator, showSkipBackwardIndicator, setShowSkipBackwardIndicator, - setIsSkipForwardAnimatingOut, - setIsSkipBackwardAnimatingOut, - setShowVolumeBar, - setToastMessage, - setShowToast + setIsSkipForwardAnimatingOut, setIsSkipBackwardAnimatingOut, + setShowVolumeBar, setToastMessage, setShowToast } = state; const playbackControls = usePlaybackControls({ - videoRef, - isPlaying, - setIsPlaying, - setIsLoading, - initialTime, - setDuration, - setCurrentTime, - onTimeUpdate, - onError, - isDraggingProgressRef + videoRef, isPlaying, setIsPlaying, setIsLoading, + initialTime, setDuration, setCurrentTime, onTimeUpdate, onError, + isDraggingProgressRef, speedMenuTimeoutRef, setPlaybackRate, setShowSpeedMenu }); const volumeControls = useVolumeControls({ - videoRef, - volumeBarRef, - volume, - isMuted, - setVolume, - setIsMuted, - setShowVolumeBar, - volumeBarTimeoutRef, - isDraggingVolumeRef + videoRef, volumeBarRef, volume, isMuted, + setVolume, setIsMuted, setShowVolumeBar, + volumeBarTimeoutRef, isDraggingVolumeRef }); const progressControls = useProgressControls({ - videoRef, - progressBarRef, - duration, - setCurrentTime, - isDraggingProgressRef + videoRef, progressBarRef, duration, + setCurrentTime, isDraggingProgressRef }); const skipControls = useSkipControls({ - videoRef, - duration, - setCurrentTime, - showSkipForwardIndicator, - showSkipBackwardIndicator, - skipForwardAmount, - skipBackwardAmount, - setShowSkipForwardIndicator, - setShowSkipBackwardIndicator, - setSkipForwardAmount, - setSkipBackwardAmount, - setIsSkipForwardAnimatingOut, - setIsSkipBackwardAnimatingOut, - skipForwardTimeoutRef, - skipBackwardTimeoutRef + videoRef, duration, setCurrentTime, + showSkipForwardIndicator, showSkipBackwardIndicator, + skipForwardAmount, skipBackwardAmount, + setShowSkipForwardIndicator, setShowSkipBackwardIndicator, + setSkipForwardAmount, setSkipBackwardAmount, + setIsSkipForwardAnimatingOut, setIsSkipBackwardAnimatingOut, + skipForwardTimeoutRef, skipBackwardTimeoutRef }); const fullscreenControls = useFullscreenControls({ - containerRef, - videoRef, - isFullscreen, - setIsFullscreen, - isPiPSupported, - isAirPlaySupported, - setIsPiPSupported, - setIsAirPlaySupported + containerRef, videoRef, isFullscreen, setIsFullscreen, + isPiPSupported, isAirPlaySupported, setIsPiPSupported, setIsAirPlaySupported }); const controlsVisibility = useControlsVisibility({ - isPlaying, - showControls, - showSpeedMenu, - setShowControls, - setShowSpeedMenu, - controlsTimeoutRef, - speedMenuTimeoutRef, - mouseMoveThrottleRef + isPlaying, showControls, showSpeedMenu, + setShowControls, setShowSpeedMenu, + controlsTimeoutRef, speedMenuTimeoutRef, mouseMoveThrottleRef }); const utilities = useUtilities({ - src, - setToastMessage, - setShowToast, - toastTimeoutRef + src, setToastMessage, setShowToast, toastTimeoutRef }); - const changePlaybackSpeed = (speed: number) => { - playbackControls.changePlaybackSpeed(speed, speedMenuTimeoutRef, setPlaybackRate, setShowSpeedMenu); - }; - - useKeyboardShortcuts({ - videoRef, - isPlaying, - volume, - isPiPSupported, + useDesktopShortcuts({ + videoRef, isPlaying, volume, isPiPSupported, togglePlay: playbackControls.togglePlay, toggleMute: volumeControls.toggleMute, toggleFullscreen: fullscreenControls.toggleFullscreen, @@ -160,10 +106,7 @@ export function useDesktopPlayerLogic({ skipForward: skipControls.skipForward, skipBackward: skipControls.skipBackward, showVolumeBarTemporarily: volumeControls.showVolumeBarTemporarily, - setShowControls, - setVolume, - setIsMuted, - controlsTimeoutRef + setShowControls, setVolume, setIsMuted, controlsTimeoutRef }); return { @@ -185,7 +128,7 @@ export function useDesktopPlayerLogic({ showAirPlayMenu: fullscreenControls.showAirPlayMenu, skipForward: skipControls.skipForward, skipBackward: skipControls.skipBackward, - changePlaybackSpeed, + changePlaybackSpeed: playbackControls.changePlaybackSpeed, handleCopyLink: utilities.handleCopyLink, startSpeedMenuTimeout: controlsVisibility.startSpeedMenuTimeout, clearSpeedMenuTimeout: controlsVisibility.clearSpeedMenuTimeout, diff --git a/components/player/hooks/useMobileGestures.ts b/components/player/hooks/useMobileGestures.ts new file mode 100644 index 0000000..51696c0 --- /dev/null +++ b/components/player/hooks/useMobileGestures.ts @@ -0,0 +1,55 @@ +import { MutableRefObject } from 'react'; +import { useDoubleTap } from '@/lib/hooks/useMobilePlayer'; + +interface UseMobileGesturesProps { + skipVideo: (seconds: number, side: 'left' | 'right') => void; + showSkipIndicator: boolean; + showControls: boolean; + setShowControls: (show: boolean) => void; + controlsTimeoutRef: MutableRefObject; + isPlaying: boolean; + togglePlay: () => void; +} + +export function useMobileGestures({ + skipVideo, + showSkipIndicator, + showControls, + setShowControls, + controlsTimeoutRef, + isPlaying, + togglePlay, +}: UseMobileGesturesProps) { + const { handleTap } = useDoubleTap({ + onDoubleTapLeft: () => skipVideo(10, 'left'), + onDoubleTapRight: () => skipVideo(10, 'right'), + onSkipContinueLeft: () => skipVideo(10, 'left'), + onSkipContinueRight: () => skipVideo(10, 'right'), + isSkipModeActive: showSkipIndicator, + onSingleTap: () => { + if (!showControls) { + setShowControls(true); + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + if (isPlaying) { + controlsTimeoutRef.current = setTimeout(() => { + setShowControls(false); + }, 3000); + } + } else { + togglePlay(); + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + if (isPlaying) { + controlsTimeoutRef.current = setTimeout(() => { + setShowControls(false); + }, 3000); + } + } + }, + }); + + return { handleTap }; +} diff --git a/components/player/hooks/useMobilePlayerLogic.ts b/components/player/hooks/useMobilePlayerLogic.ts index 943a7c1..0058639 100644 --- a/components/player/hooks/useMobilePlayerLogic.ts +++ b/components/player/hooks/useMobilePlayerLogic.ts @@ -4,6 +4,14 @@ import { useMobileSkipControls } from './mobile/useMobileSkipControls'; import { useMobileFullscreenControls } from './mobile/useMobileFullscreenControls'; import { useMobileMenuControls } from './mobile/useMobileMenuControls'; import { useMobileUtilities } from './mobile/useMobileUtilities'; +import { + buildPlaybackParams, + buildProgressParams, + buildSkipParams, + buildFullscreenParams, + buildUtilitiesParams, + buildMenuParams, +} from './mobile/mobile-player-params'; interface UseMobilePlayerLogicProps { src: string; @@ -58,81 +66,35 @@ export function useMobilePlayerLogic({ setViewportWidth } = state; - const playbackControls = useMobilePlaybackControls({ - videoRef, - isPlaying, - setIsPlaying, - setIsLoading, - initialTime, - setDuration, - setCurrentTime, - setPlaybackRate, - setShowMoreMenu, - setShowVolumeMenu, - setShowSpeedMenu, - onTimeUpdate, - onError, - isDraggingProgressRef, - isTogglingRef - }); + const playbackControls = useMobilePlaybackControls(buildPlaybackParams({ + videoRef, isPlaying, setIsPlaying, setIsLoading, initialTime, setDuration, + setCurrentTime, setPlaybackRate, setShowMoreMenu, setShowVolumeMenu, + setShowSpeedMenu, onTimeUpdate, onError, isDraggingProgressRef, isTogglingRef + })); - const progressControls = useMobileProgressControls({ - videoRef, - progressBarRef, - duration, - setCurrentTime, - isDraggingProgressRef - }); + const progressControls = useMobileProgressControls(buildProgressParams({ + videoRef, progressBarRef, duration, setCurrentTime, isDraggingProgressRef + })); - const skipControls = useMobileSkipControls({ - videoRef, - duration, - setCurrentTime, - skipAmount, - skipSide, - setSkipAmount, - setSkipSide, - setShowSkipIndicator, - skipTimeoutRef - }); + const skipControls = useMobileSkipControls(buildSkipParams({ + videoRef, duration, setCurrentTime, skipAmount, skipSide, setSkipAmount, + setSkipSide, setShowSkipIndicator, skipTimeoutRef + })); - const fullscreenControls = useMobileFullscreenControls({ - containerRef, - videoRef, - isFullscreen, - setIsFullscreen, - isPiPSupported, - setIsPiPSupported - }); + const fullscreenControls = useMobileFullscreenControls(buildFullscreenParams({ + containerRef, videoRef, isFullscreen, setIsFullscreen, isPiPSupported, setIsPiPSupported + })); - const utilities = useMobileUtilities({ - src, - volume, - isMuted, - videoRef, - setVolume, - setIsMuted, - setViewportWidth, - setToastMessage, - setShowToast, - toastTimeoutRef - }); + const utilities = useMobileUtilities(buildUtilitiesParams({ + src, volume, isMuted, videoRef, setVolume, setIsMuted, setViewportWidth, + setToastMessage, setShowToast, toastTimeoutRef + })); - useMobileMenuControls({ - videoRef, - isPlaying, - showMoreMenu, - showVolumeMenu, - showSpeedMenu, - wasPlayingBeforeMenu, - setShowControls, - setShowMoreMenu, - setShowVolumeMenu, - setShowSpeedMenu, - setWasPlayingBeforeMenu, - controlsTimeoutRef, - menuIdleTimeoutRef - }); + useMobileMenuControls(buildMenuParams({ + videoRef, isPlaying, showMoreMenu, showVolumeMenu, showSpeedMenu, + wasPlayingBeforeMenu, setShowControls, setShowMoreMenu, setShowVolumeMenu, + setShowSpeedMenu, setWasPlayingBeforeMenu, controlsTimeoutRef, menuIdleTimeoutRef + })); return { skipVideo: skipControls.skipVideo, diff --git a/components/player/mobile/FullControls.tsx b/components/player/mobile/FullControls.tsx index b446fd6..5d1ae6d 100644 --- a/components/player/mobile/FullControls.tsx +++ b/components/player/mobile/FullControls.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { Icons } from '@/components/ui/Icon'; -import { MobileVolumeMenu } from './MobileVolumeMenu'; -import { MobileSpeedMenu } from './MobileSpeedMenu'; +import { LeftControls } from './controls/LeftControls'; +import { RightControls } from './controls/RightControls'; interface FullControlsProps { isPlaying: boolean; @@ -62,140 +61,43 @@ export function FullControls({ }: FullControlsProps) { return (
-
- - - - - - -
- - - -
- - - {formatTime(currentTime)} / {formatTime(duration)} - -
+
-
-
- - - -
- - {isPiPSupported && ( - - )} - - - - -
+
); } diff --git a/components/player/mobile/MobileControlsWrapper.tsx b/components/player/mobile/MobileControlsWrapper.tsx new file mode 100644 index 0000000..85dfccf --- /dev/null +++ b/components/player/mobile/MobileControlsWrapper.tsx @@ -0,0 +1,90 @@ +import { MobileControls } from './MobileControls'; +import { useMobilePlayerState } from '../hooks/useMobilePlayerState'; +import { useMobilePlayerLogic } from '../hooks/useMobilePlayerLogic'; + +interface MobileControlsWrapperProps { + state: ReturnType['state']; + logic: ReturnType; + refs: ReturnType['refs']; +} + +export function MobileControlsWrapper({ state, logic, refs }: MobileControlsWrapperProps) { + const { + isPlaying, + currentTime, + duration, + volume, + isMuted, + isFullscreen, + showControls, + playbackRate, + showSpeedMenu, + showVolumeMenu, + showMoreMenu, + isPiPSupported, + viewportWidth, + } = state; + + const { + progressBarRef, + videoRef, + } = refs; + + const { + togglePlay, + skipVideo, + toggleMute, + toggleFullscreen, + togglePictureInPicture, + changePlaybackSpeed, + handleCopyLink, + handleProgressClick, + handleProgressTouchStart, + handleProgressTouchMove, + handleProgressTouchEnd, + formatTime, + } = logic; + + const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; + const isCompactLayout = viewportWidth < 640; + + return ( + state.setShowMoreMenu(!showMoreMenu)} + onToggleVolumeMenu={() => state.setShowVolumeMenu(!showVolumeMenu)} + onToggleSpeedMenu={() => state.setShowSpeedMenu(!showSpeedMenu)} + onTogglePiP={togglePictureInPicture} + onVolumeChange={(v) => { + state.setVolume(v); + if (videoRef.current) videoRef.current.volume = v; + state.setIsMuted(v === 0); + }} + onSpeedChange={changePlaybackSpeed} + onCopyLink={handleCopyLink} + onProgressClick={handleProgressClick} + onProgressTouchStart={handleProgressTouchStart} + onProgressTouchMove={handleProgressTouchMove} + onProgressTouchEnd={handleProgressTouchEnd} + formatTime={formatTime} + speeds={speeds} + /> + ); +} diff --git a/components/player/mobile/controls/LeftControls.tsx b/components/player/mobile/controls/LeftControls.tsx new file mode 100644 index 0000000..ae0fc8a --- /dev/null +++ b/components/player/mobile/controls/LeftControls.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Icons } from '@/components/ui/Icon'; +import { MobileVolumeMenu } from '../MobileVolumeMenu'; + +interface LeftControlsProps { + isPlaying: boolean; + onTogglePlay: () => void; + onSkipVideo: (seconds: number, side: 'left' | 'right') => void; + isMuted: boolean; + volume: number; + showVolumeMenu: boolean; + onToggleVolumeMenu: () => void; + onToggleMute: () => void; + onVolumeChange: (volume: number) => void; + currentTime: number; + duration: number; + formatTime: (seconds: number) => string; + iconSize: number; + buttonPadding: string; + textSize: string; + controlsGap: string; +} + +export function LeftControls({ + isPlaying, + onTogglePlay, + onSkipVideo, + isMuted, + volume, + showVolumeMenu, + onToggleVolumeMenu, + onToggleMute, + onVolumeChange, + currentTime, + duration, + formatTime, + iconSize, + buttonPadding, + textSize, + controlsGap, +}: LeftControlsProps) { + return ( +
+ + + + + + +
+ + + +
+ + + {formatTime(currentTime)} / {formatTime(duration)} + +
+ ); +} diff --git a/components/player/mobile/controls/RightControls.tsx b/components/player/mobile/controls/RightControls.tsx new file mode 100644 index 0000000..45ef73a --- /dev/null +++ b/components/player/mobile/controls/RightControls.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { Icons } from '@/components/ui/Icon'; +import { MobileSpeedMenu } from '../MobileSpeedMenu'; + +interface RightControlsProps { + playbackRate: number; + showSpeedMenu: boolean; + onToggleSpeedMenu: () => void; + speeds: number[]; + onSpeedChange: (speed: number) => void; + isPiPSupported: boolean; + onTogglePiP: () => void; + onToggleMoreMenu: () => void; + isFullscreen: boolean; + onToggleFullscreen: () => void; + iconSize: number; + buttonPadding: string; + textSize: string; + controlsGap: string; +} + +export function RightControls({ + playbackRate, + showSpeedMenu, + onToggleSpeedMenu, + speeds, + onSpeedChange, + isPiPSupported, + onTogglePiP, + onToggleMoreMenu, + isFullscreen, + onToggleFullscreen, + iconSize, + buttonPadding, + textSize, + controlsGap, +}: RightControlsProps) { + return ( +
+
+ + + +
+ + {isPiPSupported && ( + + )} + + + + +
+ ); +} diff --git a/components/search/ResultsHeader.tsx b/components/search/ResultsHeader.tsx index fe22b42..fbcc52a 100644 --- a/components/search/ResultsHeader.tsx +++ b/components/search/ResultsHeader.tsx @@ -2,11 +2,12 @@ import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; +import type { SourceBadge } from '@/lib/types'; interface ResultsHeaderProps { loading: boolean; resultsCount: number; - availableSources: Array<{ id: string; name: string; count: number }>; + availableSources: SourceBadge[]; } export function ResultsHeader({ diff --git a/components/search/SearchBox.tsx b/components/search/SearchBox.tsx new file mode 100644 index 0000000..699d32f --- /dev/null +++ b/components/search/SearchBox.tsx @@ -0,0 +1,123 @@ +import { useState, FormEvent, useEffect, useRef } from 'react'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { Icons } from '@/components/ui/Icon'; +import { SearchHistoryDropdown } from '@/components/search/SearchHistoryDropdown'; +import { useSearchHistory } from '@/lib/hooks/useSearchHistory'; +import { useSearchBoxHandlers } from './hooks/useSearchBoxHandlers'; + +interface SearchBoxProps { + onSearch: (query: string) => void; + onClear?: () => void; + initialQuery?: string; +} + +export function SearchBox({ onSearch, onClear, initialQuery = '' }: SearchBoxProps) { + const [query, setQuery] = useState(initialQuery); + const inputRef = useRef(null); + + // Search history hook + const { + searchHistory, + isDropdownOpen, + highlightedIndex, + showDropdown, + hideDropdown, + addSearch, + removeSearch, + clearAll, + selectHistoryItem, + navigateDropdown, + resetHighlight, + } = useSearchHistory((selectedQuery) => { + setQuery(selectedQuery); + onSearch(selectedQuery); + // Blur the input after selecting from history + setTimeout(() => { + inputRef.current?.blur(); + }, 100); + }); + + // Update query when initialQuery changes + useEffect(() => { + setQuery(initialQuery); + }, [initialQuery]); + + const { + handleSubmit, + handleClear, + handleInputFocus, + handleInputBlur, + handleKeyDown, + } = useSearchBoxHandlers({ + query, + setQuery, + onSearch, + onClear, + inputRef, + isDropdownOpen, + highlightedIndex, + searchHistory, + addSearch, + hideDropdown, + showDropdown, + resetHighlight, + selectHistoryItem, + navigateDropdown, + }); + + return ( +
+ setQuery(e.target.value)} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + onKeyDown={handleKeyDown} + placeholder="搜索电影、电视剧、综艺..." + className="text-base sm:text-lg pr-28 sm:pr-36 md:pr-44 truncate" + aria-label="搜索视频内容" + aria-expanded={isDropdownOpen} + aria-controls="search-history-dropdown" + aria-autocomplete="list" + /> + +
+ {query && ( + + )} + +
+ + {/* Search History Dropdown */} + + + ); +} diff --git a/components/search/SearchForm.tsx b/components/search/SearchForm.tsx index cd1c90c..ae61cd1 100644 --- a/components/search/SearchForm.tsx +++ b/components/search/SearchForm.tsx @@ -1,12 +1,7 @@ 'use client'; -import { useState, FormEvent, useEffect, useRef } from 'react'; -import { Input } from '@/components/ui/Input'; -import { Button } from '@/components/ui/Button'; -import { Icons } from '@/components/ui/Icon'; import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation'; -import { SearchHistoryDropdown } from '@/components/search/SearchHistoryDropdown'; -import { useSearchHistory } from '@/lib/hooks/useSearchHistory'; +import { SearchBox } from './SearchBox'; interface SearchFormProps { onSearch: (query: string) => void; @@ -27,161 +22,24 @@ export function SearchForm({ checkedSources = 0, totalSources = 16, }: SearchFormProps) { - const [query, setQuery] = useState(initialQuery); - const inputRef = useRef(null); - - // Search history hook - const { - searchHistory, - isDropdownOpen, - highlightedIndex, - showDropdown, - hideDropdown, - addSearch, - removeSearch, - clearAll, - selectHistoryItem, - navigateDropdown, - resetHighlight, - } = useSearchHistory((selectedQuery) => { - setQuery(selectedQuery); - onSearch(selectedQuery); - // Blur the input after selecting from history - setTimeout(() => { - inputRef.current?.blur(); - }, 100); - }); - - // Update query when initialQuery changes - useEffect(() => { - setQuery(initialQuery); - }, [initialQuery]); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - if (query.trim()) { - // Add to search history before searching - addSearch(query.trim()); - onSearch(query); - hideDropdown(); - // Blur the input to remove focus - inputRef.current?.blur(); - } - }; - - const handleClear = () => { - setQuery(''); - if (onClear) { - onClear(); - } - resetHighlight(); - }; - - const handleInputFocus = () => { - // Always show dropdown when focused, regardless of content - showDropdown(); - }; - - const handleInputBlur = (e: React.FocusEvent) => { - // Check if the new focus target is within the dropdown - const relatedTarget = e.relatedTarget as HTMLElement; - if (relatedTarget && relatedTarget.closest('.search-history-dropdown')) { - // Don't hide dropdown if focus moved to dropdown - return; - } - hideDropdown(); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!isDropdownOpen) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - navigateDropdown('down'); - break; - case 'ArrowUp': - e.preventDefault(); - navigateDropdown('up'); - break; - case 'Enter': - if (highlightedIndex >= 0 && searchHistory[highlightedIndex]) { - e.preventDefault(); - selectHistoryItem(searchHistory[highlightedIndex].query); - } - break; - case 'Escape': - hideDropdown(); - inputRef.current?.blur(); - break; - } - }; - return ( -
-
- setQuery(e.target.value)} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - onKeyDown={handleKeyDown} - placeholder="搜索电影、电视剧、综艺..." - className="text-base sm:text-lg pr-28 sm:pr-36 md:pr-44 truncate" - aria-label="搜索视频内容" - aria-expanded={isDropdownOpen} - aria-controls="search-history-dropdown" - aria-autocomplete="list" - /> - -
- {query && ( - - )} - -
+
+ - {/* Search History Dropdown */} - -
- {/* Loading Animation */} {isLoading && (
-
)} - +
); } diff --git a/components/search/SearchHistoryDropdown.tsx b/components/search/SearchHistoryDropdown.tsx index 5426c61..d50bfc6 100644 --- a/components/search/SearchHistoryDropdown.tsx +++ b/components/search/SearchHistoryDropdown.tsx @@ -9,6 +9,9 @@ import { useEffect, useRef } from 'react'; import { Icons } from '@/components/ui/Icon'; import type { SearchHistoryItem } from '@/lib/store/search-history-store'; +import { SearchHistoryEmptyState } from './SearchHistoryEmptyState'; +import { SearchHistoryHeader } from './SearchHistoryHeader'; +import { SearchHistoryListItem } from './SearchHistoryListItem'; interface SearchHistoryDropdownProps { isOpen: boolean; @@ -63,33 +66,11 @@ export function SearchHistoryDropdown({ }} > {searchHistory.length === 0 ? ( - // Empty state -
- - 暂无搜索历史 -
+ ) : ( <> {/* Header with clear all button */} -
-
- - - 搜索历史 - -
- -
+ {/* Divider */}
@@ -97,51 +78,14 @@ export function SearchHistoryDropdown({ {/* History items */}
{searchHistory.map((item, index) => ( -
{ - e.preventDefault(); - onSelectItem(item.query); - }} - onMouseEnter={() => { - // Visual feedback on hover - }} - tabIndex={0} - > -
- - - {item.query} - - {item.resultCount !== undefined && ( - - {item.resultCount} 个结果 - - )} -
- -
+ item={item} + index={index} + isHighlighted={index === highlightedIndex} + onSelectItem={onSelectItem} + onRemoveItem={onRemoveItem} + /> ))}
diff --git a/components/search/SearchHistoryEmptyState.tsx b/components/search/SearchHistoryEmptyState.tsx new file mode 100644 index 0000000..d24216f --- /dev/null +++ b/components/search/SearchHistoryEmptyState.tsx @@ -0,0 +1,14 @@ +/** + * Empty state for search history dropdown + */ + +import { Icons } from '@/components/ui/Icon'; + +export function SearchHistoryEmptyState() { + return ( +
+ + 暂无搜索历史 +
+ ); +} diff --git a/components/search/SearchHistoryHeader.tsx b/components/search/SearchHistoryHeader.tsx new file mode 100644 index 0000000..5645abd --- /dev/null +++ b/components/search/SearchHistoryHeader.tsx @@ -0,0 +1,33 @@ +/** + * Header for search history dropdown + */ + +import { Icons } from '@/components/ui/Icon'; + +interface SearchHistoryHeaderProps { + onClearAll: () => void; +} + +export function SearchHistoryHeader({ onClearAll }: SearchHistoryHeaderProps) { + return ( +
+
+ + + 搜索历史 + +
+ +
+ ); +} diff --git a/components/search/SearchHistoryListItem.tsx b/components/search/SearchHistoryListItem.tsx new file mode 100644 index 0000000..7a0edbf --- /dev/null +++ b/components/search/SearchHistoryListItem.tsx @@ -0,0 +1,64 @@ +/** + * Individual search history list item + */ + +import { Icons } from '@/components/ui/Icon'; +import type { SearchHistoryItem } from '@/lib/store/search-history-store'; + +interface SearchHistoryListItemProps { + item: SearchHistoryItem; + index: number; + isHighlighted: boolean; + onSelectItem: (query: string) => void; + onRemoveItem: (query: string) => void; +} + +export function SearchHistoryListItem({ + item, + index, + isHighlighted, + onSelectItem, + onRemoveItem, +}: SearchHistoryListItemProps) { + return ( +
{ + e.preventDefault(); + onSelectItem(item.query); + }} + tabIndex={0} + > +
+ + + {item.query} + + {item.resultCount !== undefined && ( + + {item.resultCount} 个结果 + + )} +
+ +
+ ); +} diff --git a/components/search/SourceBadges.tsx b/components/search/SourceBadges.tsx index 8282db6..994a625 100644 --- a/components/search/SourceBadges.tsx +++ b/components/search/SourceBadges.tsx @@ -11,25 +11,20 @@ import { memo } from 'react'; import { Card } from '@/components/ui/Card'; import { Icons } from '@/components/ui/Icon'; import { SourceBadgeList } from './SourceBadgeList'; - -interface Source { - id: string; - name: string; - count: number; -} +import type { SourceBadge } from '@/lib/types'; interface SourceBadgesProps { - sources: Source[]; + sources: SourceBadge[]; selectedSources: Set; onToggleSource: (sourceId: string) => void; className?: string; } -export const SourceBadges = memo(function SourceBadges({ - sources, +export const SourceBadges = memo(function SourceBadges({ + sources, selectedSources, onToggleSource, - className = '' + className = '' }: SourceBadgesProps) { if (sources.length === 0) { return null; @@ -40,8 +35,8 @@ export const SourceBadges = memo(function SourceBadges({ }; return ( -
@@ -51,14 +46,14 @@ export const SourceBadges = memo(function SourceBadges({ 视频源 ({sources.length}):
- +
- + {selectedSources.size > 0 && (
- + {selectedTypes.size > 0 && (
-
+
diff --git a/components/settings/DataSettings.tsx b/components/settings/DataSettings.tsx new file mode 100644 index 0000000..289e125 --- /dev/null +++ b/components/settings/DataSettings.tsx @@ -0,0 +1,44 @@ +interface DataSettingsProps { + onExport: () => void; + onImport: () => void; + onReset: () => void; +} + +export function DataSettings({ onExport, onImport, onReset }: DataSettingsProps) { + return ( +
+

数据管理

+
+ + + + + +
+
+ ); +} diff --git a/components/settings/SettingsHeader.tsx b/components/settings/SettingsHeader.tsx new file mode 100644 index 0000000..d7ff59b --- /dev/null +++ b/components/settings/SettingsHeader.tsx @@ -0,0 +1,30 @@ +import { useRouter } from 'next/navigation'; + +export function SettingsHeader() { + const router = useRouter(); + + return ( +
+ +
+
+ + + +
+
+

设置

+

管理应用程序配置

+
+
+
+ ); +} diff --git a/components/settings/SortSettings.tsx b/components/settings/SortSettings.tsx new file mode 100644 index 0000000..e20cdc2 --- /dev/null +++ b/components/settings/SortSettings.tsx @@ -0,0 +1,31 @@ +import { sortOptions, type SortOption } from '@/lib/store/settings-store'; + +interface SortSettingsProps { + sortBy: SortOption; + onSortChange: (sort: SortOption) => void; +} + +export function SortSettings({ sortBy, onSortChange }: SortSettingsProps) { + return ( +
+

搜索结果排序

+

+ 选择搜索结果的默认排序方式 +

+
+ {(Object.keys(sortOptions) as SortOption[]).map((option) => ( + + ))} +
+
+ ); +} diff --git a/components/settings/SourceSettings.tsx b/components/settings/SourceSettings.tsx new file mode 100644 index 0000000..194dddd --- /dev/null +++ b/components/settings/SourceSettings.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { SourceManager } from '@/components/settings/SourceManager'; +import type { VideoSource } from '@/lib/types'; + +interface SourceSettingsProps { + sources: VideoSource[]; + onSourcesChange: (sources: VideoSource[]) => void; + onRestoreDefaults: () => void; + onAddSource: () => void; +} + +export function SourceSettings({ + sources, + onSourcesChange, + onRestoreDefaults, + onAddSource, +}: SourceSettingsProps) { + const [showAllSources, setShowAllSources] = useState(false); + + return ( +
+
+

视频源管理

+
+ + +
+
+

+ 管理视频来源,调整优先级和启用状态 +

+ + {sources.length > 10 && ( + + )} +
+ ); +} diff --git a/components/settings/hooks/useAddSourceForm.ts b/components/settings/hooks/useAddSourceForm.ts new file mode 100644 index 0000000..d47f6e2 --- /dev/null +++ b/components/settings/hooks/useAddSourceForm.ts @@ -0,0 +1,74 @@ +/** + * Form hook for AddSourceModal + */ + +'use client'; + +import { useState, useEffect } from 'react'; +import type { VideoSource } from '@/lib/types'; + +interface UseAddSourceFormProps { + isOpen: boolean; + existingIds: string[]; + onAdd: (source: VideoSource) => void; + onClose: () => void; +} + +export function useAddSourceForm({ isOpen, existingIds, onAdd, onClose }: UseAddSourceFormProps) { + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + if (isOpen) { + setName(''); + setUrl(''); + setError(''); + } + }, [isOpen]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!name.trim() || !url.trim()) { + setError('请填写所有字段'); + return; + } + + try { + new URL(url); + } catch { + setError('请输入有效的 URL'); + return; + } + + const id = name.toLowerCase().replace(/[^a-z0-9]/g, '-'); + if (existingIds.includes(id)) { + setError('此源名称已存在'); + return; + } + + const newSource: VideoSource = { + id, + name: name.trim(), + baseUrl: url.trim(), + searchPath: '', + detailPath: '', + enabled: true, + priority: existingIds.length + 1, + }; + + onAdd(newSource); + onClose(); + }; + + return { + name, + setName, + url, + setUrl, + error, + handleSubmit, + }; +} diff --git a/components/ui/ModalBackdrop.tsx b/components/ui/ModalBackdrop.tsx new file mode 100644 index 0000000..0ed46fe --- /dev/null +++ b/components/ui/ModalBackdrop.tsx @@ -0,0 +1,18 @@ +/** + * Reusable modal backdrop component + */ + +interface ModalBackdropProps { + isOpen: boolean; + onClose: () => void; +} + +export function ModalBackdrop({ isOpen, onClose }: ModalBackdropProps) { + return ( +
+ ); +} diff --git a/components/ui/ModalHeader.tsx b/components/ui/ModalHeader.tsx new file mode 100644 index 0000000..93d9260 --- /dev/null +++ b/components/ui/ModalHeader.tsx @@ -0,0 +1,27 @@ +/** + * Reusable modal header component + */ + +interface ModalHeaderProps { + title: string; + onClose: () => void; +} + +export function ModalHeader({ title, onClose }: ModalHeaderProps) { + return ( +
+

+ {title} +

+ +
+ ); +} diff --git a/lib/api/client.ts b/lib/api/client.ts index 4b85bf0..4a18d4b 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -3,174 +3,7 @@ * Handles parallel requests and data normalization */ -import type { - VideoSource, - VideoItem, - VideoDetail, - ApiSearchResponse, - ApiDetailResponse, -} from '@/lib/types'; -import { fetchWithTimeout, withRetry } from './http-utils'; -import { parseEpisodes } from './parsers'; - -/** - * Search videos from a single source - */ -async function searchVideosBySource( - query: string, - source: VideoSource, - page: number = 1 -): Promise<{ results: VideoItem[]; source: string; responseTime: number }> { - const startTime = Date.now(); - - const url = new URL(`${source.baseUrl}${source.searchPath}`); - url.searchParams.set('ac', 'detail'); - url.searchParams.set('wd', query); - url.searchParams.set('pg', page.toString()); - - try { - const response = await withRetry(async () => { - const res = await fetchWithTimeout(url.toString(), { - method: 'GET', - headers: { - 'User-Agent': 'Mozilla/5.0', - ...source.headers, - }, - }); - - if (!res.ok) { - throw new Error(`HTTP ${res.status}: ${res.statusText}`); - } - - return res; - }); - - const data: ApiSearchResponse = await response.json(); - - if (data.code !== 1 && data.code !== 0) { - throw new Error(data.msg || 'Invalid API response'); - } - - const results: VideoItem[] = (data.list || []).map(item => ({ - ...item, - source: source.id, - })); - - return { - results, - source: source.id, - responseTime: Date.now() - startTime, - }; - } catch (error) { - console.error(`Search failed for source ${source.name}:`, error); - throw { - code: 'SEARCH_FAILED', - message: `Failed to search from ${source.name}`, - source: source.id, - retryable: true, - }; - } -} - -/** - * Search videos from multiple sources in parallel - */ -export async function searchVideos( - query: string, - sources: VideoSource[], - page: number = 1 -): Promise> { - const searchPromises = sources.map(async source => { - try { - return await searchVideosBySource(query, source, page); - } catch (error) { - return { - results: [], - source: source.id, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }); - - return Promise.all(searchPromises); -} - - - -/** - * Get video detail from a single source - */ -export async function getVideoDetail( - id: string | number, - source: VideoSource -): Promise { - const url = new URL(`${source.baseUrl}${source.detailPath}`); - url.searchParams.set('ac', 'detail'); - url.searchParams.set('ids', id.toString()); - - try { - const response = await withRetry(async () => { - const res = await fetchWithTimeout(url.toString(), { - method: 'GET', - headers: { - 'User-Agent': 'Mozilla/5.0', - ...source.headers, - }, - }); - - if (!res.ok) { - throw new Error(`HTTP ${res.status}: ${res.statusText}`); - } - - return res; - }); - - const data: ApiDetailResponse = await response.json(); - - - - if (data.code !== 1 && data.code !== 0) { - throw new Error(data.msg || 'Invalid API response'); - } - - if (!data.list || data.list.length === 0) { - throw new Error('Video not found'); - } - - const videoData = data.list[0]; - - // Parse episodes from vod_play_url - const episodes = parseEpisodes(videoData.vod_play_url || ''); - - - if (episodes.length > 0) { - } - - - return { - vod_id: videoData.vod_id, - vod_name: videoData.vod_name, - vod_pic: videoData.vod_pic, - vod_remarks: videoData.vod_remarks, - vod_year: videoData.vod_year, - vod_area: videoData.vod_area, - vod_actor: videoData.vod_actor, - vod_director: videoData.vod_director, - vod_content: videoData.vod_content, - type_name: videoData.type_name, - episodes, - source: source.id, - source_code: videoData.vod_play_from || '', - }; - } catch (error) { - console.error(`Detail fetch failed for source ${source.name}:`, error); - throw { - code: 'DETAIL_FAILED', - message: `Failed to fetch video detail from ${source.name}`, - source: source.id, - retryable: false, - }; - } -} +export { searchVideos, searchVideosBySource } from './search-api'; +export { getVideoDetail } from './detail-api'; diff --git a/lib/api/detail-api.ts b/lib/api/detail-api.ts new file mode 100644 index 0000000..0d75616 --- /dev/null +++ b/lib/api/detail-api.ts @@ -0,0 +1,76 @@ +import type { + VideoSource, + VideoDetail, + ApiDetailResponse, +} from '@/lib/types'; +import { fetchWithTimeout, withRetry } from './http-utils'; +import { parseEpisodes } from './parsers'; + +/** + * Get video detail from a single source + */ +export async function getVideoDetail( + id: string | number, + source: VideoSource +): Promise { + const url = new URL(`${source.baseUrl}${source.detailPath}`); + url.searchParams.set('ac', 'detail'); + url.searchParams.set('ids', id.toString()); + + try { + const response = await withRetry(async () => { + const res = await fetchWithTimeout(url.toString(), { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0', + ...source.headers, + }, + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + return res; + }); + + const data: ApiDetailResponse = await response.json(); + + if (data.code !== 1 && data.code !== 0) { + throw new Error(data.msg || 'Invalid API response'); + } + + if (!data.list || data.list.length === 0) { + throw new Error('Video not found'); + } + + const videoData = data.list[0]; + + // Parse episodes from vod_play_url + const episodes = parseEpisodes(videoData.vod_play_url || ''); + + return { + vod_id: videoData.vod_id, + vod_name: videoData.vod_name, + vod_pic: videoData.vod_pic, + vod_remarks: videoData.vod_remarks, + vod_year: videoData.vod_year, + vod_area: videoData.vod_area, + vod_actor: videoData.vod_actor, + vod_director: videoData.vod_director, + vod_content: videoData.vod_content, + type_name: videoData.type_name, + episodes, + source: source.id, + source_code: videoData.vod_play_from || '', + }; + } catch (error) { + console.error(`Detail fetch failed for source ${source.name}:`, error); + throw { + code: 'DETAIL_FAILED', + message: `Failed to fetch video detail from ${source.name}`, + source: source.id, + retryable: false, + }; + } +} diff --git a/lib/api/search-api.ts b/lib/api/search-api.ts new file mode 100644 index 0000000..1f4c559 --- /dev/null +++ b/lib/api/search-api.ts @@ -0,0 +1,88 @@ +import type { + VideoSource, + VideoItem, + ApiSearchResponse, +} from '@/lib/types'; +import { fetchWithTimeout, withRetry } from './http-utils'; + +/** + * Search videos from a single source + */ +export async function searchVideosBySource( + query: string, + source: VideoSource, + page: number = 1 +): Promise<{ results: VideoItem[]; source: string; responseTime: number }> { + const startTime = Date.now(); + + const url = new URL(`${source.baseUrl}${source.searchPath}`); + url.searchParams.set('ac', 'detail'); + url.searchParams.set('wd', query); + url.searchParams.set('pg', page.toString()); + + try { + const response = await withRetry(async () => { + const res = await fetchWithTimeout(url.toString(), { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0', + ...source.headers, + }, + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + return res; + }); + + const data: ApiSearchResponse = await response.json(); + + if (data.code !== 1 && data.code !== 0) { + throw new Error(data.msg || 'Invalid API response'); + } + + const results: VideoItem[] = (data.list || []).map(item => ({ + ...item, + source: source.id, + })); + + return { + results, + source: source.id, + responseTime: Date.now() - startTime, + }; + } catch (error) { + console.error(`Search failed for source ${source.name}:`, error); + throw { + code: 'SEARCH_FAILED', + message: `Failed to search from ${source.name}`, + source: source.id, + retryable: true, + }; + } +} + +/** + * Search videos from multiple sources in parallel + */ +export async function searchVideos( + query: string, + sources: VideoSource[], + page: number = 1 +): Promise> { + const searchPromises = sources.map(async source => { + try { + return await searchVideosBySource(query, source, page); + } catch (error) { + return { + results: [], + source: source.id, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + return Promise.all(searchPromises); +} diff --git a/lib/hooks/mobile/useDeviceDetection.ts b/lib/hooks/mobile/useDeviceDetection.ts new file mode 100644 index 0000000..6047bb7 --- /dev/null +++ b/lib/hooks/mobile/useDeviceDetection.ts @@ -0,0 +1,42 @@ +import { useState, useEffect } from 'react'; + +/** + * Hook to detect if the device is mobile + */ +export function useIsMobile() { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const checkMobile = () => { + const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent + ) || window.innerWidth < 768; + setIsMobile(mobile); + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + + return () => window.removeEventListener('resize', checkMobile); + }, []); + + return isMobile; +} + +/** + * Hook to detect if the device is iOS + */ +export function useIsIOS() { + const [isIOS, setIsIOS] = useState(false); + + useEffect(() => { + const checkIOS = () => { + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; + setIsIOS(ios); + }; + + checkIOS(); + }, []); + + return isIOS; +} diff --git a/lib/hooks/mobile/useDoubleTap.ts b/lib/hooks/mobile/useDoubleTap.ts new file mode 100644 index 0000000..ad4f2c7 --- /dev/null +++ b/lib/hooks/mobile/useDoubleTap.ts @@ -0,0 +1,88 @@ +import { useRef } from 'react'; + +interface DoubleTapHandler { + onDoubleTapLeft: () => void; + onDoubleTapRight: () => void; + onSingleTap: () => void; + onSkipContinueLeft: () => void; + onSkipContinueRight: () => void; + isSkipModeActive: boolean; +} + +/** + * Hook for handling double-tap gestures on mobile devices + * Divides the video into left/right zones for skip forward/backward + */ +export function useDoubleTap({ + onDoubleTapLeft, + onDoubleTapRight, + onSingleTap, + onSkipContinueLeft, + onSkipContinueRight, + isSkipModeActive, +}: DoubleTapHandler) { + const lastTapRef = useRef<{ time: number; side: 'left' | 'right' | null }>({ + time: 0, + side: null, + }); + const singleTapTimeoutRef = useRef(null); + + const handleTap = (e: React.TouchEvent) => { + const currentTime = Date.now(); + const videoElement = e.currentTarget; + const touch = e.touches[0] || e.changedTouches[0]; + + if (!touch || !videoElement) return; + + // Calculate touch position relative to video element + const rect = videoElement.getBoundingClientRect(); + const x = touch.clientX - rect.left; + const width = rect.width; + const side = x < width / 2 ? 'left' : 'right'; + + const timeDiff = currentTime - lastTapRef.current.time; + const sameSide = lastTapRef.current.side === side; + + // Clear any pending single tap + if (singleTapTimeoutRef.current) { + clearTimeout(singleTapTimeoutRef.current); + singleTapTimeoutRef.current = null; + } + + // If skip mode is active, single tap continues skipping + if (isSkipModeActive) { + if (side === 'left') { + onSkipContinueLeft(); + } else { + onSkipContinueRight(); + } + lastTapRef.current = { time: currentTime, side }; + return; + } + + // Double tap detected (within 300ms on the same side) + if (timeDiff < 300 && sameSide) { + e.preventDefault(); + + if (side === 'left') { + onDoubleTapLeft(); + } else { + onDoubleTapRight(); + } + + // Reset to prevent triple-tap + lastTapRef.current = { time: 0, side: null }; + } else { + // Possible single tap - wait to see if there's a double tap + lastTapRef.current = { time: currentTime, side }; + + singleTapTimeoutRef.current = setTimeout(() => { + // After 300ms, no double tap detected, execute single tap action + onSingleTap(); + singleTapTimeoutRef.current = null; + }, 300); + } + }; + + return { handleTap }; +} diff --git a/lib/hooks/mobile/useScreenOrientation.ts b/lib/hooks/mobile/useScreenOrientation.ts new file mode 100644 index 0000000..49d2af0 --- /dev/null +++ b/lib/hooks/mobile/useScreenOrientation.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; + +/** + * Hook for managing screen orientation on mobile devices + * Auto-rotates to landscape on fullscreen, portrait on exit + */ +export function useScreenOrientation(isFullscreen: boolean) { + useEffect(() => { + if (typeof window === 'undefined' || !('screen' in window)) return; + + const handleOrientation = async () => { + try { + const screen = window.screen as any; + + if (isFullscreen) { + // Fullscreen: Lock to landscape + if (screen.orientation?.lock) { + await screen.orientation.lock('landscape').catch((err: any) => { + console.warn('Could not lock orientation:', err); + }); + } + } else { + // Exit fullscreen: Unlock to allow portrait + if (screen.orientation?.unlock) { + screen.orientation.unlock(); + } + } + } catch (error) { + console.warn('Orientation API not supported:', error); + } + }; + + handleOrientation(); + + // Cleanup: Always unlock on unmount + return () => { + try { + const screen = window.screen as any; + if (screen.orientation?.unlock) { + screen.orientation.unlock(); + } + } catch (error) { + // Ignore cleanup errors + } + }; + }, [isFullscreen]); +} diff --git a/lib/hooks/useMobilePlayer.ts b/lib/hooks/useMobilePlayer.ts index 1119617..051d8e8 100644 --- a/lib/hooks/useMobilePlayer.ts +++ b/lib/hooks/useMobilePlayer.ts @@ -1,177 +1,5 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; - -interface DoubleTapHandler { - onDoubleTapLeft: () => void; - onDoubleTapRight: () => void; - onSingleTap: () => void; - onSkipContinueLeft: () => void; - onSkipContinueRight: () => void; - isSkipModeActive: boolean; -} - -/** - * Hook for handling double-tap gestures on mobile devices - * Divides the video into left/right zones for skip forward/backward - */ -export function useDoubleTap({ - onDoubleTapLeft, - onDoubleTapRight, - onSingleTap, - onSkipContinueLeft, - onSkipContinueRight, - isSkipModeActive, -}: DoubleTapHandler) { - const lastTapRef = useRef<{ time: number; side: 'left' | 'right' | null }>({ - time: 0, - side: null, - }); - const singleTapTimeoutRef = useRef(null); - - const handleTap = (e: React.TouchEvent) => { - const currentTime = Date.now(); - const videoElement = e.currentTarget; - const touch = e.touches[0] || e.changedTouches[0]; - - if (!touch || !videoElement) return; - - // Calculate touch position relative to video element - const rect = videoElement.getBoundingClientRect(); - const x = touch.clientX - rect.left; - const width = rect.width; - const side = x < width / 2 ? 'left' : 'right'; - - const timeDiff = currentTime - lastTapRef.current.time; - const sameSide = lastTapRef.current.side === side; - - // Clear any pending single tap - if (singleTapTimeoutRef.current) { - clearTimeout(singleTapTimeoutRef.current); - singleTapTimeoutRef.current = null; - } - - // If skip mode is active, single tap continues skipping - if (isSkipModeActive) { - if (side === 'left') { - onSkipContinueLeft(); - } else { - onSkipContinueRight(); - } - lastTapRef.current = { time: currentTime, side }; - return; - } - - // Double tap detected (within 300ms on the same side) - if (timeDiff < 300 && sameSide) { - e.preventDefault(); - - if (side === 'left') { - onDoubleTapLeft(); - } else { - onDoubleTapRight(); - } - - // Reset to prevent triple-tap - lastTapRef.current = { time: 0, side: null }; - } else { - // Possible single tap - wait to see if there's a double tap - lastTapRef.current = { time: currentTime, side }; - - singleTapTimeoutRef.current = setTimeout(() => { - // After 300ms, no double tap detected, execute single tap action - onSingleTap(); - singleTapTimeoutRef.current = null; - }, 300); - } - }; - - return { handleTap }; -} - -/** - * Hook for managing screen orientation on mobile devices - * Auto-rotates to landscape on fullscreen, portrait on exit - */ -export function useScreenOrientation(isFullscreen: boolean) { - useEffect(() => { - if (typeof window === 'undefined' || !('screen' in window)) return; - - const handleOrientation = async () => { - try { - const screen = window.screen as any; - - if (isFullscreen) { - // Fullscreen: Lock to landscape - if (screen.orientation?.lock) { - await screen.orientation.lock('landscape').catch((err: any) => { - console.warn('Could not lock orientation:', err); - }); - } - } else { - // Exit fullscreen: Unlock to allow portrait - if (screen.orientation?.unlock) { - screen.orientation.unlock(); - } - } - } catch (error) { - console.warn('Orientation API not supported:', error); - } - }; - - handleOrientation(); - - // Cleanup: Always unlock on unmount - return () => { - try { - const screen = window.screen as any; - if (screen.orientation?.unlock) { - screen.orientation.unlock(); - } - } catch (error) { - // Ignore cleanup errors - } - }; - }, [isFullscreen]); -} - -/** - * Hook to detect if the device is mobile - */ -export function useIsMobile() { - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent - ) || window.innerWidth < 768; - setIsMobile(mobile); - }; - - checkMobile(); - window.addEventListener('resize', checkMobile); - - return () => window.removeEventListener('resize', checkMobile); - }, []); - - return isMobile; -} - -/** - * Hook to detect if the device is iOS - */ -export function useIsIOS() { - const [isIOS, setIsIOS] = useState(false); - - useEffect(() => { - const checkIOS = () => { - const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; - setIsIOS(ios); - }; - - checkIOS(); - }, []); - - return isIOS; -} +export { useDoubleTap } from './mobile/useDoubleTap'; +export { useScreenOrientation } from './mobile/useScreenOrientation'; +export { useIsMobile, useIsIOS } from './mobile/useDeviceDetection'; diff --git a/lib/hooks/useParallelSearch.ts b/lib/hooks/useParallelSearch.ts index 511fb68..5d15725 100644 --- a/lib/hooks/useParallelSearch.ts +++ b/lib/hooks/useParallelSearch.ts @@ -1,33 +1,16 @@ 'use client'; -import { useState, useRef, useCallback } from 'react'; -import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names'; -import { calculateRelevanceScore } from '@/lib/utils/search'; +import { useCallback } from 'react'; import { sortVideos } from '@/lib/utils/sort'; -import { binaryInsertVideos } from '@/lib/utils/sorted-insert'; import type { SortOption } from '@/lib/store/settings-store'; - -interface Video { - vod_id: string; - vod_name: string; - vod_pic?: string; - vod_remarks?: string; - vod_year?: string; - type_name?: string; - source: string; - sourceName?: string; - isNew?: boolean; - vod_play_url?: string; - vod_actor?: string; - vod_director?: string; - relevanceScore?: number; - latency?: number; // Response time in milliseconds -} +import type { Video, SourceBadge } from '@/lib/types'; +import { useSearchState } from './useSearchState'; +import { useSearchAction } from './useSearchAction'; export interface ParallelSearchResult { loading: boolean; results: Video[]; - availableSources: any[]; + availableSources: SourceBadge[]; completedSources: number; totalSources: number; totalVideosFound: number; @@ -41,178 +24,49 @@ export function useParallelSearch( onCacheUpdate: (query: string, results: any[], sources: any[]) => void, onUrlUpdate: (query: string) => void ): ParallelSearchResult { - const [loading, setLoading] = useState(false); - const [results, setResults] = useState([]); - const [availableSources, setAvailableSources] = useState([]); - const [completedSources, setCompletedSources] = useState(0); - const [totalSources, setTotalSources] = useState(0); - const [totalVideosFound, setTotalVideosFound] = useState(0); - const currentQueryRef = useRef(''); + const state = useSearchState(); + const { + loading, + results, + availableSources, + completedSources, + totalSources, + totalVideosFound, + setResults, + setAvailableSources, + setTotalVideosFound, + resetState, + } = state; - const abortControllerRef = useRef(null); - - /** - * Perform parallel search with streaming results - */ - const performSearch = useCallback(async (searchQuery: string, sortBy: SortOption = 'default') => { - if (!searchQuery.trim()) return; - - // Abort any ongoing search - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - abortControllerRef.current = new AbortController(); - - // Reset state - setLoading(true); - setResults([]); - setAvailableSources([]); - setCompletedSources(0); - setTotalSources(0); - setTotalVideosFound(0); - currentQueryRef.current = searchQuery.trim(); - - // Update URL - onUrlUpdate(searchQuery); - - try { - const response = await fetch('/api/search-parallel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: searchQuery, sources: SOURCE_IDS }), - signal: abortControllerRef.current.signal, - }); - - if (!response.ok) throw new Error('Search failed'); - - const reader = response.body?.getReader(); - const decoder = new TextDecoder(); - if (!reader) throw new Error('No response stream'); - - let buffer = ''; - const sourcesMap = new Map(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - - try { - const data = JSON.parse(line.slice(6)); - - if (data.type === 'start') { - setTotalSources(data.totalSources); - - } - else if (data.type === 'videos') { - const currentQuery = currentQueryRef.current; - const newVideos: Video[] = data.videos.map((video: any) => ({ - ...video, - sourceName: video.sourceDisplayName || getSourceName(video.source), - isNew: true, - relevanceScore: calculateRelevanceScore(video, currentQuery), - })); - - - // Optimized: Insert new videos in sorted position - setResults((prev) => binaryInsertVideos(prev, newVideos)); - - // Update source stats - if (!sourcesMap.has(data.source)) { - sourcesMap.set(data.source, { - count: newVideos.length, - name: newVideos[0]?.sourceName || data.source, - }); - } - } - else if (data.type === 'progress') { - setCompletedSources(data.completedSources); - setTotalVideosFound(data.totalVideosFound); - } - else if (data.type === 'complete') { - setLoading(false); - - - - // Update available sources with correct property names - const sources = Array.from(sourcesMap.entries()).map(([id, info]) => ({ - id: id, // Changed from sourceId to id - name: info.name, // Changed from sourceName to name - count: info.count, - })); - setAvailableSources(sources); - - - - // Apply final sorting after all results are received - setResults((currentResults) => { - const sorted = sortVideos(currentResults, sortBy); - - // Cache results - setTimeout(() => { - onCacheUpdate(searchQuery, sorted, sources); - }, 100); - - return sorted; - }); - } - else if (data.type === 'error') { - console.error('Search error:', data.message); - setLoading(false); - } - } catch (error) { - console.error('Error parsing stream data:', error); - } - } - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - - } else { - console.error('Search error:', error); - } - setLoading(false); - } - }, [loading, onUrlUpdate, onCacheUpdate]); + const { performSearch, cancelSearch } = useSearchAction({ + state, + onCacheUpdate, + onUrlUpdate, + }); /** * Reset search state */ const resetSearch = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - setLoading(false); - setResults([]); - setAvailableSources([]); - setCompletedSources(0); - setTotalSources(0); - setTotalVideosFound(0); - currentQueryRef.current = ''; - }, []); + cancelSearch(); + resetState(); + }, [cancelSearch, resetState]); /** * Load cached results */ const loadCachedResults = useCallback((cachedResults: Video[], cachedSources: any[]) => { - setResults(cachedResults); setAvailableSources(cachedSources); setTotalVideosFound(cachedResults.length); - }, []); + }, [setResults, setAvailableSources, setTotalVideosFound]); /** * Apply sorting to current results */ const applySorting = useCallback((sortBy: SortOption) => { setResults((currentResults) => sortVideos(currentResults, sortBy)); - }, []); + }, [setResults]); return { loading, @@ -227,3 +81,4 @@ export function useParallelSearch( applySorting, }; } + diff --git a/lib/hooks/useSearchAction.ts b/lib/hooks/useSearchAction.ts new file mode 100644 index 0000000..6a463e6 --- /dev/null +++ b/lib/hooks/useSearchAction.ts @@ -0,0 +1,127 @@ +import { useRef, useCallback } from 'react'; +import { SOURCE_IDS } from '@/lib/utils/source-names'; +import { sortVideos } from '@/lib/utils/sort'; +import { binaryInsertVideos } from '@/lib/utils/sorted-insert'; +import { processSearchStream } from '@/lib/utils/search-stream'; +import type { SortOption } from '@/lib/store/settings-store'; +import type { Video } from '@/lib/types'; +import { useSearchState } from './useSearchState'; + +type SearchState = ReturnType; + +interface UseSearchActionProps { + state: SearchState; + onCacheUpdate: (query: string, results: any[], sources: any[]) => void; + onUrlUpdate: (query: string) => void; +} + +export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearchActionProps) { + const { + setLoading, + setResults, + setAvailableSources, + setCompletedSources, + setTotalSources, + setTotalVideosFound, + startSearch, + } = state; + + const abortControllerRef = useRef(null); + + const performSearch = useCallback(async (searchQuery: string, sortBy: SortOption = 'default') => { + if (!searchQuery.trim()) return; + + // Abort any ongoing search + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + // Reset state + startSearch(searchQuery.trim()); + + // Update URL + onUrlUpdate(searchQuery); + + try { + const response = await fetch('/api/search-parallel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: searchQuery, sources: SOURCE_IDS }), + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) throw new Error('Search failed'); + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response stream'); + + const sourcesMap = new Map(); + + await processSearchStream({ + reader, + currentQuery: searchQuery.trim(), + onStart: (total) => setTotalSources(total), + onVideos: (newVideos, sourceId) => { + // Optimized: Insert new videos in sorted position + setResults((prev) => binaryInsertVideos(prev, newVideos)); + + // Update source stats + if (!sourcesMap.has(sourceId)) { + sourcesMap.set(sourceId, { + count: newVideos.length, + name: newVideos[0]?.sourceName || sourceId, + }); + } + }, + onProgress: (completed, found) => { + setCompletedSources(completed); + setTotalVideosFound(found); + }, + onComplete: () => { + setLoading(false); + + // Update available sources with correct property names + const sources = Array.from(sourcesMap.entries()).map(([id, info]) => ({ + id: id, + name: info.name, + count: info.count, + })); + setAvailableSources(sources); + + // Apply final sorting after all results are received + setResults((currentResults) => { + const sorted = sortVideos(currentResults, sortBy); + + // Cache results + setTimeout(() => { + onCacheUpdate(searchQuery, sorted, sources); + }, 100); + + return sorted; + }); + }, + onError: (message) => { + console.error('Search error:', message); + setLoading(false); + }, + }); + + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + // Ignore abort errors + } else { + console.error('Search error:', error); + } + setLoading(false); + } + }, [startSearch, onUrlUpdate, onCacheUpdate, setTotalSources, setResults, setCompletedSources, setTotalVideosFound, setLoading, setAvailableSources]); + + const cancelSearch = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }, []); + + return { performSearch, cancelSearch }; +} diff --git a/lib/hooks/useSearchState.ts b/lib/hooks/useSearchState.ts new file mode 100644 index 0000000..87b8106 --- /dev/null +++ b/lib/hooks/useSearchState.ts @@ -0,0 +1,50 @@ +import { useState, useRef, useCallback } from 'react'; +import { Video, SourceBadge } from '@/lib/types'; + +export function useSearchState() { + const [loading, setLoading] = useState(false); + const [results, setResults] = useState([]); + const [availableSources, setAvailableSources] = useState([]); + const [completedSources, setCompletedSources] = useState(0); + const [totalSources, setTotalSources] = useState(0); + const [totalVideosFound, setTotalVideosFound] = useState(0); + const currentQueryRef = useRef(''); + + const resetState = useCallback(() => { + setLoading(false); + setResults([]); + setAvailableSources([]); + setCompletedSources(0); + setTotalSources(0); + setTotalVideosFound(0); + currentQueryRef.current = ''; + }, []); + + const startSearch = useCallback((query: string) => { + setLoading(true); + setResults([]); + setAvailableSources([]); + setCompletedSources(0); + setTotalSources(0); + setTotalVideosFound(0); + currentQueryRef.current = query; + }, []); + + return { + loading, + setLoading, + results, + setResults, + availableSources, + setAvailableSources, + completedSources, + setCompletedSources, + totalSources, + setTotalSources, + totalVideosFound, + setTotalVideosFound, + currentQueryRef, + resetState, + startSearch, + }; +} diff --git a/lib/hooks/useSourceBadges.ts b/lib/hooks/useSourceBadges.ts index f53380d..cc83d48 100644 --- a/lib/hooks/useSourceBadges.ts +++ b/lib/hooks/useSourceBadges.ts @@ -1,12 +1,7 @@ 'use client'; import { useState, useEffect, useMemo, useCallback } from 'react'; - -interface SourceBadge { - id: string; - name: string; - count: number; -} +import type { SourceBadge } from '@/lib/types'; /** * Custom hook to manage source badge filtering @@ -27,8 +22,8 @@ export function useSourceBadges + + return videos.filter(video => video.source && selectedSources.has(video.source) ); }, [videos, selectedSources]); @@ -49,12 +44,12 @@ export function useSourceBadges { const availableSourceIds = new Set(availableSources.map(s => s.id)); - + setSelectedSources(prev => { const filtered = new Set( Array.from(prev).filter(sourceId => availableSourceIds.has(sourceId)) ); - + // Only update if changed if (filtered.size !== prev.size) { return filtered; diff --git a/lib/hooks/useTypeBadges.ts b/lib/hooks/useTypeBadges.ts index 3e3b0c7..7b14dcf 100644 --- a/lib/hooks/useTypeBadges.ts +++ b/lib/hooks/useTypeBadges.ts @@ -1,11 +1,7 @@ 'use client'; import { useState, useEffect, useMemo, useCallback } from 'react'; - -interface TypeBadge { - type: string; - count: number; -} +import type { TypeBadge } from '@/lib/types'; /** * Custom hook to automatically collect and track type badges from video results @@ -23,7 +19,7 @@ export function useTypeBadges(videos: T[]) { // Collect and count type badges from videos const typeBadges = useMemo(() => { const typeMap = new Map(); - + videos.forEach(video => { if (video.type_name && video.type_name.trim()) { const type = video.type_name.trim(); @@ -42,8 +38,8 @@ export function useTypeBadges(videos: T[]) { if (selectedTypes.size === 0) { return videos; } - - return videos.filter(video => + + return videos.filter(video => video.type_name && selectedTypes.has(video.type_name.trim()) ); }, [videos, selectedTypes]); @@ -65,12 +61,12 @@ export function useTypeBadges(videos: T[]) { // Auto-cleanup: remove selected types that no longer exist in badges useEffect(() => { const availableTypes = new Set(typeBadges.map(b => b.type)); - + setSelectedTypes(prev => { const filtered = new Set( Array.from(prev).filter(type => availableTypes.has(type)) ); - + // Only update if changed if (filtered.size !== prev.size) { return filtered; diff --git a/lib/types/index.ts b/lib/types/index.ts index 54c7be0..af40e43 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -18,7 +18,7 @@ export interface VideoSource { export interface VideoItem { vod_id: number | string; vod_name: string; - vod_pic: string; + vod_pic?: string; type_name?: string; vod_remarks?: string; vod_year?: string; @@ -30,6 +30,23 @@ export interface VideoItem { latency?: number; // Response time in milliseconds } +export interface Video extends VideoItem { + sourceName?: string; + isNew?: boolean; + relevanceScore?: number; +} + +export interface SourceBadge { + id: string; + name: string; + count: number; +} + +export interface TypeBadge { + type: string; + count: number; +} + // Episode Information export interface Episode { name: string; diff --git a/lib/utils/format-utils.ts b/lib/utils/format-utils.ts new file mode 100644 index 0000000..26100e6 --- /dev/null +++ b/lib/utils/format-utils.ts @@ -0,0 +1,33 @@ +/** + * Formatting utilities for time and dates + */ + +/** + * Format seconds to HH:MM:SS or MM:SS + */ +export function formatTime(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Format timestamp to relative date (今天, 昨天, X天前, or date) + */ +export function formatDate(ts: number): string { + const date = new Date(ts); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return '今天'; + if (days === 1) return '昨天'; + if (days < 7) return `${days}天前`; + + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); +} diff --git a/lib/utils/search-stream.ts b/lib/utils/search-stream.ts new file mode 100644 index 0000000..329dc18 --- /dev/null +++ b/lib/utils/search-stream.ts @@ -0,0 +1,68 @@ +import { Video } from '@/lib/types'; +import { getSourceName } from '@/lib/utils/source-names'; +import { calculateRelevanceScore } from '@/lib/utils/search'; +import { binaryInsertVideos } from '@/lib/utils/sorted-insert'; + +interface StreamHandlerParams { + reader: ReadableStreamDefaultReader; + onStart: (totalSources: number) => void; + onVideos: (videos: Video[], source: string) => void; + onProgress: (completedSources: number, totalVideosFound: number) => void; + onComplete: () => void; + onError: (message: string) => void; + currentQuery: string; +} + +export async function processSearchStream({ + reader, + onStart, + onVideos, + onProgress, + onComplete, + onError, + currentQuery, +}: StreamHandlerParams) { + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + try { + const data = JSON.parse(line.slice(6)); + + if (data.type === 'start') { + onStart(data.totalSources); + } else if (data.type === 'videos') { + const newVideos: Video[] = data.videos.map((video: any) => ({ + ...video, + sourceName: video.sourceDisplayName || getSourceName(video.source), + isNew: true, + relevanceScore: calculateRelevanceScore(video, currentQuery), + })); + onVideos(newVideos, data.source); + } else if (data.type === 'progress') { + onProgress(data.completedSources, data.totalVideosFound); + } else if (data.type === 'complete') { + onComplete(); + } else if (data.type === 'error') { + onError(data.message); + } + } catch (error) { + console.error('Error parsing stream data:', error); + } + } + } + } catch (error) { + throw error; + } +} diff --git a/lib/utils/sort.ts b/lib/utils/sort.ts index 5957f26..02ab63d 100644 --- a/lib/utils/sort.ts +++ b/lib/utils/sort.ts @@ -3,27 +3,11 @@ */ import type { SortOption } from '@/lib/store/settings-store'; - -interface Video { - vod_id: string; - vod_name: string; - vod_pic?: string; - vod_remarks?: string; - vod_year?: string; - type_name?: string; - source: string; - sourceName?: string; - isNew?: boolean; - vod_play_url?: string; - vod_actor?: string; - vod_director?: string; - relevanceScore?: number; - latency?: number; -} +import type { Video } from '@/lib/types'; export function sortVideos(videos: Video[], sortBy: SortOption): Video[] { const sorted = [...videos]; - + switch (sortBy) { case 'relevance': // Sort by relevance score (highest first) @@ -32,7 +16,7 @@ export function sortVideos(videos: Video[], sortBy: SortOption): Video[] { const scoreB = (b as any).relevanceScore || 0; return scoreB - scoreA; }); - + case 'latency-asc': // Sort by latency (lowest first) return sorted.sort((a, b) => { @@ -40,7 +24,7 @@ export function sortVideos(videos: Video[], sortBy: SortOption): Video[] { const latencyB = b.latency || 99999; return latencyA - latencyB; }); - + case 'date-desc': // Sort by year (newest first) return sorted.sort((a, b) => { @@ -48,7 +32,7 @@ export function sortVideos(videos: Video[], sortBy: SortOption): Video[] { const yearB = parseInt(b.vod_year || '0'); return yearB - yearA; }); - + case 'date-asc': // Sort by year (oldest first) return sorted.sort((a, b) => { @@ -56,7 +40,7 @@ export function sortVideos(videos: Video[], sortBy: SortOption): Video[] { const yearB = parseInt(b.vod_year || '0'); return yearA - yearB; }); - + case 'rating-desc': // Sort by rating if available (placeholder for future implementation) return sorted.sort((a, b) => { @@ -64,30 +48,30 @@ export function sortVideos(videos: Video[], sortBy: SortOption): Video[] { const ratingB = (b as any).vod_score || 0; return ratingB - ratingA; }); - + case 'name-asc': // Sort by name A-Z return sorted.sort((a, b) => { return a.vod_name.localeCompare(b.vod_name, 'zh-CN'); }); - + case 'name-desc': // Sort by name Z-A return sorted.sort((a, b) => { return b.vod_name.localeCompare(a.vod_name, 'zh-CN'); }); - + case 'default': default: // Default: by relevance then latency return sorted.sort((a, b) => { const scoreA = (a as any).relevanceScore || 0; const scoreB = (b as any).relevanceScore || 0; - + if (scoreA !== scoreB) { return scoreB - scoreA; } - + const latencyA = a.latency || 99999; const latencyB = b.latency || 99999; return latencyA - latencyB; diff --git a/lib/utils/sorted-insert.ts b/lib/utils/sorted-insert.ts index 0831625..b969e54 100644 --- a/lib/utils/sorted-insert.ts +++ b/lib/utils/sorted-insert.ts @@ -2,10 +2,7 @@ * Binary insert utility for sorted arrays */ -interface Video { - relevanceScore?: number; - latency?: number; -} +import type { Video } from '@/lib/types'; /** * Insert videos into sorted array using binary search