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 (