mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: implement secret mode with separate history and favorites, and dedicated player links
This commit is contained in:
+6
-7
@@ -9,8 +9,7 @@ import { EpisodeList } from '@/components/player/EpisodeList';
|
||||
import { PlayerError } from '@/components/player/PlayerError';
|
||||
import { SourceSelector, SourceInfo } from '@/components/player/SourceSelector';
|
||||
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
|
||||
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
||||
import { PlayerNavbar } from '@/components/player/PlayerNavbar';
|
||||
@@ -20,7 +19,8 @@ import Image from 'next/image';
|
||||
function PlayerContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { addToHistory } = useHistoryStore();
|
||||
const isSecret = searchParams.get('secret') === '1';
|
||||
const { addToHistory } = useHistory(isSecret);
|
||||
|
||||
const videoId = searchParams.get('id');
|
||||
const source = searchParams.get('source');
|
||||
@@ -162,6 +162,7 @@ function PlayerContent() {
|
||||
totalEpisodes={videoData?.episodes?.length || 1}
|
||||
onNextEpisode={handleNextEpisode}
|
||||
isReversed={isReversed}
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
<VideoMetadata
|
||||
videoData={videoData}
|
||||
@@ -180,6 +181,7 @@ function PlayerContent() {
|
||||
type={videoData.type_name}
|
||||
year={videoData.vod_year}
|
||||
size={20}
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
<span className="text-sm text-[var(--text-color-secondary)]">
|
||||
收藏这个视频
|
||||
@@ -227,10 +229,7 @@ function PlayerContent() {
|
||||
</main>
|
||||
|
||||
{/* Favorites Sidebar - Left */}
|
||||
<FavoritesSidebar />
|
||||
|
||||
{/* Watch History Sidebar - Right */}
|
||||
<WatchHistorySidebar />
|
||||
<FavoritesSidebar isSecret={isSecret} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -52,6 +52,7 @@ function SecretHomePage() {
|
||||
results={results}
|
||||
availableSources={availableSources}
|
||||
loading={loading}
|
||||
isSecret={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -67,7 +68,7 @@ function SecretHomePage() {
|
||||
</main>
|
||||
|
||||
{/* Favorites Sidebar - Left */}
|
||||
<FavoritesSidebar />
|
||||
<FavoritesSidebar isSecret={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback, useState, useEffect } from 'react';
|
||||
import { useFavoritesStore } from '@/lib/store/favorites-store';
|
||||
import { useFavorites } from '@/lib/store/favorites-store';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
@@ -21,6 +21,7 @@ interface FavoriteButtonProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
showTooltip?: boolean;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export const FavoriteButton = memo<FavoriteButtonProps>(({
|
||||
@@ -35,8 +36,9 @@ export const FavoriteButton = memo<FavoriteButtonProps>(({
|
||||
className = '',
|
||||
size = 20,
|
||||
showTooltip = true,
|
||||
isSecret = false,
|
||||
}) => {
|
||||
const { isFavorite, toggleFavorite } = useFavoritesStore();
|
||||
const { isFavorite, toggleFavorite } = useFavorites(isSecret);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [isFav, setIsFav] = useState(false);
|
||||
|
||||
|
||||
@@ -10,15 +10,19 @@ import type { FavoriteItem } from '@/lib/types';
|
||||
interface FavoritesItemProps {
|
||||
item: FavoriteItem;
|
||||
onRemove: () => void;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export function FavoritesItem({ item, onRemove }: FavoritesItemProps) {
|
||||
export function FavoritesItem({ item, onRemove, isSecret = false }: FavoritesItemProps) {
|
||||
const getVideoUrl = (): string => {
|
||||
const params = new URLSearchParams({
|
||||
id: item.videoId.toString(),
|
||||
source: item.source,
|
||||
title: item.title,
|
||||
});
|
||||
if (isSecret) {
|
||||
params.set('secret', '1');
|
||||
}
|
||||
return `/player?${params.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ import { FavoritesEmptyState } from './FavoritesEmptyState';
|
||||
interface FavoritesListProps {
|
||||
favorites: FavoriteItem[];
|
||||
onRemove: (videoId: string | number, source: string) => void;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export function FavoritesList({ favorites, onRemove }: FavoritesListProps) {
|
||||
export function FavoritesList({ favorites, onRemove, isSecret = false }: FavoritesListProps) {
|
||||
if (favorites.length === 0) {
|
||||
return <FavoritesEmptyState />;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export function FavoritesList({ favorites, onRemove }: FavoritesListProps) {
|
||||
key={`${item.source}:${item.videoId}`}
|
||||
item={item}
|
||||
onRemove={() => onRemove(item.videoId, item.source)}
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useFavoritesStore } from '@/lib/store/favorites-store';
|
||||
import { useFavorites } from '@/lib/store/favorites-store';
|
||||
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { FavoritesHeader } from './FavoritesHeader';
|
||||
@@ -14,7 +15,7 @@ import { FavoritesList } from './FavoritesList';
|
||||
import { FavoritesFooter } from './FavoritesFooter';
|
||||
import { trapFocus } from '@/lib/accessibility/focus-management';
|
||||
|
||||
export function FavoritesSidebar() {
|
||||
export function FavoritesSidebar({ isSecret = false }: { isSecret?: boolean }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
isOpen: boolean;
|
||||
@@ -22,7 +23,7 @@ export function FavoritesSidebar() {
|
||||
source?: string;
|
||||
isClearAll?: boolean;
|
||||
}>({ isOpen: false });
|
||||
const { favorites, removeFavorite, clearFavorites } = useFavoritesStore();
|
||||
const { favorites, removeFavorite, clearFavorites } = useFavorites(isSecret);
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
const cleanupFocusTrapRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -115,6 +116,7 @@ export function FavoritesSidebar() {
|
||||
<FavoritesList
|
||||
favorites={favorites}
|
||||
onRemove={handleDeleteItem}
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
|
||||
<FavoritesFooter
|
||||
@@ -123,6 +125,9 @@ export function FavoritesSidebar() {
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Watch History Sidebar - Right side */}
|
||||
<WatchHistorySidebar isSecret={isSecret} />
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
|
||||
@@ -8,39 +8,25 @@ import { Icons } from '@/components/ui/Icon';
|
||||
import { formatTime, formatDate } from '@/lib/utils/format-utils';
|
||||
import { PosterImage } from './PosterImage';
|
||||
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
||||
import type { VideoHistoryItem } from '@/lib/types';
|
||||
|
||||
interface HistoryItemProps {
|
||||
videoId: string | number;
|
||||
source: string;
|
||||
title: string;
|
||||
poster?: string;
|
||||
episodeIndex: number;
|
||||
episodes?: Array<{ name: string }>;
|
||||
playbackPosition: number;
|
||||
duration: number;
|
||||
timestamp: number;
|
||||
item: VideoHistoryItem;
|
||||
onRemove: () => void;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export function HistoryItem({
|
||||
videoId,
|
||||
source,
|
||||
title,
|
||||
poster,
|
||||
episodeIndex,
|
||||
episodes,
|
||||
playbackPosition,
|
||||
duration,
|
||||
timestamp,
|
||||
onRemove,
|
||||
}: HistoryItemProps) {
|
||||
export function HistoryItem({ item, onRemove, isSecret = false }: HistoryItemProps) {
|
||||
const getVideoUrl = (): string => {
|
||||
const params = new URLSearchParams({
|
||||
id: videoId.toString(),
|
||||
source,
|
||||
title,
|
||||
episode: episodeIndex.toString(),
|
||||
id: item.videoId.toString(),
|
||||
source: item.source,
|
||||
title: item.title,
|
||||
episode: item.episodeIndex.toString(),
|
||||
});
|
||||
if (isSecret) {
|
||||
params.set('secret', '1');
|
||||
}
|
||||
return `/player?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -53,9 +39,9 @@ export function HistoryItem({
|
||||
}
|
||||
};
|
||||
|
||||
const progress = (playbackPosition / duration) * 100;
|
||||
const episodeText = episodes && episodes.length > 0
|
||||
? episodes[episodeIndex]?.name || `第${episodeIndex + 1}集`
|
||||
const progress = (item.playbackPosition / item.duration) * 100;
|
||||
const episodeText = item.episodes && item.episodes.length > 0
|
||||
? item.episodes[item.episodeIndex]?.name || `第${item.episodeIndex + 1}集`
|
||||
: '';
|
||||
|
||||
return (
|
||||
@@ -74,12 +60,12 @@ export function HistoryItem({
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Poster */}
|
||||
<PosterImage poster={poster} title={title} progress={progress} />
|
||||
<PosterImage poster={item.poster} title={item.title} progress={progress} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] truncate group-hover:text-[var(--accent-color)] transition-colors mb-1">
|
||||
{title}
|
||||
{item.title}
|
||||
</h3>
|
||||
{episodeText && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mb-1">
|
||||
@@ -87,8 +73,8 @@ export function HistoryItem({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
|
||||
<span>{formatTime(playbackPosition)} / {formatTime(duration)}</span>
|
||||
<span>{formatDate(timestamp)}</span>
|
||||
<span>{formatTime(item.playbackPosition)} / {formatTime(item.duration)}</span>
|
||||
<span>{formatDate(item.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,14 +82,15 @@ export function HistoryItem({
|
||||
<div className="flex flex-col gap-1 self-start opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* Favorite button */}
|
||||
<FavoriteButton
|
||||
videoId={videoId}
|
||||
source={source}
|
||||
title={title}
|
||||
poster={poster}
|
||||
videoId={item.videoId}
|
||||
source={item.source}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
remarks={episodeText}
|
||||
size={14}
|
||||
className="!p-1.5 !bg-transparent !border-0 !shadow-none hover:!bg-[var(--glass-bg)]"
|
||||
showTooltip={false}
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
|
||||
{/* Delete button */}
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { VideoHistoryItem } from '@/lib/types';
|
||||
interface HistoryListProps {
|
||||
history: VideoHistoryItem[];
|
||||
onRemove: (videoId: string | number, source: string) => void;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export function HistoryList({ history, onRemove }: HistoryListProps) {
|
||||
export function HistoryList({ history, onRemove, isSecret = false }: HistoryListProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
@@ -20,16 +21,9 @@ export function HistoryList({ history, onRemove }: HistoryListProps) {
|
||||
{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}
|
||||
item={item}
|
||||
onRemove={() => onRemove(item.videoId, item.source)}
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { HistoryHeader } from './HistoryHeader';
|
||||
@@ -14,7 +14,7 @@ import { HistoryList } from './HistoryList';
|
||||
import { HistoryFooter } from './HistoryFooter';
|
||||
import { trapFocus } from '@/lib/accessibility/focus-management';
|
||||
|
||||
export function WatchHistorySidebar() {
|
||||
export function WatchHistorySidebar({ isSecret = false }: { isSecret?: boolean }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
isOpen: boolean;
|
||||
@@ -22,7 +22,7 @@ export function WatchHistorySidebar() {
|
||||
source?: string;
|
||||
isClearAll?: boolean;
|
||||
}>({ isOpen: false });
|
||||
const { viewingHistory, removeFromHistory, clearHistory } = useHistoryStore();
|
||||
const { viewingHistory, removeFromHistory, clearHistory } = useHistory(isSecret);
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
const cleanupFocusTrapRef = useRef<(() => void) | null>(null);
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ interface SearchResultsProps {
|
||||
results: Video[];
|
||||
availableSources: SourceBadge[];
|
||||
loading: boolean;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export function SearchResults({ results, availableSources, loading }: SearchResultsProps) {
|
||||
export function SearchResults({ results, availableSources, loading, isSecret = false }: SearchResultsProps) {
|
||||
// Source badges hook - filters by video source
|
||||
const {
|
||||
selectedSources,
|
||||
@@ -61,7 +62,7 @@ export function SearchResults({ results, availableSources, loading }: SearchResu
|
||||
)}
|
||||
|
||||
{/* Display filtered videos (both source and type filters applied) */}
|
||||
<VideoGrid videos={finalFilteredVideos} />
|
||||
<VideoGrid videos={finalFilteredVideos} isSecret={isSecret} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||
import { VideoPlayerError } from './VideoPlayerError';
|
||||
@@ -18,6 +18,7 @@ interface VideoPlayerProps {
|
||||
totalEpisodes?: number;
|
||||
onNextEpisode?: () => void;
|
||||
isReversed?: boolean;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
@@ -27,7 +28,8 @@ export function VideoPlayer({
|
||||
onBack,
|
||||
totalEpisodes,
|
||||
onNextEpisode,
|
||||
isReversed = false
|
||||
isReversed = false,
|
||||
isSecret = false
|
||||
}: VideoPlayerProps) {
|
||||
const [videoError, setVideoError] = useState<string>('');
|
||||
const [useProxy, setUseProxy] = useState(false);
|
||||
@@ -47,9 +49,8 @@ export function VideoPlayer({
|
||||
|
||||
// Use reactive hook to subscribe to history updates
|
||||
// This ensures the component re-renders when history is hydrated from localStorage
|
||||
const viewingHistory = useHistoryStore(state => state.viewingHistory);
|
||||
const { viewingHistory, addToHistory } = useHistory(isSecret);
|
||||
const searchParams = useSearchParams();
|
||||
const { addToHistory } = useHistoryStore();
|
||||
|
||||
// Get video metadata from URL params
|
||||
const source = searchParams.get('source') || '';
|
||||
|
||||
@@ -119,7 +119,7 @@ export function DesktopOverlay({
|
||||
<div className="absolute top-1/2 left-24 -translate-y-1/2 pointer-events-none transition-all duration-300 z-20">
|
||||
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipBackwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
||||
}`}>
|
||||
-{skipBackwardAmount}s
|
||||
-{skipBackwardAmount}秒
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -129,7 +129,7 @@ export function DesktopOverlay({
|
||||
<div className="absolute top-1/2 right-24 -translate-y-1/2 pointer-events-none transition-all duration-300 z-20">
|
||||
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipForwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
||||
}`}>
|
||||
+{skipForwardAmount}s
|
||||
+{skipForwardAmount}秒
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -48,8 +48,8 @@ export function DesktopRightControls({
|
||||
<button
|
||||
onClick={onShowAirPlayMenu}
|
||||
className="btn-icon"
|
||||
aria-label="AirPlay"
|
||||
title="AirPlay"
|
||||
aria-label="隔空播放"
|
||||
title="隔空播放"
|
||||
>
|
||||
<Icons.Airplay size={20} />
|
||||
</button>
|
||||
@@ -62,8 +62,8 @@ export function DesktopRightControls({
|
||||
<button
|
||||
onClick={onShowCastMenu}
|
||||
className="btn-icon"
|
||||
aria-label="Google Cast"
|
||||
title="Google Cast"
|
||||
aria-label="投屏"
|
||||
title="投屏"
|
||||
>
|
||||
<Icons.Cast size={20} />
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface VideoCardProps {
|
||||
cardId: string;
|
||||
isActive: boolean;
|
||||
onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export const VideoCard = memo<VideoCardProps>(({
|
||||
@@ -25,7 +26,8 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
videoUrl,
|
||||
cardId,
|
||||
isActive,
|
||||
onCardClick
|
||||
onCardClick,
|
||||
isSecret = false
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
@@ -107,6 +109,7 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
remarks={video.vod_remarks}
|
||||
size={16}
|
||||
className="shadow-md"
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ import { Video } from '@/lib/types';
|
||||
interface VideoGridProps {
|
||||
videos: Video[];
|
||||
className?: string;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: VideoGridProps) {
|
||||
export const VideoGrid = memo(function VideoGrid({ videos, className = '', isSecret = false }: VideoGridProps) {
|
||||
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(24);
|
||||
const [displayMode, setDisplayMode] = useState<'normal' | 'grouped'>('normal');
|
||||
@@ -99,17 +100,23 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
|
||||
if (displayMode === 'grouped') return [];
|
||||
|
||||
return videos.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
const params: Record<string, string> = {
|
||||
id: String(video.vod_id),
|
||||
source: video.source,
|
||||
title: video.vod_name,
|
||||
}).toString()}`;
|
||||
};
|
||||
|
||||
if (isSecret) {
|
||||
params.secret = '1';
|
||||
}
|
||||
|
||||
const videoUrl = `/player?${new URLSearchParams(params).toString()}`;
|
||||
|
||||
const cardId = `${video.vod_id}-${index}`;
|
||||
|
||||
return { video, videoUrl, cardId };
|
||||
});
|
||||
}, [videos, displayMode]);
|
||||
}, [videos, displayMode, isSecret]);
|
||||
|
||||
// Grouped mode items
|
||||
const groupItems = useMemo(() => {
|
||||
@@ -157,6 +164,7 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
|
||||
cardId={cardId}
|
||||
isActive={isActive}
|
||||
onCardClick={handleCardClick}
|
||||
isSecret={isSecret}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
|
||||
|
||||
<div>
|
||||
<label htmlFor="source-url" className="block mb-2 font-medium text-[var(--text-color)]">
|
||||
API 地址
|
||||
接口地址
|
||||
</label>
|
||||
<input
|
||||
id="source-url"
|
||||
|
||||
@@ -49,7 +49,7 @@ export function FileImportTab({ onImport }: FileImportTabProps) {
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<p className="text-[var(--text-color-secondary)] text-sm mb-4">
|
||||
选择之前导出的设置文件(JSON 格式)。支持新旧版本格式。
|
||||
选择之前导出的设置文件(JSON 配置文件)。支持新旧版本格式。
|
||||
</p>
|
||||
|
||||
<input
|
||||
|
||||
@@ -82,7 +82,7 @@ export function LinkImportTab({ onImport }: LinkImportTabProps) {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-2 ml-1">
|
||||
支持 JSON 格式的单个或多个源配置链接
|
||||
支持 JSON 配置文件格式的单个或多个源配置链接
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useVideoPlayer(
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setVideoError(data.error || 'This video source is not available. Please go back and try another source.');
|
||||
setVideoError(data.error || '该视频源不可用。请返回并尝试其他来源。');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -108,15 +108,15 @@ export function useVideoPlayer(
|
||||
setCurrentEpisode(validIndex);
|
||||
setPlayUrl(episodeUrl);
|
||||
} else {
|
||||
setVideoError('No playable episodes available for this video from this source');
|
||||
setVideoError('该来源没有可播放的剧集');
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'Invalid response from API');
|
||||
throw new Error(data.error || '来自 API 的响应无效');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video details:', error);
|
||||
setVideoError(error instanceof Error ? error.message : 'Failed to load video details.');
|
||||
setVideoError(error instanceof Error ? error.message : '加载视频详情失败。');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [videoId, source]);
|
||||
|
||||
@@ -9,10 +9,11 @@ import type { FavoriteItem } from '@/lib/types';
|
||||
|
||||
const MAX_FAVORITES = 100;
|
||||
|
||||
interface FavoritesStore {
|
||||
interface FavoritesState {
|
||||
favorites: FavoriteItem[];
|
||||
}
|
||||
|
||||
// Actions
|
||||
interface FavoritesActions {
|
||||
addFavorite: (item: Omit<FavoriteItem, 'addedAt'>) => void;
|
||||
removeFavorite: (videoId: string | number, source: string) => void;
|
||||
toggleFavorite: (item: Omit<FavoriteItem, 'addedAt'>) => boolean;
|
||||
@@ -21,6 +22,8 @@ interface FavoritesStore {
|
||||
importFavorites: (favorites: FavoriteItem[]) => void;
|
||||
}
|
||||
|
||||
interface FavoritesStore extends FavoritesState, FavoritesActions { }
|
||||
|
||||
/**
|
||||
* Generate unique identifier for a favorite item
|
||||
*/
|
||||
@@ -31,84 +34,97 @@ function generateFavoriteId(
|
||||
return `${source}:${videoId}`;
|
||||
}
|
||||
|
||||
export const useFavoritesStore = create<FavoritesStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
favorites: [],
|
||||
const createFavoritesStore = (name: string) =>
|
||||
create<FavoritesStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
favorites: [],
|
||||
|
||||
addFavorite: (item) => {
|
||||
const favoriteId = generateFavoriteId(item.videoId, item.source);
|
||||
addFavorite: (item) => {
|
||||
const favoriteId = generateFavoriteId(item.videoId, item.source);
|
||||
|
||||
set((state) => {
|
||||
// Check if already exists
|
||||
set((state) => {
|
||||
// Check if already exists
|
||||
const exists = state.favorites.some(
|
||||
(fav) => generateFavoriteId(fav.videoId, fav.source) === favoriteId
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const newFavorite: FavoriteItem = {
|
||||
...item,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
|
||||
let newFavorites = [newFavorite, ...state.favorites];
|
||||
|
||||
// Limit favorites size
|
||||
if (newFavorites.length > MAX_FAVORITES) {
|
||||
newFavorites = newFavorites.slice(0, MAX_FAVORITES);
|
||||
}
|
||||
|
||||
return { favorites: newFavorites };
|
||||
});
|
||||
},
|
||||
|
||||
removeFavorite: (videoId, source) => {
|
||||
const favoriteId = generateFavoriteId(videoId, source);
|
||||
|
||||
set((state) => ({
|
||||
favorites: state.favorites.filter(
|
||||
(fav) => generateFavoriteId(fav.videoId, fav.source) !== favoriteId
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleFavorite: (item) => {
|
||||
const state = get();
|
||||
const favoriteId = generateFavoriteId(item.videoId, item.source);
|
||||
const exists = state.favorites.some(
|
||||
(fav) => generateFavoriteId(fav.videoId, fav.source) === favoriteId
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
return state;
|
||||
state.removeFavorite(item.videoId, item.source);
|
||||
return false;
|
||||
} else {
|
||||
state.addFavorite(item);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
const newFavorite: FavoriteItem = {
|
||||
...item,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
isFavorite: (videoId, source) => {
|
||||
const state = get();
|
||||
const favoriteId = generateFavoriteId(videoId, source);
|
||||
return state.favorites.some(
|
||||
(fav) => generateFavoriteId(fav.videoId, fav.source) === favoriteId
|
||||
);
|
||||
},
|
||||
|
||||
let newFavorites = [newFavorite, ...state.favorites];
|
||||
clearFavorites: () => {
|
||||
set({ favorites: [] });
|
||||
},
|
||||
|
||||
// Limit favorites size
|
||||
if (newFavorites.length > MAX_FAVORITES) {
|
||||
newFavorites = newFavorites.slice(0, MAX_FAVORITES);
|
||||
}
|
||||
importFavorites: (favorites) => {
|
||||
set({ favorites });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return { favorites: newFavorites };
|
||||
});
|
||||
},
|
||||
export const useFavoritesStore = createFavoritesStore('kvideo-favorites-store');
|
||||
export const useSecretFavoritesStore = createFavoritesStore('kvideo-secret-favorites-store');
|
||||
|
||||
removeFavorite: (videoId, source) => {
|
||||
const favoriteId = generateFavoriteId(videoId, source);
|
||||
|
||||
set((state) => ({
|
||||
favorites: state.favorites.filter(
|
||||
(fav) => generateFavoriteId(fav.videoId, fav.source) !== favoriteId
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleFavorite: (item) => {
|
||||
const state = get();
|
||||
const favoriteId = generateFavoriteId(item.videoId, item.source);
|
||||
const exists = state.favorites.some(
|
||||
(fav) => generateFavoriteId(fav.videoId, fav.source) === favoriteId
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
state.removeFavorite(item.videoId, item.source);
|
||||
return false;
|
||||
} else {
|
||||
state.addFavorite(item);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
isFavorite: (videoId, source) => {
|
||||
const state = get();
|
||||
const favoriteId = generateFavoriteId(videoId, source);
|
||||
return state.favorites.some(
|
||||
(fav) => generateFavoriteId(fav.videoId, fav.source) === favoriteId
|
||||
);
|
||||
},
|
||||
|
||||
clearFavorites: () => {
|
||||
set({ favorites: [] });
|
||||
},
|
||||
|
||||
importFavorites: (favorites) => {
|
||||
set({ favorites });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'kvideo-favorites-store',
|
||||
}
|
||||
)
|
||||
);
|
||||
/**
|
||||
* Helper hook to get the appropriate favorites store
|
||||
*/
|
||||
export function useFavorites(isSecret = false) {
|
||||
const normalStore = useFavoritesStore();
|
||||
const secretStore = useSecretFavoritesStore();
|
||||
return isSecret ? secretStore : normalStore;
|
||||
}
|
||||
|
||||
+112
-96
@@ -10,10 +10,11 @@ import { clearSegmentsForUrl, clearAllCache } from '@/lib/utils/cacheManager';
|
||||
|
||||
const MAX_HISTORY_ITEMS = 50;
|
||||
|
||||
interface HistoryStore {
|
||||
interface HistoryState {
|
||||
viewingHistory: VideoHistoryItem[];
|
||||
}
|
||||
|
||||
// Actions
|
||||
interface HistoryActions {
|
||||
addToHistory: (
|
||||
videoId: string | number,
|
||||
title: string,
|
||||
@@ -31,6 +32,8 @@ interface HistoryStore {
|
||||
importHistory: (history: VideoHistoryItem[]) => void;
|
||||
}
|
||||
|
||||
interface HistoryStore extends HistoryState, HistoryActions { }
|
||||
|
||||
/**
|
||||
* Generate unique identifier for deduplication
|
||||
*/
|
||||
@@ -42,107 +45,120 @@ function generateShowIdentifier(
|
||||
return `${source}:${videoId}:${title.toLowerCase().trim()}`;
|
||||
}
|
||||
|
||||
export const useHistoryStore = create<HistoryStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
viewingHistory: [],
|
||||
const createHistoryStore = (name: string) =>
|
||||
create<HistoryStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
viewingHistory: [],
|
||||
|
||||
addToHistory: (
|
||||
videoId,
|
||||
title,
|
||||
url,
|
||||
episodeIndex,
|
||||
source,
|
||||
playbackPosition,
|
||||
duration,
|
||||
poster,
|
||||
episodes = []
|
||||
) => {
|
||||
const showIdentifier = generateShowIdentifier(title, source, videoId);
|
||||
const timestamp = Date.now();
|
||||
addToHistory: (
|
||||
videoId,
|
||||
title,
|
||||
url,
|
||||
episodeIndex,
|
||||
source,
|
||||
playbackPosition,
|
||||
duration,
|
||||
poster,
|
||||
episodes = []
|
||||
) => {
|
||||
const showIdentifier = generateShowIdentifier(title, source, videoId);
|
||||
const timestamp = Date.now();
|
||||
|
||||
set((state) => {
|
||||
// Check if item already exists
|
||||
const existingIndex = state.viewingHistory.findIndex(
|
||||
(item) => item.showIdentifier === showIdentifier
|
||||
set((state) => {
|
||||
// Check if item already exists
|
||||
const existingIndex = state.viewingHistory.findIndex(
|
||||
(item) => item.showIdentifier === showIdentifier
|
||||
);
|
||||
|
||||
let newHistory: VideoHistoryItem[];
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing item and move to top
|
||||
const updatedItem: VideoHistoryItem = {
|
||||
...state.viewingHistory[existingIndex],
|
||||
url,
|
||||
episodeIndex,
|
||||
playbackPosition,
|
||||
duration,
|
||||
timestamp,
|
||||
episodes: episodes.length > 0 ? episodes : state.viewingHistory[existingIndex].episodes,
|
||||
};
|
||||
|
||||
newHistory = [
|
||||
updatedItem,
|
||||
...state.viewingHistory.filter((_, index) => index !== existingIndex),
|
||||
];
|
||||
} else {
|
||||
// Add new item at the top
|
||||
const newItem: VideoHistoryItem = {
|
||||
videoId,
|
||||
title,
|
||||
url,
|
||||
episodeIndex,
|
||||
source,
|
||||
timestamp,
|
||||
playbackPosition,
|
||||
duration,
|
||||
poster,
|
||||
episodes,
|
||||
showIdentifier,
|
||||
};
|
||||
|
||||
newHistory = [newItem, ...state.viewingHistory];
|
||||
}
|
||||
|
||||
// Limit history size
|
||||
if (newHistory.length > MAX_HISTORY_ITEMS) {
|
||||
newHistory = newHistory.slice(0, MAX_HISTORY_ITEMS);
|
||||
}
|
||||
|
||||
return { viewingHistory: newHistory };
|
||||
});
|
||||
},
|
||||
|
||||
removeFromHistory: (videoId, source) => {
|
||||
const state = get();
|
||||
const itemToRemove = state.viewingHistory.find(
|
||||
(item) => item.videoId === videoId && item.source === source
|
||||
);
|
||||
|
||||
let newHistory: VideoHistoryItem[];
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing item and move to top
|
||||
const updatedItem: VideoHistoryItem = {
|
||||
...state.viewingHistory[existingIndex],
|
||||
url,
|
||||
episodeIndex,
|
||||
playbackPosition,
|
||||
duration,
|
||||
timestamp,
|
||||
episodes: episodes.length > 0 ? episodes : state.viewingHistory[existingIndex].episodes,
|
||||
};
|
||||
|
||||
newHistory = [
|
||||
updatedItem,
|
||||
...state.viewingHistory.filter((_, index) => index !== existingIndex),
|
||||
];
|
||||
} else {
|
||||
// Add new item at the top
|
||||
const newItem: VideoHistoryItem = {
|
||||
videoId,
|
||||
title,
|
||||
url,
|
||||
episodeIndex,
|
||||
source,
|
||||
timestamp,
|
||||
playbackPosition,
|
||||
duration,
|
||||
poster,
|
||||
episodes,
|
||||
showIdentifier,
|
||||
};
|
||||
|
||||
newHistory = [newItem, ...state.viewingHistory];
|
||||
if (itemToRemove) {
|
||||
// Clear cache for this video
|
||||
clearSegmentsForUrl(itemToRemove.url);
|
||||
}
|
||||
|
||||
// Limit history size
|
||||
if (newHistory.length > MAX_HISTORY_ITEMS) {
|
||||
newHistory = newHistory.slice(0, MAX_HISTORY_ITEMS);
|
||||
}
|
||||
set((state) => ({
|
||||
viewingHistory: state.viewingHistory.filter(
|
||||
(item) => !(item.videoId === videoId && item.source === source)
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
return { viewingHistory: newHistory };
|
||||
});
|
||||
},
|
||||
clearHistory: () => {
|
||||
// Clear all cached segments
|
||||
clearAllCache();
|
||||
set({ viewingHistory: [] });
|
||||
},
|
||||
|
||||
removeFromHistory: (videoId, source) => {
|
||||
const state = get();
|
||||
const itemToRemove = state.viewingHistory.find(
|
||||
(item) => item.videoId === videoId && item.source === source
|
||||
);
|
||||
importHistory: (history) => {
|
||||
set({ viewingHistory: history });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (itemToRemove) {
|
||||
// Clear cache for this video
|
||||
clearSegmentsForUrl(itemToRemove.url);
|
||||
}
|
||||
export const useHistoryStore = createHistoryStore('kvideo-history-store');
|
||||
export const useSecretHistoryStore = createHistoryStore('kvideo-secret-history-store');
|
||||
|
||||
set((state) => ({
|
||||
viewingHistory: state.viewingHistory.filter(
|
||||
(item) => !(item.videoId === videoId && item.source === source)
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearHistory: () => {
|
||||
// Clear all cached segments
|
||||
clearAllCache();
|
||||
set({ viewingHistory: [] });
|
||||
},
|
||||
|
||||
importHistory: (history) => {
|
||||
set({ viewingHistory: history });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'kvideo-history-store',
|
||||
}
|
||||
)
|
||||
);
|
||||
/**
|
||||
* Helper hook to get the appropriate history store
|
||||
*/
|
||||
export function useHistory(isSecret = false) {
|
||||
const normalStore = useHistoryStore();
|
||||
const secretStore = useSecretHistoryStore();
|
||||
return isSecret ? secretStore : normalStore;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "3.8.8",
|
||||
"version": "3.8.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "3.8.8",
|
||||
"version": "3.8.9",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "3.8.8",
|
||||
"version": "3.8.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user