diff --git a/app/page.tsx b/app/page.tsx
index 393a59e..24a144f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -17,6 +17,7 @@ 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';
function HomePage() {
const router = useRouter();
@@ -26,6 +27,7 @@ function HomePage() {
const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false);
+ const [currentSortBy, setCurrentSortBy] = useState('default');
// Search stream hook
const {
@@ -38,11 +40,18 @@ function HomePage() {
performSearch,
resetSearch,
loadCachedResults,
+ applySorting,
} = useParallelSearch(
saveToCache,
(q: string) => router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false })
);
+ // Load sort preference on mount
+ useEffect(() => {
+ const settings = settingsStore.getSettings();
+ setCurrentSortBy(settings.sortBy);
+ }, []);
+
// Source badges hook - filters by video source
const {
selectedSources,
@@ -83,7 +92,7 @@ function HomePage() {
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
setHasSearched(true);
- performSearch(searchQuery);
+ performSearch(searchQuery, currentSortBy as any);
};
const handleReset = () => {
@@ -119,7 +128,19 @@ function HomePage() {
视频聚合平台
-
+
+
diff --git a/app/settings/README.md b/app/settings/README.md
new file mode 100644
index 0000000..55f2b33
--- /dev/null
+++ b/app/settings/README.md
@@ -0,0 +1,131 @@
+# Settings Feature Documentation
+
+## Overview
+Comprehensive settings system for KVideo following the Liquid Glass design system principles.
+
+## Features
+
+### 1. Video Source Management
+- **View all sources**: Display default and custom video sources with their URLs
+- **Toggle sources**: Enable/disable sources with animated switches
+- **Reorder sources**: Change priority using up/down arrows
+- **Delete sources**: Remove custom sources (default sources can also be removed)
+- **Add custom sources**: Add new video API sources with validation
+- **Restore defaults**: One-click restore to default source configuration
+
+### 2. Search Result Sorting
+Users can select how search results should be sorted:
+- **默认排序** (Default): Original order
+- **按相关性** (By Relevance): Most relevant results first
+- **延迟低到高** (Latency: Low to High): Fastest responding sources first
+- **发布时间(新到旧)** (Release Date: Newest First)
+- **发布时间(旧到新)** (Release Date: Oldest First)
+- **按评分(高到低)** (By Rating: High to Low)
+- **按名称(A-Z)** (By Name: A-Z)
+- **按名称(Z-A)** (By Name: Z-A)
+
+### 3. Data Management
+
+#### Export Settings
+- Export app configuration to JSON file
+- Options to include:
+ - Search history
+ - Watch history
+ - Source configuration (always included)
+- Downloaded as timestamped JSON file
+
+#### Import Settings
+- Import previously exported configuration
+- Automatic validation
+- Auto-refresh after successful import
+
+#### Reset All Data
+- Clear all settings
+- Clear search history
+- Clear watch history
+- Clear all cookies
+- Clear all cache
+- Restore to factory defaults
+
+## Components
+
+### Core Components
+- `SourceManager.tsx` - Manage video sources with toggle, reorder, delete
+- `AddSourceModal.tsx` - Modal dialog for adding custom sources
+- `ExportModal.tsx` - Export settings with history options
+- `ImportModal.tsx` - Import settings from JSON file
+- `ConfirmDialog.tsx` - Reusable confirmation dialog (updated)
+
+### Store
+- `settings-store.ts` - Settings state management and persistence
+
+## Design System Compliance
+
+All components strictly follow the Liquid Glass design system:
+
+### Visual Elements
+- **Glass Effect**: `backdrop-blur-xl`, `saturate(180%)`
+- **Border Radius**: Only `rounded-[var(--radius-2xl)]` and `rounded-[var(--radius-full)]`
+- **Colors**: CSS variables for theme consistency
+- **Shadows**: `shadow-[var(--shadow-sm)]` and `shadow-[var(--shadow-md)]`
+
+### Animations
+- **Modal Entry/Exit**: Fade + scale transforms with cubic-bezier easing
+- **Switch Toggle**: 0.4s fluid transition with `cubic-bezier(0.2, 0.8, 0.2, 1)`
+- **Button Hover**: Smooth color transitions with brightness changes
+- **List Items**: Staggered animations for visual hierarchy
+
+### Interactive Elements
+- **Checkboxes**: Custom styled with smooth check animation
+- **Switches**: Animated toggle with sliding thumb
+- **Buttons**: Glass morphism with hover lift effects
+- **Inputs**: Glass background with focus ring animation
+
+## Technical Implementation
+
+### State Management
+```typescript
+interface AppSettings {
+ sources: VideoSource[];
+ sortBy: SortOption;
+ searchHistory: boolean;
+ watchHistory: boolean;
+}
+```
+
+### Local Storage Keys
+- `kvideo-settings` - Main settings object
+- `kvideo-search-history` - Search history array
+- `kvideo-watch-history` - Watch history array
+
+### Data Flow
+1. Settings loaded from localStorage on mount
+2. Changes immediately persisted to localStorage
+3. Export creates downloadable JSON blob
+4. Import validates and applies configuration
+5. Reset clears all storage and reloads page
+
+## Accessibility
+
+- Semantic HTML structure
+- ARIA labels on all interactive elements
+- Keyboard navigation support
+- Focus management in modals
+- High contrast mode support
+- Screen reader friendly
+
+## Browser Compatibility
+
+- Modern browsers with CSS backdrop-filter support
+- LocalStorage API
+- File API for export/import
+- Cookie manipulation for reset
+
+## Future Enhancements
+
+- Cloud sync for settings across devices
+- Source health monitoring
+- Advanced filtering options
+- Bulk source import from URL
+- Settings backup scheduling
+- Theme customization
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
new file mode 100644
index 0000000..6409500
--- /dev/null
+++ b/app/settings/page.tsx
@@ -0,0 +1,243 @@
+'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';
+
+export default function SettingsPage() {
+ const router = useRouter();
+ const [sources, setSources] = useState([]);
+ const [sortBy, setSortBy] = useState('default');
+ const [isAddModalOpen, setIsAddModalOpen] = useState(false);
+ const [isExportModalOpen, setIsExportModalOpen] = useState(false);
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
+ const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
+ const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
+ const [showAllSources, setShowAllSources] = useState(false);
+
+ useEffect(() => {
+ const settings = settingsStore.getSettings();
+ setSources(settings.sources);
+ setSortBy(settings.sortBy);
+ }, []);
+
+ const handleSourcesChange = (newSources: VideoSource[]) => {
+ setSources(newSources);
+ settingsStore.saveSettings({ sources: newSources, sortBy, searchHistory: true, watchHistory: true });
+ };
+
+ const handleAddSource = (source: VideoSource) => {
+ const updated = [...sources, source];
+ handleSourcesChange(updated);
+ };
+
+ const handleSortChange = (newSort: SortOption) => {
+ setSortBy(newSort);
+ settingsStore.saveSettings({ sources, sortBy: newSort, searchHistory: true, watchHistory: true });
+ };
+
+ const handleExport = (includeSearchHistory: boolean, includeWatchHistory: boolean) => {
+ const data = settingsStore.exportSettings(includeSearchHistory || includeWatchHistory);
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `kvideo-settings-${Date.now()}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const handleImport = (jsonString: string): boolean => {
+ const success = settingsStore.importSettings(jsonString);
+ if (success) {
+ const settings = settingsStore.getSettings();
+ setSources(settings.sources);
+ setSortBy(settings.sortBy);
+ }
+ return success;
+ };
+
+ const handleRestoreDefaults = () => {
+ const defaults = getDefaultSources();
+ handleSourcesChange(defaults);
+ setIsRestoreDefaultsDialogOpen(false);
+ };
+
+ const handleResetAll = () => {
+ settingsStore.resetToDefaults();
+ setIsResetDialogOpen(false);
+ window.location.reload();
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+ {/* Source Management */}
+
+
+
视频源管理
+
+
+
+
+
+
+ 管理视频来源,调整优先级和启用状态
+
+
+ {sources.length > 10 && (
+
+ )}
+
+
+ {/* Sort Options */}
+
+
搜索结果排序
+
+ 选择搜索结果的默认排序方式
+
+
+ {(Object.keys(sortOptions) as SortOption[]).map((option) => (
+
+ ))}
+
+
+
+ {/* Data Management */}
+
+
数据管理
+
+
+
+
+
+
+
+
+
+
+ {/* Modals */}
+
setIsAddModalOpen(false)}
+ onAdd={handleAddSource}
+ existingIds={sources.map(s => s.id)}
+ />
+
+ setIsExportModalOpen(false)}
+ onExport={handleExport}
+ />
+
+ setIsImportModalOpen(false)}
+ onImport={handleImport}
+ />
+
+ setIsRestoreDefaultsDialogOpen(false)}
+ />
+
+ setIsResetDialogOpen(false)}
+ dangerous
+ />
+
+ );
+}
diff --git a/components/search/SearchForm.tsx b/components/search/SearchForm.tsx
index 39c5927..196a8e1 100644
--- a/components/search/SearchForm.tsx
+++ b/components/search/SearchForm.tsx
@@ -55,7 +55,7 @@ export function SearchForm({
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
- if (query.trim() && !isLoading) {
+ if (query.trim()) {
// Add to search history before searching
addSearch(query.trim());
onSearch(query);
@@ -118,33 +118,36 @@ export function SearchForm({
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
placeholder="搜索电影、电视剧、综艺..."
- className="text-base sm:text-lg pr-24 md:pr-32 truncate"
+ className="text-base sm:text-lg pr-28 sm:pr-36 md:pr-44 truncate"
aria-label="搜索视频内容"
aria-expanded={isDropdownOpen}
aria-controls="search-history-dropdown"
aria-autocomplete="list"
/>
- {query && (
-
+
{/* Search History Dropdown */}
)}
- {/* Overlay - Show on hover (desktop) or when active (mobile) - Simplified for performance */}
- {isActive && (
-
-
- {/* Mobile indicator when active */}
+ {/* Overlay - Show on hover (desktop) or when active (mobile) */}
+
+
+ {/* Mobile indicator when active */}
+ {isActive && (
再次点击播放 →
- {video.type_name && (
-
- {video.type_name}
-
- )}
- {video.vod_year && (
-
-
- {video.vod_year}
-
- )}
-
+ )}
+ {video.type_name && (
+
+ {video.type_name}
+
+ )}
+ {video.vod_year && (
+
+
+ {video.vod_year}
+
+ )}
- )}
+
{/* Info - Fixed height section */}
diff --git a/components/settings/AddSourceModal.tsx b/components/settings/AddSourceModal.tsx
new file mode 100644
index 0000000..b5c266f
--- /dev/null
+++ b/components/settings/AddSourceModal.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import type { VideoSource } from '@/lib/types';
+
+interface AddSourceModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onAdd: (source: VideoSource) => void;
+ existingIds: string[];
+}
+
+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();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ >
+ );
+}
diff --git a/components/settings/ExportModal.tsx b/components/settings/ExportModal.tsx
new file mode 100644
index 0000000..cf04338
--- /dev/null
+++ b/components/settings/ExportModal.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+interface ExportModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onExport: (includeSearchHistory: boolean, includeWatchHistory: boolean) => void;
+}
+
+export function ExportModal({ isOpen, onClose, onExport }: ExportModalProps) {
+ const [includeSearchHistory, setIncludeSearchHistory] = useState(true);
+ const [includeWatchHistory, setIncludeWatchHistory] = useState(true);
+
+ useEffect(() => {
+ if (isOpen) {
+ setIncludeSearchHistory(true);
+ setIncludeWatchHistory(true);
+ }
+ }, [isOpen]);
+
+ const handleExport = () => {
+ onExport(includeSearchHistory, includeWatchHistory);
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+
+
+
+
+ 选择要导出的内容:
+
+
+ {/* Checkbox for Search History */}
+
+
+ {/* Checkbox for Watch History */}
+
+
+
+ 注意:源设置将始终包含在导出中
+
+
+
+
+
+ 取消
+
+
+ 导出
+
+
+
+
+ >
+ );
+}
diff --git a/components/settings/ImportModal.tsx b/components/settings/ImportModal.tsx
new file mode 100644
index 0000000..b80a231
--- /dev/null
+++ b/components/settings/ImportModal.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useState, useRef, useEffect } from 'react';
+
+interface ImportModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onImport: (jsonString: string) => boolean;
+}
+
+export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ if (isOpen) {
+ setError('');
+ setSuccess(false);
+ }
+ }, [isOpen]);
+
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ try {
+ const content = event.target?.result as string;
+ const isValid = onImport(content);
+
+ if (isValid) {
+ setSuccess(true);
+ setError('');
+ setTimeout(() => {
+ onClose();
+ window.location.reload();
+ }, 1500);
+ } else {
+ setError('导入失败:文件格式无效');
+ setSuccess(false);
+ }
+ } catch {
+ setError('导入失败:无法读取文件');
+ setSuccess(false);
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+
+
+
+
+ 选择之前导出的设置文件(JSON 格式)
+
+
+
+
+
fileInputRef.current?.click()}
+ disabled={success}
+ className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border-2 border-dashed border-[var(--glass-border)] text-[var(--text-color)] font-medium hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] hover:border-[var(--accent-color)] disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
+ >
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {success && (
+
+ )}
+
+
+
+ 取消
+
+
+
+ >
+ );
+}
diff --git a/components/settings/SourceManager.tsx b/components/settings/SourceManager.tsx
new file mode 100644
index 0000000..f069aa8
--- /dev/null
+++ b/components/settings/SourceManager.tsx
@@ -0,0 +1,122 @@
+'use client';
+
+import { useState } from 'react';
+import type { VideoSource } from '@/lib/types';
+
+interface SourceManagerProps {
+ sources: VideoSource[];
+ onSourcesChange: (sources: VideoSource[]) => void;
+}
+
+export function SourceManager({ sources, onSourcesChange }: SourceManagerProps) {
+ const [editingId, setEditingId] = useState(null);
+
+ const handleToggle = (id: string) => {
+ const updated = sources.map(s =>
+ s.id === id ? { ...s, enabled: !s.enabled } : s
+ );
+ onSourcesChange(updated);
+ };
+
+ const handleDelete = (id: string) => {
+ const updated = sources.filter(s => s.id !== id);
+ onSourcesChange(updated);
+ };
+
+ const handlePriorityChange = (id: string, direction: 'up' | 'down') => {
+ const currentIndex = sources.findIndex(s => s.id === id);
+ if (currentIndex === -1) return;
+
+ const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
+ if (newIndex < 0 || newIndex >= sources.length) return;
+
+ const updated = [...sources];
+ [updated[currentIndex], updated[newIndex]] = [updated[newIndex], updated[currentIndex]];
+
+ // Update priorities
+ updated.forEach((s, idx) => s.priority = idx + 1);
+ onSourcesChange(updated);
+ };
+
+ return (
+
+ {sources.map((source, index) => (
+
+
+
+ {/* Toggle Switch */}
+
handleToggle(source.id)}
+ className="relative inline-block w-12 h-7 flex-shrink-0 cursor-pointer"
+ aria-label={`Toggle ${source.name}`}
+ >
+
+
+
+
+ {/* Source Info */}
+
+
+ {source.name}
+
+
+ {source.baseUrl}
+
+
+
+
+ {/* Controls */}
+
+ {/* Priority Controls */}
+
handlePriorityChange(source.id, 'up')}
+ disabled={index === 0}
+ 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)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200"
+ aria-label="Move up"
+ >
+
+
+
+
handlePriorityChange(source.id, 'down')}
+ disabled={index === sources.length - 1}
+ 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)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200"
+ aria-label="Move down"
+ >
+
+
+
+ {/* Delete Button */}
+
handleDelete(source.id)}
+ className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all duration-200"
+ aria-label="Delete source"
+ >
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/ui/ConfirmDialog.tsx b/components/ui/ConfirmDialog.tsx
index 2cef467..de3d3ef 100644
--- a/components/ui/ConfirmDialog.tsx
+++ b/components/ui/ConfirmDialog.tsx
@@ -13,6 +13,7 @@ interface ConfirmDialogProps {
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
+ dangerous?: boolean;
}
export function ConfirmDialog({
@@ -24,6 +25,7 @@ export function ConfirmDialog({
confirmText = '确认',
cancelText = '取消',
variant = 'warning',
+ dangerous = false,
}: ConfirmDialogProps) {
const dialogRef = useRef(null);
const cancelButtonRef = useRef(null);
@@ -66,6 +68,9 @@ export function ConfirmDialog({
warning: 'bg-[var(--accent-color)] hover:brightness-110',
info: 'bg-blue-500 hover:bg-blue-600',
};
+
+ // Use dangerous prop to override variant
+ const finalVariant = dangerous ? 'danger' : variant;
return (
<>
@@ -114,7 +119,7 @@ export function ConfirmDialog({
{confirmText}
diff --git a/lib/hooks/useParallelSearch.ts b/lib/hooks/useParallelSearch.ts
index c4f6422..2fdc500 100644
--- a/lib/hooks/useParallelSearch.ts
+++ b/lib/hooks/useParallelSearch.ts
@@ -3,6 +3,8 @@
import { useState, useRef, useCallback } from 'react';
import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names';
import { calculateRelevanceScore } from '@/lib/utils/search';
+import { sortVideos } from '@/lib/utils/sort';
+import type { SortOption } from '@/lib/store/settings-store';
interface Video {
vod_id: string;
@@ -28,9 +30,10 @@ export interface ParallelSearchResult {
completedSources: number;
totalSources: number;
totalVideosFound: number;
- performSearch: (query: string) => Promise;
+ performSearch: (query: string, sortBy?: SortOption) => Promise;
resetSearch: () => void;
loadCachedResults: (results: Video[], sources: any[]) => void;
+ applySorting: (sortBy: SortOption) => void;
}
export function useParallelSearch(
@@ -50,7 +53,7 @@ export function useParallelSearch(
/**
* Perform parallel search with streaming results
*/
- const performSearch = useCallback(async (searchQuery: string) => {
+ const performSearch = useCallback(async (searchQuery: string, sortBy: SortOption = 'default') => {
if (!searchQuery.trim()) return;
// Abort any ongoing search
@@ -183,13 +186,17 @@ export function useParallelSearch(
console.log('[useParallelSearch] Available sources:', sources);
- // Cache results - wait a bit for current results to be in state
- setTimeout(() => {
- setResults((currentResults) => {
- onCacheUpdate(searchQuery, currentResults, sources);
- return currentResults;
- });
- }, 100);
+ // 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);
@@ -236,6 +243,13 @@ export function useParallelSearch(
setTotalVideosFound(cachedResults.length);
}, []);
+ /**
+ * Apply sorting to current results
+ */
+ const applySorting = useCallback((sortBy: SortOption) => {
+ setResults((currentResults) => sortVideos(currentResults, sortBy));
+ }, []);
+
return {
loading,
results,
@@ -246,5 +260,6 @@ export function useParallelSearch(
performSearch,
resetSearch,
loadCachedResults,
+ applySorting,
};
}
diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts
new file mode 100644
index 0000000..cb34591
--- /dev/null
+++ b/lib/store/settings-store.ts
@@ -0,0 +1,180 @@
+/**
+ * Settings Store - Manages application settings and preferences
+ */
+
+import type { VideoSource } from '@/lib/types';
+
+export type SortOption =
+ | 'default'
+ | 'relevance'
+ | 'latency-asc'
+ | 'date-desc'
+ | 'date-asc'
+ | 'rating-desc'
+ | 'name-asc'
+ | 'name-desc';
+
+export interface AppSettings {
+ sources: VideoSource[];
+ sortBy: SortOption;
+ searchHistory: boolean;
+ watchHistory: boolean;
+}
+
+const SETTINGS_KEY = 'kvideo-settings';
+const SEARCH_HISTORY_KEY = 'kvideo-search-history';
+const WATCH_HISTORY_KEY = 'kvideo-watch-history';
+
+export const sortOptions: Record = {
+ 'default': '默认排序',
+ 'relevance': '按相关性',
+ 'latency-asc': '延迟低到高',
+ 'date-desc': '发布时间(新到旧)',
+ 'date-asc': '发布时间(旧到新)',
+ 'rating-desc': '按评分(高到低)',
+ 'name-asc': '按名称(A-Z)',
+ 'name-desc': '按名称(Z-A)',
+};
+
+export const getDefaultSources = (): VideoSource[] => {
+ // Import from video-sources to get all 38 default sources
+ return [
+ { id: 'feifan', name: '非凡资源', baseUrl: 'http://ffzy5.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 1 },
+ { id: 'wolong', name: '卧龙资源', baseUrl: 'https://wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 2 },
+ { id: 'zuida', name: '最大资源', baseUrl: 'https://api.zuidapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 3 },
+ { id: 'baiduyun', name: '百度云资源', baseUrl: 'https://api.apibdzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 4 },
+ { id: 'baofeng', name: '暴风资源', baseUrl: 'https://bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 5 },
+ { id: 'jisu', name: '极速资源', baseUrl: 'https://jszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 6 },
+ { id: 'tianya', name: '天涯资源', baseUrl: 'https://tyyszy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 7 },
+ { id: 'wujin', name: '无尽资源', baseUrl: 'https://api.wujinapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 8 },
+ { id: 'modu', name: '魔都资源', baseUrl: 'https://www.mdzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 9 },
+ { id: 'sanliuling', name: '360资源', baseUrl: 'https://360zy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 10 },
+ { id: 'dytt', name: '电影天堂', baseUrl: 'http://caiji.dyttzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 11 },
+ { id: 'ruyi', name: '如意资源', baseUrl: 'https://cj.rycjapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 12 },
+ { id: 'wangwang', name: '旺旺资源', baseUrl: 'https://wwzy.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 13 },
+ { id: 'hongniu', name: '红牛资源', baseUrl: 'https://www.hongniuzy2.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 14 },
+ { id: 'guangsu', name: '光速资源', baseUrl: 'https://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 15 },
+ { id: 'ikun', name: 'iKun资源', baseUrl: 'https://ikunzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 16 },
+ { id: 'youku', name: '优酷资源', baseUrl: 'https://api.ukuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 17 },
+ { id: 'huya', name: '虎牙资源', baseUrl: 'https://www.huyaapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 18 },
+ { id: 'xinlang', name: '新浪资源', baseUrl: 'http://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 19 },
+ { id: 'lezi', name: '乐子资源', baseUrl: 'https://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 20 },
+ { id: 'haihua', name: '海豚资源', baseUrl: 'https://hhzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 21 },
+ { id: 'jiangyu', name: '鲸鱼资源', baseUrl: 'https://jyzyapi.com/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 22 },
+ { id: 'yilingba', name: '1080资源', baseUrl: 'https://api.1080zyku.com/inc/api_mac10.php', searchPath: '', detailPath: '', enabled: true, priority: 23 },
+ { id: 'aidan', name: '爱蛋资源', baseUrl: 'https://lovedan.net/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 24 },
+ { id: 'leba', name: '乐播资源', baseUrl: 'https://lbapi9.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 25 },
+ { id: 'moduzy', name: '魔都影视', baseUrl: 'https://www.moduzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 26 },
+ { id: 'feifanapi', name: '非凡API', baseUrl: 'https://api.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 27 },
+ { id: 'feifancj', name: '非凡采集', baseUrl: 'http://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 28 },
+ { id: 'feifancj2', name: '非凡采集HTTPS', baseUrl: 'https://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 29 },
+ { id: 'feifan1', name: '非凡线路1', baseUrl: 'http://ffzy1.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 30 },
+ { id: 'wolong2', name: '卧龙采集', baseUrl: 'https://collect.wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 31 },
+ { id: 'baofeng2', name: '暴风APP', baseUrl: 'https://app.bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 32 },
+ { id: 'wujin2', name: '无尽ME', baseUrl: 'https://api.wujinapi.me/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 33 },
+ { id: 'tianyazy', name: '天涯海角', baseUrl: 'https://tyyszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 34 },
+ { id: 'guangsu2', name: '光速HTTP', baseUrl: 'http://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 35 },
+ { id: 'xinlang2', name: '新浪HTTPS', baseUrl: 'https://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 36 },
+ { id: 'yilingba2', name: '1080JSON', baseUrl: 'https://api.1080zyku.com/inc/apijson.php', searchPath: '', detailPath: '', enabled: true, priority: 37 },
+ { id: 'lezi2', name: '乐子HTTP', baseUrl: 'http://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 38 },
+ ];
+};
+
+export const settingsStore = {
+ getSettings(): AppSettings {
+ if (typeof window === 'undefined') {
+ return {
+ sources: getDefaultSources(),
+ sortBy: 'default',
+ searchHistory: true,
+ watchHistory: true,
+ };
+ }
+
+ const stored = localStorage.getItem(SETTINGS_KEY);
+ if (!stored) {
+ return {
+ sources: getDefaultSources(),
+ sortBy: 'default',
+ searchHistory: true,
+ watchHistory: true,
+ };
+ }
+
+ try {
+ return JSON.parse(stored);
+ } catch {
+ return {
+ sources: getDefaultSources(),
+ sortBy: 'default',
+ searchHistory: true,
+ watchHistory: true,
+ };
+ }
+ },
+
+ saveSettings(settings: AppSettings): void {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
+ }
+ },
+
+ exportSettings(includeHistory: boolean = true): string {
+ const settings = this.getSettings();
+ const exportData: Record = {
+ settings,
+ };
+
+ if (includeHistory && typeof window !== 'undefined') {
+ const searchHistory = localStorage.getItem(SEARCH_HISTORY_KEY);
+ const watchHistory = localStorage.getItem(WATCH_HISTORY_KEY);
+
+ if (searchHistory) exportData.searchHistory = JSON.parse(searchHistory);
+ if (watchHistory) exportData.watchHistory = JSON.parse(watchHistory);
+ }
+
+ return JSON.stringify(exportData, null, 2);
+ },
+
+ importSettings(jsonString: string): boolean {
+ try {
+ const data = JSON.parse(jsonString);
+
+ if (data.settings) {
+ this.saveSettings(data.settings);
+ }
+
+ if (data.searchHistory && typeof window !== 'undefined') {
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(data.searchHistory));
+ }
+
+ if (data.watchHistory && typeof window !== 'undefined') {
+ localStorage.setItem(WATCH_HISTORY_KEY, JSON.stringify(data.watchHistory));
+ }
+
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ resetToDefaults(): void {
+ if (typeof window !== 'undefined') {
+ localStorage.removeItem(SETTINGS_KEY);
+ localStorage.removeItem(SEARCH_HISTORY_KEY);
+ localStorage.removeItem(WATCH_HISTORY_KEY);
+
+ // Clear all cookies
+ document.cookie.split(";").forEach((c) => {
+ document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
+ });
+
+ // Clear cache if available
+ if ('caches' in window) {
+ caches.keys().then((names) => {
+ names.forEach(name => caches.delete(name));
+ });
+ }
+ }
+ },
+};
diff --git a/lib/utils/sort.ts b/lib/utils/sort.ts
new file mode 100644
index 0000000..5957f26
--- /dev/null
+++ b/lib/utils/sort.ts
@@ -0,0 +1,96 @@
+/**
+ * Sort utility functions for search results
+ */
+
+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;
+}
+
+export function sortVideos(videos: Video[], sortBy: SortOption): Video[] {
+ const sorted = [...videos];
+
+ switch (sortBy) {
+ case 'relevance':
+ // Sort by relevance score (highest first)
+ return sorted.sort((a, b) => {
+ const scoreA = (a as any).relevanceScore || 0;
+ const scoreB = (b as any).relevanceScore || 0;
+ return scoreB - scoreA;
+ });
+
+ case 'latency-asc':
+ // Sort by latency (lowest first)
+ return sorted.sort((a, b) => {
+ const latencyA = a.latency || 99999;
+ const latencyB = b.latency || 99999;
+ return latencyA - latencyB;
+ });
+
+ case 'date-desc':
+ // Sort by year (newest first)
+ return sorted.sort((a, b) => {
+ const yearA = parseInt(a.vod_year || '0');
+ const yearB = parseInt(b.vod_year || '0');
+ return yearB - yearA;
+ });
+
+ case 'date-asc':
+ // Sort by year (oldest first)
+ return sorted.sort((a, b) => {
+ const yearA = parseInt(a.vod_year || '0');
+ 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) => {
+ const ratingA = (a as any).vod_score || 0;
+ 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;
+ });
+ }
+}