feat: Implement comprehensive search functionality, enhance video player controls for desktop and mobile, and refactor settings and history UI.

This commit is contained in:
kuekhaoyang
2025-11-21 19:00:23 +08:00
parent a408f2270f
commit 58a8d4910e
79 changed files with 3259 additions and 2341 deletions
+3 -25
View File
@@ -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<string, string> = {
'dytt': '电影天堂',
'ruyi': '如意',
'baofeng': '暴风',
'tianya': '天涯',
'feifan': '非凡影视',
'sanliuling': '360',
'wolong': '卧龙',
'jisu': '极速',
'mozhua': '魔爪',
'modu': '魔都',
'zuida': '最大',
'yinghua': '樱花',
'baiduyun': '百度云',
'wujin': '无尽',
'wangwang': '旺旺',
'ikun': 'iKun',
};
return sourceNames[sourceId] || sourceId;
}
+5 -2
View File
@@ -2,5 +2,8 @@
@import "./scroll-optimization.css";
@import "./styles/variables.css";
@import "./styles/base.css";
@import "./styles/animations.css";
@import "./styles/components.css";
@import './styles/transitions.css';
@import './styles/effects.css';
@import './styles/glass.css';
@import "./styles/video-player.css";
@import "./styles/search-history.css";
+8 -102
View File
@@ -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 (
<div className="min-h-screen">
{/* Glass Navbar */}
<nav className="sticky top-0 z-[2000] pt-4 pb-2" style={{
transform: 'translate3d(0, 0, 0)',
willChange: 'transform'
}}>
<div className="max-w-7xl mx-auto px-4">
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)] px-6 py-4 rounded-[var(--radius-2xl)]" style={{
transform: 'translate3d(0, 0, 0)'
}}>
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
onClick={handleReset}
>
<div className="w-10 h-10 relative flex items-center justify-center">
<Image
src="/icon.png"
alt="KVideo"
width={40}
height={40}
className="object-contain"
/>
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--text-color)]">KVideo</h1>
<p className="text-xs text-[var(--text-color-secondary)]"></p>
</div>
</Link>
<div className="flex items-center gap-3">
<Link
href="/settings"
className="w-10 h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
aria-label="设置"
>
<svg className="w-5 h-5" viewBox="0 -960 960 960" fill="currentColor">
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
</svg>
</Link>
<ThemeSwitcher />
</div>
</div>
</div>
</div>
</nav>
<Navbar onReset={handleReset} />
{/* Search Form - Separate from navbar */}
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8 relative" style={{
@@ -171,36 +102,11 @@ function HomePage() {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{/* Results Section */}
{(results.length >= 1 || (!loading && results.length > 0)) && (
<div className="animate-fade-in">
<ResultsHeader
loading={loading}
resultsCount={results.length}
availableSources={availableSources}
/>
{/* Source Badges - Clickable video source filtering */}
{availableSources.length > 0 && (
<SourceBadges
sources={availableSources}
selectedSources={selectedSources}
onToggleSource={toggleSource}
className="mb-6"
/>
)}
{/* Type Badges - Auto-collected from search results */}
{typeBadges.length > 0 && (
<TypeBadges
badges={typeBadges}
selectedTypes={selectedTypes}
onToggleType={toggleType}
className="mb-6"
/>
)}
{/* Display filtered videos (both source and type filters applied) */}
<VideoGrid videos={finalFilteredVideos} />
</div>
<SearchResults
results={results}
availableSources={availableSources}
loading={loading}
/>
)}
{/* Popular Features - Homepage */}
+2 -34
View File
@@ -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 (
<div className="min-h-screen bg-[var(--bg-color)]">
{/* Glass Navbar */}
<nav className="sticky top-0 z-50 pt-4 pb-2 px-4" style={{ transform: 'translateZ(0)' }}>
<div className="max-w-7xl mx-auto bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] px-4 sm:px-6 py-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
<button
onClick={() => router.push('/')}
className="flex items-center justify-center hover:opacity-80 transition-opacity flex-shrink-0"
title="返回首页"
>
<Image
src="/icon.png"
alt="KVideo"
width={40}
height={40}
className="object-contain"
/>
</button>
<Button
variant="secondary"
onClick={() => router.back()}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={20} />
<span className="hidden sm:inline"></span>
</Button>
</div>
<div className="flex-shrink-0">
<ThemeSwitcher />
</div>
</div>
</div>
</nav>
<PlayerNavbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{loading ? (
+89
View File
@@ -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<VideoSource[]>([]);
const [sortBy, setSortBy] = useState<SortOption>('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,
};
}
+43 -178
View File
@@ -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<VideoSource[]>([]);
const [sortBy, setSortBy] = useState<SortOption>('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 (
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="inline-flex items-center gap-2 text-[var(--accent-color)] hover:underline mb-4"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<div className="flex items-center gap-4">
<div className="w-12 h-12 flex items-center justify-center rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)]">
<svg className="w-6 h-6 text-[var(--text-color)]" viewBox="0 -960 960 960" fill="currentColor">
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
</svg>
</div>
<div>
<h1 className="text-3xl font-bold text-[var(--text-color)]"></h1>
<p className="text-[var(--text-color-secondary)]"></p>
</div>
</div>
</div>
<SettingsHeader />
{/* Source Management */}
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-[var(--text-color)]"></h2>
<div className="flex gap-2">
<button
onClick={() => setIsRestoreDefaultsDialogOpen(true)}
className="px-4 py-2 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-sm font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
>
</button>
<button
onClick={() => setIsAddModalOpen(true)}
className="px-4 py-2 rounded-[var(--radius-2xl)] bg-[var(--accent-color)] text-white text-sm font-semibold hover:brightness-110 hover:-translate-y-0.5 shadow-[var(--shadow-sm)] transition-all duration-200"
>
+
</button>
</div>
</div>
<p className="text-sm text-[var(--text-color-secondary)] mb-6">
</p>
<SourceManager
sources={showAllSources ? sources : sources.slice(0, 10)}
onSourcesChange={handleSourcesChange}
/>
{sources.length > 10 && (
<button
onClick={() => setShowAllSources(!showAllSources)}
className="w-full mt-4 px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-sm font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
>
{showAllSources ? '收起' : `显示全部 (${sources.length})`}
</button>
)}
</div>
<SourceSettings
sources={sources}
onSourcesChange={handleSourcesChange}
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
onAddSource={() => setIsAddModalOpen(true)}
/>
{/* Sort Options */}
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(Object.keys(sortOptions) as SortOption[]).map((option) => (
<button
key={option}
onClick={() => handleSortChange(option)}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 ${sortBy === option
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
{sortOptions[option]}
</button>
))}
</div>
</div>
<SortSettings
sortBy={sortBy}
onSortChange={handleSortChange}
/>
{/* Data Management */}
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
<div className="space-y-3">
<button
onClick={() => setIsExportModalOpen(true)}
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 flex items-center justify-between"
>
<span></span>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
onClick={() => setIsImportModalOpen(true)}
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 flex items-center justify-between"
>
<span></span>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
</button>
<button
onClick={() => setIsResetDialogOpen(true)}
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-all duration-200 flex items-center justify-between"
>
<span></span>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
<DataSettings
onExport={() => setIsExportModalOpen(true)}
onImport={() => setIsImportModalOpen(true)}
onReset={() => setIsResetDialogOpen(true)}
/>
</div>
{/* Modals */}
@@ -240,3 +104,4 @@ export default function SettingsPage() {
</div>
);
}
-245
View File
@@ -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);
}
+85
View File
@@ -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%;
}
}
+65
View File
@@ -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;
}
+86
View File
@@ -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);
}
@@ -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);
}
}
+92
View File
@@ -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;
}
+24
View File
@@ -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 (
<footer className="mt-4 pt-4 border-t border-[var(--glass-border)]">
<Button
variant="secondary"
onClick={onClearAll}
className="w-full flex items-center justify-center gap-2"
>
<Icons.Trash size={18} />
</Button>
</footer>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Icons } from '@/components/ui/Icon';
interface HistoryHeaderProps {
onClose: () => void;
}
export function HistoryHeader({ onClose }: HistoryHeaderProps) {
return (
<header className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--glass-border)]">
<div className="flex items-center gap-3">
<Icons.History size={24} className="text-[var(--accent-color)]" />
<h2
id="history-sidebar-title"
className="text-xl font-semibold text-[var(--text-color)]"
>
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--glass-bg)] rounded-full transition-colors"
aria-label="关闭"
>
<Icons.X size={24} className="text-[var(--text-color-secondary)]" />
</button>
</header>
);
}
+3 -57
View File
@@ -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({
>
<div className="flex gap-3">
{/* Poster */}
<div className="relative w-28 h-16 flex-shrink-0 bg-[var(--glass-bg)] rounded-[var(--radius-2xl)] overflow-hidden">
{poster ? (
<Image
src={poster}
alt={title}
fill
className="object-cover"
sizes="112px"
onError={(e) => {
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 = '<svg class="text-[var(--text-color-secondary)] opacity-30" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line><line x1="2" y1="7" x2="7" y2="7"></line><line x1="2" y1="17" x2="7" y2="17"></line><line x1="17" y1="17" x2="22" y2="17"></line><line x1="17" y1="7" x2="22" y2="7"></line></svg>';
parent.appendChild(fallback);
}
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icons.Film size={32} className="text-[var(--text-color-secondary)] opacity-30" />
</div>
)}
{/* Progress overlay */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/30">
<div
className="h-full bg-[var(--accent-color)]"
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
</div>
<PosterImage poster={poster} title={title} progress={progress} />
{/* Info */}
<div className="flex-1 min-w-0">
+39
View File
@@ -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 (
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
transform: 'translate3d(0, 0, 0)',
WebkitOverflowScrolling: 'touch'
}}>
{history.length === 0 ? (
<HistoryEmptyState />
) : (
<div className="space-y-3">
{history.map((item) => (
<HistoryItem
key={`${item.videoId}-${item.source}-${item.timestamp}`}
videoId={item.videoId}
source={item.source}
title={item.title}
poster={item.poster}
episodeIndex={item.episodeIndex}
episodes={item.episodes}
playbackPosition={item.playbackPosition}
duration={item.duration}
timestamp={item.timestamp}
onRemove={() => onRemove(item.videoId, item.source)}
/>
))}
</div>
)}
</div>
);
}
+50
View File
@@ -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 (
<div className="relative w-28 h-16 flex-shrink-0 bg-[var(--glass-bg)] rounded-[var(--radius-2xl)] overflow-hidden">
{poster ? (
<Image
src={poster}
alt={title}
fill
className="object-cover"
sizes="112px"
onError={(e) => {
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 = '<svg class="text-[var(--text-color-secondary)] opacity-30" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line><line x1="2" y1="7" x2="7" y2="7"></line><line x1="2" y1="17" x2="7" y2="17"></line><line x1="17" y1="17" x2="22" y2="17"></line><line x1="17" y1="7" x2="22" y2="7"></line></svg>';
parent.appendChild(fallback);
}
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icons.Film size={32} className="text-[var(--text-color-secondary)] opacity-30" />
</div>
)}
{/* Progress overlay */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/30">
<div
className="h-full bg-[var(--accent-color)]"
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
</div>
);
}
+12 -62
View File
@@ -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 */}
<header className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--glass-border)]">
<div className="flex items-center gap-3">
<Icons.History size={24} className="text-[var(--accent-color)]" />
<h2
id="history-sidebar-title"
className="text-xl font-semibold text-[var(--text-color)]"
>
</h2>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-[var(--glass-bg)] rounded-full transition-colors"
aria-label="关闭"
>
<Icons.X size={24} className="text-[var(--text-color-secondary)]" />
</button>
</header>
<HistoryHeader onClose={() => setIsOpen(false)} />
{/* Content */}
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
transform: 'translate3d(0, 0, 0)',
WebkitOverflowScrolling: 'touch'
}}>
{viewingHistory.length === 0 ? (
<HistoryEmptyState />
) : (
<div className="space-y-3">
{viewingHistory.map((item) => (
<HistoryItem
key={`${item.videoId}-${item.source}-${item.timestamp}`}
videoId={item.videoId}
source={item.source}
title={item.title}
poster={item.poster}
episodeIndex={item.episodeIndex}
episodes={item.episodes}
playbackPosition={item.playbackPosition}
duration={item.duration}
timestamp={item.timestamp}
onRemove={() => handleDeleteItem(item.videoId, item.source)}
/>
))}
</div>
)}
</div>
<HistoryList
history={viewingHistory}
onRemove={handleDeleteItem}
/>
{/* Footer */}
{viewingHistory.length > 0 && (
<footer className="mt-4 pt-4 border-t border-[var(--glass-border)]">
<Button
variant="secondary"
onClick={handleClearAll}
className="w-full flex items-center justify-center gap-2"
>
<Icons.Trash size={18} />
</Button>
</footer>
)}
<HistoryFooter
hasHistory={viewingHistory.length > 0}
onClearAll={handleClearAll}
/>
</aside>
{/* Confirm Dialog */}
+21 -123
View File
@@ -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<DoubanMovie[]>([]);
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 (
<div className="animate-fade-in">
<TagManager
+67
View File
@@ -0,0 +1,67 @@
import { ResultsHeader } from '@/components/search/ResultsHeader';
import { SourceBadges } from '@/components/search/SourceBadges';
import { TypeBadges } from '@/components/search/TypeBadges';
import { VideoGrid } from '@/components/search/VideoGrid';
import { useSourceBadges } from '@/lib/hooks/useSourceBadges';
import { useTypeBadges } from '@/lib/hooks/useTypeBadges';
import { Video, SourceBadge } from '@/lib/types';
interface SearchResultsProps {
results: Video[];
availableSources: SourceBadge[];
loading: boolean;
}
export function SearchResults({ results, availableSources, loading }: SearchResultsProps) {
// 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);
if (results.length === 0 && !loading) return null;
return (
<div className="animate-fade-in">
<ResultsHeader
loading={loading}
resultsCount={results.length}
availableSources={availableSources}
/>
{/* Source Badges - Clickable video source filtering */}
{availableSources.length > 0 && (
<SourceBadges
sources={availableSources}
selectedSources={selectedSources}
onToggleSource={toggleSource}
className="mb-6"
/>
)}
{/* Type Badges - Auto-collected from search results */}
{typeBadges.length > 0 && (
<TypeBadges
badges={typeBadges}
selectedTypes={selectedTypes}
onToggleType={toggleType}
className="mb-6"
/>
)}
{/* Display filtered videos (both source and type filters applied) */}
<VideoGrid videos={finalFilteredVideos} />
</div>
);
}
+69
View File
@@ -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<DoubanMovie[]>([]);
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,
};
}
+84
View File
@@ -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,
};
}
+57
View File
@@ -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 (
<nav className="sticky top-0 z-[2000] pt-4 pb-2" style={{
transform: 'translate3d(0, 0, 0)',
willChange: 'transform'
}}>
<div className="max-w-7xl mx-auto px-4">
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)] px-6 py-4 rounded-[var(--radius-2xl)]" style={{
transform: 'translate3d(0, 0, 0)'
}}>
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
onClick={onReset}
>
<div className="w-10 h-10 relative flex items-center justify-center">
<Image
src="/icon.png"
alt="KVideo"
width={40}
height={40}
className="object-contain"
/>
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--text-color)]">KVideo</h1>
<p className="text-xs text-[var(--text-color-secondary)]"></p>
</div>
</Link>
<div className="flex items-center gap-3">
<Link
href="/settings"
className="w-10 h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
aria-label="设置"
>
<svg className="w-5 h-5" viewBox="0 -960 960 960" fill="currentColor">
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
</svg>
</Link>
<ThemeSwitcher />
</div>
</div>
</div>
</div>
</nav>
);
}
+8 -100
View File
@@ -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 (
<div
ref={containerRef}
@@ -116,65 +74,15 @@ export function DesktopVideoPlayer({
onClick={togglePlay}
/>
<DesktopOverlay
isLoading={isLoading}
isPlaying={isPlaying}
showSkipForwardIndicator={showSkipForwardIndicator}
showSkipBackwardIndicator={showSkipBackwardIndicator}
skipForwardAmount={skipForwardAmount}
skipBackwardAmount={skipBackwardAmount}
isSkipForwardAnimatingOut={isSkipForwardAnimatingOut}
isSkipBackwardAnimatingOut={isSkipBackwardAnimatingOut}
showToast={showToast}
toastMessage={toastMessage}
<DesktopOverlayWrapper
state={state}
onTogglePlay={togglePlay}
/>
<DesktopControls
showControls={showControls}
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
volume={volume}
isMuted={isMuted}
isFullscreen={isFullscreen}
playbackRate={playbackRate}
showSpeedMenu={showSpeedMenu}
showMoreMenu={showMoreMenu}
showVolumeBar={showVolumeBar}
isPiPSupported={isPiPSupported}
isAirPlaySupported={isAirPlaySupported}
progressBarRef={progressBarRef}
volumeBarRef={volumeBarRef}
onTogglePlay={togglePlay}
onSkipForward={skipForward}
onSkipBackward={skipBackward}
onToggleMute={toggleMute}
onVolumeChange={handleVolumeChange}
onVolumeMouseDown={handleVolumeMouseDown}
onToggleFullscreen={toggleFullscreen}
onTogglePictureInPicture={togglePictureInPicture}
onShowAirPlayMenu={showAirPlayMenu}
onToggleSpeedMenu={() => 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}
<DesktopControlsWrapper
state={state}
logic={logic}
refs={refs}
/>
</div>
);
+17 -91
View File
@@ -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 (
<div
@@ -158,42 +116,10 @@ export function MobileVideoPlayer({
skipAmount={skipAmount}
/>
<MobileControls
showControls={showControls}
isCompactLayout={isCompactLayout}
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
volume={volume}
isMuted={isMuted}
isFullscreen={isFullscreen}
playbackRate={playbackRate}
showMoreMenu={showMoreMenu}
showVolumeMenu={showVolumeMenu}
showSpeedMenu={showSpeedMenu}
isPiPSupported={isPiPSupported}
progressBarRef={progressBarRef}
onTogglePlay={togglePlay}
onSkipVideo={skipVideo}
onToggleMute={toggleMute}
onToggleFullscreen={toggleFullscreen}
onToggleMoreMenu={() => 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}
<MobileControlsWrapper
state={state}
logic={logic}
refs={refs}
/>
</div>
);
+44
View File
@@ -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 (
<nav className="sticky top-0 z-50 pt-4 pb-2 px-4" style={{ transform: 'translateZ(0)' }}>
<div className="max-w-7xl mx-auto bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] px-4 sm:px-6 py-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
<button
onClick={() => router.push('/')}
className="flex items-center justify-center hover:opacity-80 transition-opacity flex-shrink-0"
title="返回首页"
>
<Image
src="/icon.png"
alt="KVideo"
width={40}
height={40}
className="object-contain"
/>
</button>
<Button
variant="secondary"
onClick={() => router.back()}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={20} />
<span className="hidden sm:inline"></span>
</Button>
</div>
<div className="flex-shrink-0">
<ThemeSwitcher />
</div>
</div>
</div>
</nav>
);
}
@@ -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<typeof useDesktopPlayerState>['state'];
logic: ReturnType<typeof useDesktopPlayerLogic>;
refs: ReturnType<typeof useDesktopPlayerState>['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 (
<DesktopControls
showControls={showControls}
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
volume={volume}
isMuted={isMuted}
isFullscreen={isFullscreen}
playbackRate={playbackRate}
showSpeedMenu={showSpeedMenu}
showMoreMenu={showMoreMenu}
showVolumeBar={showVolumeBar}
isPiPSupported={isPiPSupported}
isAirPlaySupported={isAirPlaySupported}
progressBarRef={progressBarRef}
volumeBarRef={volumeBarRef}
onTogglePlay={togglePlay}
onSkipForward={skipForward}
onSkipBackward={skipBackward}
onToggleMute={toggleMute}
onVolumeChange={handleVolumeChange}
onVolumeMouseDown={handleVolumeMouseDown}
onToggleFullscreen={toggleFullscreen}
onTogglePictureInPicture={togglePictureInPicture}
onShowAirPlayMenu={showAirPlayMenu}
onToggleSpeedMenu={() => 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}
/>
);
}
@@ -0,0 +1,39 @@
import React from 'react';
import { DesktopOverlay } from './DesktopOverlay';
import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState';
interface DesktopOverlayWrapperProps {
state: ReturnType<typeof useDesktopPlayerState>['state'];
onTogglePlay: () => void;
}
export function DesktopOverlayWrapper({ state, onTogglePlay }: DesktopOverlayWrapperProps) {
const {
isLoading,
isPlaying,
showSkipForwardIndicator,
showSkipBackwardIndicator,
skipForwardAmount,
skipBackwardAmount,
isSkipForwardAnimatingOut,
isSkipBackwardAnimatingOut,
showToast,
toastMessage,
} = state;
return (
<DesktopOverlay
isLoading={isLoading}
isPlaying={isPlaying}
showSkipForwardIndicator={showSkipForwardIndicator}
showSkipBackwardIndicator={showSkipBackwardIndicator}
skipForwardAmount={skipForwardAmount}
skipBackwardAmount={skipBackwardAmount}
isSkipForwardAnimatingOut={isSkipForwardAnimatingOut}
isSkipBackwardAnimatingOut={isSkipBackwardAnimatingOut}
showToast={showToast}
toastMessage={toastMessage}
onTogglePlay={onTogglePlay}
/>
);
}
@@ -0,0 +1,124 @@
import { useEffect } from 'react';
interface UseDesktopShortcutsProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
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<NodeJS.Timeout | null>;
}
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,
]);
}
@@ -1,8 +1,8 @@
import { useCallback, useEffect } from 'react';
interface UseFullscreenControlsProps {
containerRef: React.RefObject<HTMLDivElement>;
videoRef: React.RefObject<HTMLVideoElement>;
containerRef: React.RefObject<HTMLDivElement | null>;
videoRef: React.RefObject<HTMLVideoElement | null>;
isFullscreen: boolean;
setIsFullscreen: (fullscreen: boolean) => void;
isPiPSupported: boolean;
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
interface UsePlaybackControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
videoRef: React.RefObject<HTMLVideoElement | null>;
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<boolean>;
speedMenuTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
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<NodeJS.Timeout | null>, 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';
@@ -1,8 +1,8 @@
import { useCallback, useEffect } from 'react';
interface UseProgressControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
progressBarRef: React.RefObject<HTMLDivElement>;
videoRef: React.RefObject<HTMLVideoElement | null>;
progressBarRef: React.RefObject<HTMLDivElement | null>;
duration: number;
setCurrentTime: (time: number) => void;
isDraggingProgressRef: React.MutableRefObject<boolean>;
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
interface UseSkipControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
videoRef: React.RefObject<HTMLVideoElement | null>;
duration: number;
setCurrentTime: (time: number) => void;
showSkipForwardIndicator: boolean;
@@ -1,8 +1,8 @@
import { useCallback, useEffect } from 'react';
interface UseVolumeControlsProps {
videoRef: React.RefObject<HTMLVideoElement>;
volumeBarRef: React.RefObject<HTMLDivElement>;
videoRef: React.RefObject<HTMLVideoElement | null>;
volumeBarRef: React.RefObject<HTMLDivElement | null>;
volume: number;
isMuted: boolean;
setVolume: (volume: number) => void;
@@ -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,
};
}
@@ -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<typeof useDesktopPlayerState>;
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,
@@ -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<NodeJS.Timeout | null>;
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 };
}
+32 -70
View File
@@ -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,
+36 -134
View File
@@ -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 (
<div className={`flex items-center ${controlsGap}`}>
<div className={`flex items-center ${controlsGap}`}>
<button
onClick={(e) => {
e.stopPropagation();
onTogglePlay();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'left');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="后退 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipBack size={iconSize} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'right');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="前进 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipForward size={iconSize} />
</button>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleVolumeMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="音量"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isMuted || volume === 0 ? <Icons.VolumeX size={iconSize} /> : <Icons.Volume2 size={iconSize} />}
</button>
<MobileVolumeMenu
showVolumeMenu={showVolumeMenu}
isCompactLayout={false}
isMuted={isMuted}
volume={volume}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onClose={onToggleVolumeMenu}
/>
</div>
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<LeftControls
isPlaying={isPlaying}
onTogglePlay={onTogglePlay}
onSkipVideo={onSkipVideo}
isMuted={isMuted}
volume={volume}
showVolumeMenu={showVolumeMenu}
onToggleVolumeMenu={onToggleVolumeMenu}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
currentTime={currentTime}
duration={duration}
formatTime={formatTime}
iconSize={iconSize}
buttonPadding={buttonPadding}
textSize={textSize}
controlsGap={controlsGap}
/>
<div className="flex-1" />
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSpeedMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="播放速度"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<span className={`text-white ${textSize} font-medium`}>{playbackRate}x</span>
</button>
<MobileSpeedMenu
showSpeedMenu={showSpeedMenu}
isCompactLayout={false}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onClose={onToggleSpeedMenu}
/>
</div>
{isPiPSupported && (
<button
onClick={(e) => {
e.stopPropagation();
onTogglePiP();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="画中画"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.PictureInPicture size={iconSize} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onToggleMoreMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="更多"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFullscreen();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
</button>
</div>
<RightControls
playbackRate={playbackRate}
showSpeedMenu={showSpeedMenu}
onToggleSpeedMenu={onToggleSpeedMenu}
speeds={speeds}
onSpeedChange={onSpeedChange}
isPiPSupported={isPiPSupported}
onTogglePiP={onTogglePiP}
onToggleMoreMenu={onToggleMoreMenu}
isFullscreen={isFullscreen}
onToggleFullscreen={onToggleFullscreen}
iconSize={iconSize}
buttonPadding={buttonPadding}
textSize={textSize}
controlsGap={controlsGap}
/>
</div>
);
}
@@ -0,0 +1,90 @@
import { MobileControls } from './MobileControls';
import { useMobilePlayerState } from '../hooks/useMobilePlayerState';
import { useMobilePlayerLogic } from '../hooks/useMobilePlayerLogic';
interface MobileControlsWrapperProps {
state: ReturnType<typeof useMobilePlayerState>['state'];
logic: ReturnType<typeof useMobilePlayerLogic>;
refs: ReturnType<typeof useMobilePlayerState>['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 (
<MobileControls
showControls={showControls}
isCompactLayout={isCompactLayout}
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
volume={volume}
isMuted={isMuted}
isFullscreen={isFullscreen}
playbackRate={playbackRate}
showMoreMenu={showMoreMenu}
showVolumeMenu={showVolumeMenu}
showSpeedMenu={showSpeedMenu}
isPiPSupported={isPiPSupported}
progressBarRef={progressBarRef}
onTogglePlay={togglePlay}
onSkipVideo={skipVideo}
onToggleMute={toggleMute}
onToggleFullscreen={toggleFullscreen}
onToggleMoreMenu={() => 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}
/>
);
}
@@ -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 (
<div className={`flex items-center ${controlsGap}`}>
<button
onClick={(e) => {
e.stopPropagation();
onTogglePlay();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isPlaying ? 'Pause' : 'Play'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isPlaying ? <Icons.Pause size={iconSize} /> : <Icons.Play size={iconSize} />}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'left');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="后退 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipBack size={iconSize} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSkipVideo(10, 'right');
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="前进 10 秒"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.SkipForward size={iconSize} />
</button>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleVolumeMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="音量"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isMuted || volume === 0 ? <Icons.VolumeX size={iconSize} /> : <Icons.Volume2 size={iconSize} />}
</button>
<MobileVolumeMenu
showVolumeMenu={showVolumeMenu}
isCompactLayout={false}
isMuted={isMuted}
volume={volume}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onClose={onToggleVolumeMenu}
/>
</div>
<span className={`text-white ${textSize} font-medium tabular-nums whitespace-nowrap`}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
);
}
@@ -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 (
<div className={`flex items-center ${controlsGap} flex-shrink-0`}>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSpeedMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="播放速度"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<span className={`text-white ${textSize} font-medium`}>{playbackRate}x</span>
</button>
<MobileSpeedMenu
showSpeedMenu={showSpeedMenu}
isCompactLayout={false}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onClose={onToggleSpeedMenu}
/>
</div>
{isPiPSupported && (
<button
onClick={(e) => {
e.stopPropagation();
onTogglePiP();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="画中画"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<Icons.PictureInPicture size={iconSize} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onToggleMoreMenu();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation`}
aria-label="更多"
style={{ WebkitTapHighlightColor: 'transparent' }}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFullscreen();
}}
className={`btn-icon ${buttonPadding} flex-shrink-0 touch-manipulation relative z-[60]`}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
style={{ WebkitTapHighlightColor: 'transparent' }}
>
{isFullscreen ? <Icons.Minimize size={iconSize} /> : <Icons.Maximize size={iconSize} />}
</button>
</div>
);
}
+2 -1
View File
@@ -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({
+123
View File
@@ -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<HTMLInputElement>(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 (
<form onSubmit={handleSubmit} className="relative group" style={{ isolation: 'isolate' }}>
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 z-10">
{query && (
<button
type="button"
onClick={handleClear}
className="p-2 text-[var(--text-color)] opacity-70 hover:opacity-100 transition-opacity touch-manipulation"
aria-label="清除搜索"
>
<Icons.X size={20} />
</button>
)}
<Button
type="submit"
disabled={!query.trim()}
variant="primary"
className="px-3 sm:px-4 md:px-6"
>
<span className="flex items-center gap-2">
<Icons.Search size={20} />
<span className="hidden sm:inline"></span>
</span>
</Button>
</div>
{/* Search History Dropdown */}
<SearchHistoryDropdown
isOpen={isDropdownOpen}
searchHistory={searchHistory}
highlightedIndex={highlightedIndex}
triggerRef={inputRef}
onSelectItem={selectHistoryItem}
onRemoveItem={removeSearch}
onClearAll={clearAll}
/>
</form>
);
}
+9 -151
View File
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
// 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<HTMLInputElement>) => {
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 (
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto">
<div className="relative group" style={{ isolation: 'isolate' }}>
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 z-10">
{query && (
<button
type="button"
onClick={handleClear}
className="p-2 text-[var(--text-color)] opacity-70 hover:opacity-100 transition-opacity touch-manipulation"
aria-label="清除搜索"
>
<Icons.X size={20} />
</button>
)}
<Button
type="submit"
disabled={!query.trim()}
variant="primary"
className="px-3 sm:px-4 md:px-6"
>
<span className="flex items-center gap-2">
<Icons.Search size={20} />
<span className="hidden sm:inline"></span>
</span>
</Button>
</div>
<div className="max-w-3xl mx-auto">
<SearchBox
onSearch={onSearch}
onClear={onClear}
initialQuery={initialQuery}
/>
{/* Search History Dropdown */}
<SearchHistoryDropdown
isOpen={isDropdownOpen}
searchHistory={searchHistory}
highlightedIndex={highlightedIndex}
triggerRef={inputRef}
onSelectItem={selectHistoryItem}
onRemoveItem={removeSearch}
onClearAll={clearAll}
/>
</div>
{/* Loading Animation */}
{isLoading && (
<div className="mt-4">
<SearchLoadingAnimation
<SearchLoadingAnimation
currentSource={currentSource}
checkedSources={checkedSources}
totalSources={totalSources}
/>
</div>
)}
</form>
</div>
);
}
+12 -68
View File
@@ -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
<div className="search-history-empty">
<Icons.Clock size={32} className="text-[var(--text-color-secondary)] mx-auto mb-2 opacity-50" />
<span className="text-sm text-[var(--text-color-secondary)]"></span>
</div>
<SearchHistoryEmptyState />
) : (
<>
{/* Header with clear all button */}
<div className="search-history-header">
<div className="flex items-center gap-2">
<Icons.Clock size={16} className="text-[var(--text-color-secondary)]" />
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClearAll();
}}
className="text-xs text-[var(--accent-color)] hover:underline transition-all"
aria-label="清除所有历史"
>
</button>
</div>
<SearchHistoryHeader onClearAll={onClearAll} />
{/* Divider */}
<div className="search-history-divider" />
@@ -97,51 +78,14 @@ export function SearchHistoryDropdown({
{/* History items */}
<div className="search-history-list">
{searchHistory.map((item, index) => (
<div
<SearchHistoryListItem
key={`${item.query}-${item.timestamp}`}
data-index={index}
role="option"
aria-selected={index === highlightedIndex}
className={`search-history-item ${
index === highlightedIndex ? 'highlighted' : ''
}`}
onClick={(e) => {
e.preventDefault();
onSelectItem(item.query);
}}
onMouseEnter={() => {
// Visual feedback on hover
}}
tabIndex={0}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Icons.Search
size={16}
className="flex-shrink-0 text-[var(--text-color-secondary)]"
/>
<span className="text-[var(--text-color)] truncate flex-1">
{item.query}
</span>
{item.resultCount !== undefined && (
<span className="text-xs text-[var(--text-color-secondary)] flex-shrink-0">
{item.resultCount}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemoveItem(item.query);
}}
className="search-history-remove"
aria-label={`删除 "${item.query}"`}
tabIndex={0}
>
<Icons.X size={14} />
</button>
</div>
item={item}
index={index}
isHighlighted={index === highlightedIndex}
onSelectItem={onSelectItem}
onRemoveItem={onRemoveItem}
/>
))}
</div>
</>
@@ -0,0 +1,14 @@
/**
* Empty state for search history dropdown
*/
import { Icons } from '@/components/ui/Icon';
export function SearchHistoryEmptyState() {
return (
<div className="search-history-empty">
<Icons.Clock size={32} className="text-[var(--text-color-secondary)] mx-auto mb-2 opacity-50" />
<span className="text-sm text-[var(--text-color-secondary)]"></span>
</div>
);
}
+33
View File
@@ -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 (
<div className="search-history-header">
<div className="flex items-center gap-2">
<Icons.Clock size={16} className="text-[var(--text-color-secondary)]" />
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClearAll();
}}
className="text-xs text-[var(--accent-color)] hover:underline transition-all"
aria-label="清除所有历史"
>
</button>
</div>
);
}
@@ -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 (
<div
data-index={index}
role="option"
aria-selected={isHighlighted}
className={`search-history-item ${isHighlighted ? 'highlighted' : ''}`}
onClick={(e) => {
e.preventDefault();
onSelectItem(item.query);
}}
tabIndex={0}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Icons.Search
size={16}
className="flex-shrink-0 text-[var(--text-color-secondary)]"
/>
<span className="text-[var(--text-color)] truncate flex-1">
{item.query}
</span>
{item.resultCount !== undefined && (
<span className="text-xs text-[var(--text-color-secondary)] flex-shrink-0">
{item.resultCount}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemoveItem(item.query);
}}
className="search-history-remove"
aria-label={`删除 "${item.query}"`}
tabIndex={0}
>
<Icons.X size={14} />
</button>
</div>
);
}
+9 -14
View File
@@ -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<string>;
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 (
<Card
hover={false}
<Card
hover={false}
className={`p-4 animate-fade-in bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] ${className}`}
>
<div className="flex items-start gap-3">
@@ -51,14 +46,14 @@ export const SourceBadges = memo(function SourceBadges({
({sources.length}):
</span>
</div>
<SourceBadgeList
sources={sources}
selectedSources={selectedSources}
onToggleSource={onToggleSource}
/>
</div>
{selectedSources.size > 0 && (
<div className="mt-3 pt-3 border-t border-[var(--glass-border)]">
<button
+8 -12
View File
@@ -11,11 +11,7 @@ import { memo } from 'react';
import { Card } from '@/components/ui/Card';
import { Icons } from '@/components/ui/Icon';
import { TypeBadgeList } from './TypeBadgeList';
interface TypeBadge {
type: string;
count: number;
}
import type { TypeBadge } from '@/lib/types';
interface TypeBadgesProps {
badges: TypeBadge[];
@@ -24,11 +20,11 @@ interface TypeBadgesProps {
className?: string;
}
export const TypeBadges = memo(function TypeBadges({
badges,
export const TypeBadges = memo(function TypeBadges({
badges,
selectedTypes,
onToggleType,
className = ''
className = ''
}: TypeBadgesProps) {
if (badges.length === 0) {
return null;
@@ -39,8 +35,8 @@ export const TypeBadges = memo(function TypeBadges({
};
return (
<Card
hover={false}
<Card
hover={false}
className={`p-4 animate-fade-in bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] ${className}`}
>
<div className="flex items-start gap-3">
@@ -50,14 +46,14 @@ export const TypeBadges = memo(function TypeBadges({
({badges.length}):
</span>
</div>
<TypeBadgeList
badges={badges}
selectedTypes={selectedTypes}
onToggleType={onToggleType}
/>
</div>
{selectedTypes.size > 0 && (
<div className="mt-3 pt-3 border-t border-[var(--glass-border)]">
<button
+1 -12
View File
@@ -8,18 +8,7 @@ import { Badge } from '@/components/ui/Badge';
import { Icons } from '@/components/ui/Icon';
import { LatencyBadge } from '@/components/ui/LatencyBadge';
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;
latency?: number;
}
import { Video } from '@/lib/types';
interface VideoCardProps {
video: Video;
+2 -13
View File
@@ -3,18 +3,7 @@
import { useState, useRef, useCallback, useMemo, memo } from 'react';
import { VideoCard } from './VideoCard';
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;
latency?: number;
}
import { Video } from '@/lib/types';
interface VideoGridProps {
videos: Video[];
@@ -69,7 +58,7 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
const videoItems = useMemo(() => {
return videos.map((video, index) => {
const videoUrl = `/player?${new URLSearchParams({
id: video.vod_id,
id: String(video.vod_id),
source: video.source,
title: video.vod_name,
}).toString()}`;
@@ -0,0 +1,102 @@
/**
* Search box event handlers hook
*/
import { FormEvent, RefObject } from 'react';
interface UseSearchBoxHandlersProps {
query: string;
setQuery: (query: string) => void;
onSearch: (query: string) => void;
onClear?: () => void;
inputRef: RefObject<HTMLInputElement | null>;
isDropdownOpen: boolean;
highlightedIndex: number;
searchHistory: Array<{ query: string }>;
addSearch: (query: string) => void;
hideDropdown: () => void;
showDropdown: () => void;
resetHighlight: () => void;
selectHistoryItem: (query: string) => void;
navigateDropdown: (direction: 'up' | 'down') => void;
}
export function useSearchBoxHandlers({
query,
setQuery,
onSearch,
onClear,
inputRef,
isDropdownOpen,
highlightedIndex,
searchHistory,
addSearch,
hideDropdown,
showDropdown,
resetHighlight,
selectHistoryItem,
navigateDropdown,
}: UseSearchBoxHandlersProps) {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (query.trim()) {
addSearch(query.trim());
onSearch(query);
hideDropdown();
inputRef.current?.blur();
}
};
const handleClear = () => {
setQuery('');
if (onClear) {
onClear();
}
resetHighlight();
};
const handleInputFocus = () => {
showDropdown();
};
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget && relatedTarget.closest('.search-history-dropdown')) {
return;
}
hideDropdown();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 {
handleSubmit,
handleClear,
handleInputFocus,
handleInputBlur,
handleKeyDown,
};
}
+15 -74
View File
@@ -1,6 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
import { useAddSourceForm } from './hooks/useAddSourceForm';
import { ModalBackdrop } from '@/components/ui/ModalBackdrop';
import { ModalHeader } from '@/components/ui/ModalHeader';
import type { VideoSource } from '@/lib/types';
interface AddSourceModalProps {
@@ -11,89 +13,28 @@ interface AddSourceModalProps {
}
export function AddSourceModal({ isOpen, onClose, onAdd, existingIds }: AddSourceModalProps) {
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();
};
const { name, setName, url, setUrl, error, handleSubmit } = useAddSourceForm({
isOpen,
existingIds,
onAdd,
onClose,
});
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
/>
<ModalBackdrop isOpen={isOpen} onClose={onClose} />
{/* Modal */}
<div
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${
isOpen
? 'opacity-100 -translate-y-1/2 scale-100'
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
}`}
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${isOpen
? 'opacity-100 -translate-y-1/2 scale-100'
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
}`}
>
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold text-[var(--text-color)]">
</h3>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
aria-label="关闭"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<ModalHeader title="添加自定义源" onClose={onClose} />
<form onSubmit={handleSubmit} className="space-y-4">
<div>
+44
View File
@@ -0,0 +1,44 @@
interface DataSettingsProps {
onExport: () => void;
onImport: () => void;
onReset: () => void;
}
export function DataSettings({ onExport, onImport, onReset }: DataSettingsProps) {
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
<div className="space-y-3">
<button
onClick={onExport}
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 flex items-center justify-between"
>
<span></span>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
onClick={onImport}
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 flex items-center justify-between"
>
<span></span>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
</button>
<button
onClick={onReset}
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-all duration-200 flex items-center justify-between"
>
<span></span>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { useRouter } from 'next/navigation';
export function SettingsHeader() {
const router = useRouter();
return (
<div className="mb-8">
<button
onClick={() => router.back()}
className="inline-flex items-center gap-2 text-[var(--accent-color)] hover:underline mb-4"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<div className="flex items-center gap-4">
<div className="w-12 h-12 flex items-center justify-center rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)]">
<svg className="w-6 h-6 text-[var(--text-color)]" viewBox="0 -960 960 960" fill="currentColor">
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
</svg>
</div>
<div>
<h1 className="text-3xl font-bold text-[var(--text-color)]"></h1>
<p className="text-[var(--text-color-secondary)]"></p>
</div>
</div>
</div>
);
}
+31
View File
@@ -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 (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(Object.keys(sortOptions) as SortOption[]).map((option) => (
<button
key={option}
onClick={() => onSortChange(option)}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 ${sortBy === option
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
{sortOptions[option]}
</button>
))}
</div>
</div>
);
}
+56
View File
@@ -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 (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-[var(--text-color)]"></h2>
<div className="flex gap-2">
<button
onClick={onRestoreDefaults}
className="px-4 py-2 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-sm font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
>
</button>
<button
onClick={onAddSource}
className="px-4 py-2 rounded-[var(--radius-2xl)] bg-[var(--accent-color)] text-white text-sm font-semibold hover:brightness-110 hover:-translate-y-0.5 shadow-[var(--shadow-sm)] transition-all duration-200"
>
+
</button>
</div>
</div>
<p className="text-sm text-[var(--text-color-secondary)] mb-6">
</p>
<SourceManager
sources={showAllSources ? sources : sources.slice(0, 10)}
onSourcesChange={onSourcesChange}
/>
{sources.length > 10 && (
<button
onClick={() => setShowAllSources(!showAllSources)}
className="w-full mt-4 px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-sm font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
>
{showAllSources ? '收起' : `显示全部 (${sources.length})`}
</button>
)}
</div>
);
}
@@ -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,
};
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Reusable modal backdrop component
*/
interface ModalBackdropProps {
isOpen: boolean;
onClose: () => void;
}
export function ModalBackdrop({ isOpen, onClose }: ModalBackdropProps) {
return (
<div
className={`fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Reusable modal header component
*/
interface ModalHeaderProps {
title: string;
onClose: () => void;
}
export function ModalHeader({ title, onClose }: ModalHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold text-[var(--text-color)]">
{title}
</h3>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200"
aria-label="关闭"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
);
}
+2 -169
View File
@@ -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<Array<{ results: VideoItem[]; source: string; responseTime?: number; error?: string }>> {
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<VideoDetail> {
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';
+76
View File
@@ -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<VideoDetail> {
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,
};
}
}
+88
View File
@@ -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<Array<{ results: VideoItem[]; source: string; responseTime?: number; error?: string }>> {
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);
}
+42
View File
@@ -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;
}
+88
View File
@@ -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<NodeJS.Timeout | null>(null);
const handleTap = (e: React.TouchEvent<HTMLVideoElement>) => {
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 };
}
+47
View File
@@ -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]);
}
+3 -175
View File
@@ -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<NodeJS.Timeout | null>(null);
const handleTap = (e: React.TouchEvent<HTMLVideoElement>) => {
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';
+29 -174
View File
@@ -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<Video[]>([]);
const [availableSources, setAvailableSources] = useState<any[]>([]);
const [completedSources, setCompletedSources] = useState(0);
const [totalSources, setTotalSources] = useState(0);
const [totalVideosFound, setTotalVideosFound] = useState(0);
const currentQueryRef = useRef<string>('');
const state = useSearchState();
const {
loading,
results,
availableSources,
completedSources,
totalSources,
totalVideosFound,
setResults,
setAvailableSources,
setTotalVideosFound,
resetState,
} = state;
const abortControllerRef = useRef<AbortController | null>(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<string, { count: number; name: string }>();
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,
};
}
+127
View File
@@ -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<typeof useSearchState>;
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<AbortController | null>(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<string, { count: number; name: string }>();
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 };
}
+50
View File
@@ -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<Video[]>([]);
const [availableSources, setAvailableSources] = useState<SourceBadge[]>([]);
const [completedSources, setCompletedSources] = useState(0);
const [totalSources, setTotalSources] = useState(0);
const [totalVideosFound, setTotalVideosFound] = useState(0);
const currentQueryRef = useRef<string>('');
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,
};
}
+5 -10
View File
@@ -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<T extends { source?: string; sourceName?: string
if (selectedSources.size === 0) {
return videos;
}
return videos.filter(video =>
return videos.filter(video =>
video.source && selectedSources.has(video.source)
);
}, [videos, selectedSources]);
@@ -49,12 +44,12 @@ export function useSourceBadges<T extends { source?: string; sourceName?: string
// Auto-cleanup: remove selected sources that no longer exist
useEffect(() => {
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;
+6 -10
View File
@@ -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<T extends { type_name?: string }>(videos: T[]) {
// Collect and count type badges from videos
const typeBadges = useMemo<TypeBadge[]>(() => {
const typeMap = new Map<string, number>();
videos.forEach(video => {
if (video.type_name && video.type_name.trim()) {
const type = video.type_name.trim();
@@ -42,8 +38,8 @@ export function useTypeBadges<T extends { type_name?: string }>(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<T extends { type_name?: string }>(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;
+18 -1
View File
@@ -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;
+33
View File
@@ -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' });
}
+68
View File
@@ -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<Uint8Array>;
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;
}
}
+11 -27
View File
@@ -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;
+1 -4
View File
@@ -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