diff --git a/app/globals.css b/app/globals.css index 7b6106e..6217178 100644 --- a/app/globals.css +++ b/app/globals.css @@ -314,4 +314,33 @@ body.dark, color: white; } +/* Animations for History Components */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} diff --git a/app/page.tsx b/app/page.tsx index e439b65..ea2a6dd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,6 +12,7 @@ import { NoResults } from '@/components/search/NoResults'; import { ResultsHeader } from '@/components/search/ResultsHeader'; import { TypeBadges } from '@/components/search/TypeBadges'; 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'; @@ -167,6 +168,9 @@ function HomePage() { )} + + {/* Watch History Sidebar */} + ); } diff --git a/app/player/page.tsx b/app/player/page.tsx index 10c84dd..98f73ec 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense } from 'react'; +import { Suspense, useEffect } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { Button } from '@/components/ui/Button'; import { ThemeSwitcher } from '@/components/ThemeSwitcher'; @@ -10,11 +10,14 @@ import { VideoMetadata } from '@/components/player/VideoMetadata'; import { EpisodeList } from '@/components/player/EpisodeList'; 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 Image from 'next/image'; function PlayerContent() { const searchParams = useSearchParams(); const router = useRouter(); + const { addToHistory } = useHistoryStore(); const videoId = searchParams.get('id'); const source = searchParams.get('source'); @@ -39,6 +42,30 @@ function PlayerContent() { fetchVideoDetails, } = useVideoPlayer(videoId, source, episodeParam); + // Add initial history entry when video data is loaded + useEffect(() => { + if (videoData && playUrl && videoId) { + // Map episodes to include index + const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({ + name: ep.name || `第${idx + 1}集`, + url: ep.url, + index: idx, + })) || []; + + addToHistory( + videoId, + videoData.vod_name || title || '未知视频', + playUrl, + currentEpisode, + source, + 0, // Initial playback position + 0, // Will be updated by VideoPlayer + videoData.vod_pic, + mappedEpisodes + ); + } + }, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]); + const handleEpisodeClick = (episode: any, index: number) => { setCurrentEpisode(index); setPlayUrl(episode.url); @@ -124,6 +151,9 @@ function PlayerContent() { )} + + {/* Watch History Sidebar */} + ); } diff --git a/components/history/WatchHistorySidebar.tsx b/components/history/WatchHistorySidebar.tsx new file mode 100644 index 0000000..99ab157 --- /dev/null +++ b/components/history/WatchHistorySidebar.tsx @@ -0,0 +1,214 @@ +/** + * Watch History Sidebar Component + * 观看历史侧边栏组件 + */ + +'use client'; + +import { useState } from 'react'; +import { useHistoryStore } from '@/lib/store/history-store'; +import { Icons } from '@/components/ui/Icon'; +import { Button } from '@/components/ui/Button'; +import Image from 'next/image'; + +export function WatchHistorySidebar() { + const [isOpen, setIsOpen] = useState(false); + const { viewingHistory, removeFromHistory, clearHistory } = useHistoryStore(); + + 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 = (timestamp: number): string => { + const date = new Date(timestamp); + 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 = (item: any): string => { + const params = new URLSearchParams({ + id: item.videoId.toString(), + source: item.source, + title: item.title, + episode: item.episodeIndex.toString(), + }); + return `/player?${params.toString()}`; + }; + + const handleItemClick = (item: any, event: React.MouseEvent) => { + // Middle mouse or Ctrl/Cmd+click opens in new tab + if (event.button === 1 || event.ctrlKey || event.metaKey) { + event.preventDefault(); + window.open(getVideoUrl(item), '_blank'); + return; + } + }; + + return ( + <> + {/* Toggle Button */} + + + {/* Backdrop */} + {isOpen && ( +
setIsOpen(false)} + /> + )} + + {/* Sidebar */} + + + ); +} diff --git a/components/player/VideoPlayer.tsx b/components/player/VideoPlayer.tsx index 5b03311..bade193 100644 --- a/components/player/VideoPlayer.tsx +++ b/components/player/VideoPlayer.tsx @@ -1,9 +1,11 @@ 'use client'; -import { useRef, useState } from 'react'; +import { useRef, useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Icons } from '@/components/ui/Icon'; +import { useHistoryStore } from '@/lib/store/history-store'; interface VideoPlayerProps { playUrl: string; @@ -16,6 +18,80 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP const videoRef = useRef(null); const [videoError, setVideoError] = useState(''); const [isVideoLoading, setIsVideoLoading] = useState(false); + const searchParams = useSearchParams(); + const { addToHistory } = useHistoryStore(); + + // Get video metadata from URL params + const source = searchParams.get('source') || ''; + const title = searchParams.get('title') || '未知视频'; + + // Save progress to history periodically + useEffect(() => { + if (!videoRef.current || !videoId || !playUrl) return; + + const video = videoRef.current; + let lastSavedTime = 0; + + const updateProgress = () => { + if (video && video.duration > 0) { + const position = video.currentTime; + const duration = video.duration; + + // Only save if we have meaningful progress (more than 1 second) + // and if at least 5 seconds have passed since last save + if (position > 1 && Math.abs(position - lastSavedTime) >= 5) { + lastSavedTime = position; + console.log(`[Watch History] Saving progress: ${position.toFixed(1)}s / ${duration.toFixed(1)}s`); + + addToHistory( + videoId, + title, + playUrl, + currentEpisode, + source, + position, + duration, + undefined, // poster - updated from player page + [] // episodes - updated from player page + ); + } + } + }; + + // Update progress on time update (throttled by the 5 second check) + const handleTimeUpdate = () => updateProgress(); + + // Also update on pause + const handlePause = () => { + if (video && video.duration > 0) { + console.log('[Watch History] Saving on pause'); + updateProgress(); + } + }; + + // Update when leaving the page + const handleBeforeUnload = () => { + if (video && video.duration > 0) { + console.log('[Watch History] Saving before unload'); + updateProgress(); + } + }; + + video.addEventListener('timeupdate', handleTimeUpdate); + video.addEventListener('pause', handlePause); + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + video.removeEventListener('timeupdate', handleTimeUpdate); + video.removeEventListener('pause', handlePause); + window.removeEventListener('beforeunload', handleBeforeUnload); + // Save progress one last time on unmount + if (video && video.duration > 0) { + console.log('[Watch History] Saving on unmount'); + updateProgress(); + } + }; + }, [videoId, playUrl, currentEpisode, source, title, addToHistory]); const handleVideoError = (e: React.SyntheticEvent) => { const video = e.currentTarget; diff --git a/components/search/SearchForm.tsx b/components/search/SearchForm.tsx index 5413996..83132d1 100644 --- a/components/search/SearchForm.tsx +++ b/components/search/SearchForm.tsx @@ -1,10 +1,12 @@ 'use client'; -import { useState, FormEvent, useEffect } from 'react'; +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 { useSearchHistoryStore } from '@/lib/store/search-history-store'; interface SearchFormProps { onSearch: (query: string) => void; @@ -32,6 +34,10 @@ export function SearchForm({ searchStage = 'searching', }: SearchFormProps) { const [query, setQuery] = useState(initialQuery); + const [showHistory, setShowHistory] = useState(false); + const [inputRect, setInputRect] = useState(null); + const inputRef = useRef(null); + const { addSearchHistory } = useSearchHistoryStore(); // Update query when initialQuery changes useEffect(() => { @@ -41,7 +47,9 @@ export function SearchForm({ const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (query.trim() && !isLoading) { + addSearchHistory(query.trim()); onSearch(query); + setShowHistory(false); } }; @@ -50,15 +58,31 @@ export function SearchForm({ if (onClear) { onClear(); } + setShowHistory(false); + }; + + const handleInputFocus = () => { + if (inputRef.current) { + setInputRect(inputRef.current.getBoundingClientRect()); + setShowHistory(true); + } + }; + + const handleHistorySelect = (selectedQuery: string) => { + setQuery(selectedQuery); + setShowHistory(false); + onSearch(selectedQuery); }; return (
setQuery(e.target.value)} + onFocus={handleInputFocus} placeholder="搜索电影、电视剧、综艺..." className="text-lg pr-32" /> @@ -83,6 +107,14 @@ export function SearchForm({
+ + {/* Search History Dropdown */} + setShowHistory(false)} + inputRect={inputRect} + /> {/* Loading Animation */} {isLoading && ( diff --git a/components/search/SearchHistoryDropdown.tsx b/components/search/SearchHistoryDropdown.tsx new file mode 100644 index 0000000..b6c5346 --- /dev/null +++ b/components/search/SearchHistoryDropdown.tsx @@ -0,0 +1,120 @@ +/** + * Search History Dropdown Component + * 搜索历史下拉组件 + */ + +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useSearchHistoryStore } from '@/lib/store/search-history-store'; +import { Icons } from '@/components/ui/Icon'; + +interface SearchHistoryDropdownProps { + isVisible: boolean; + onSelect: (query: string) => void; + onClose: () => void; + inputRect: DOMRect | null; +} + +export function SearchHistoryDropdown({ + isVisible, + onSelect, + onClose, + inputRect, +}: SearchHistoryDropdownProps) { + const { searchHistory, removeSearchHistory, clearSearchHistory } = useSearchHistoryStore(); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + if (isVisible) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isVisible, onClose]); + + if (!isVisible || searchHistory.length === 0) return null; + + const style = inputRect + ? { + position: 'fixed' as const, + top: `${inputRect.bottom + 8}px`, + left: `${inputRect.left}px`, + width: `${inputRect.width}px`, + } + : {}; + + const handleItemClick = (query: string, event: React.MouseEvent) => { + // Check if middle mouse button (opens in new tab) + if (event.button === 1 || event.ctrlKey || event.metaKey) { + event.preventDefault(); + window.open(`/?q=${encodeURIComponent(query)}`, '_blank'); + return; + } + onSelect(query); + }; + + return ( +
+
+ + 搜索历史 + + +
+ +
+ {searchHistory.map((item) => ( +
+ + { + e.preventDefault(); + handleItemClick(item.query, e as any); + }} + onAuxClick={(e) => handleItemClick(item.query, e as any)} + className="flex-1 text-sm text-[var(--text-color)] hover:text-[var(--accent-color)] transition-colors truncate" + title={item.query} + > + {item.query} + + +
+ ))} +
+
+ ); +} diff --git a/components/ui/Icon.tsx b/components/ui/Icon.tsx index 30919c0..96ffbbe 100644 --- a/components/ui/Icon.tsx +++ b/components/ui/Icon.tsx @@ -326,4 +326,50 @@ export const Icons = { ), + + Clock: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + History: ({ className = "", size = 24 }: IconProps) => ( + + + + ), + + Trash: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), }; diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx index d1ccac5..013cb29 100644 --- a/components/ui/Input.tsx +++ b/components/ui/Input.tsx @@ -1,44 +1,49 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; interface InputProps extends React.InputHTMLAttributes { label?: string; error?: string; } -export function Input({ label, error, className = '', ...props }: InputProps) { - return ( -
- {label && ( - - )} - - {error && ( -

{error}

- )} -
- ); -} +export const Input = forwardRef( + ({ label, error, className = '', ...props }, ref) => { + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/lib/store/search-history-store.ts b/lib/store/search-history-store.ts new file mode 100644 index 0000000..d4cc165 --- /dev/null +++ b/lib/store/search-history-store.ts @@ -0,0 +1,70 @@ +/** + * Search History Store + * 搜索历史记录存储 + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +const MAX_SEARCH_HISTORY = 20; + +export interface SearchHistoryItem { + query: string; + timestamp: number; +} + +interface SearchHistoryStore { + searchHistory: SearchHistoryItem[]; + + addSearchHistory: (query: string) => void; + removeSearchHistory: (query: string) => void; + clearSearchHistory: () => void; + getSearchHistory: () => SearchHistoryItem[]; +} + +export const useSearchHistoryStore = create()( + persist( + (set, get) => ({ + searchHistory: [], + + addSearchHistory: (query: string) => { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return; + + set((state) => { + // 移除已存在的相同搜索 + const filtered = state.searchHistory.filter( + (item) => item.query !== trimmedQuery + ); + + // 添加新搜索到顶部 + const newHistory = [ + { query: trimmedQuery, timestamp: Date.now() }, + ...filtered, + ].slice(0, MAX_SEARCH_HISTORY); + + return { searchHistory: newHistory }; + }); + }, + + removeSearchHistory: (query: string) => { + set((state) => ({ + searchHistory: state.searchHistory.filter( + (item) => item.query !== query + ), + })); + }, + + clearSearchHistory: () => { + set({ searchHistory: [] }); + }, + + getSearchHistory: () => { + return get().searchHistory; + }, + }), + { + name: 'kvideo-search-history', + } + ) +); diff --git a/next.config.ts b/next.config.ts index 62d54c2..7392810 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { remotePatterns: [ + // Douban images { protocol: 'https', hostname: 'img3.doubanio.com', @@ -19,6 +20,23 @@ const nextConfig: NextConfig = { protocol: 'https', hostname: 'img9.doubanio.com', }, + // Video source images - allow all subdomains with wildcards + { + protocol: 'http', + hostname: '**.com', + }, + { + protocol: 'https', + hostname: '**.com', + }, + { + protocol: 'http', + hostname: '**.cn', + }, + { + protocol: 'https', + hostname: '**.cn', + }, ], }, };