feat: implement secret mode with separate history and favorites, and dedicated player links

This commit is contained in:
kuekhaoyang
2026-01-02 13:54:27 +08:00
parent 8dd9bb67b5
commit c5e2397f2a
23 changed files with 296 additions and 257 deletions
+6 -7
View File
@@ -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
View File
@@ -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>
);
}
+4 -2
View File
@@ -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);
+5 -1
View File
@@ -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()}`;
};
+3 -1
View File
@@ -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>
+8 -3
View File
@@ -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}
+23 -36
View File
@@ -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 */}
+4 -10
View File
@@ -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>
+3 -3
View File
@@ -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);
+3 -2
View File
@@ -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>
);
}
+5 -4
View File
@@ -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') || '';
+2 -2
View File
@@ -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>
+4 -1
View File
@@ -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>
+12 -4
View File
@@ -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}
/>
);
})
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 && (
+4 -4
View File
@@ -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]);
+86 -70
View File
@@ -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
View File
@@ -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;
}
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "3.8.8",
"version": "3.8.9",
"private": true,
"scripts": {
"dev": "next dev",