mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-18 02:03:43 +08:00
feat: Implement comprehensive search functionality, enhance video player controls for desktop and mobile, and refactor settings and history UI.
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user