- {/* Custom Header for Secret Settings */}
+ {/* Custom Header for Premium Settings */}
WKWebView {
+ let config = WKWebViewConfiguration()
+ config.allowsInlineMediaPlayback = true
+ config.mediaTypesRequiringUserActionForPlayback = []
+
+ let preferences = WKWebpagePreferences()
+ preferences.allowsContentJavaScript = true
+ config.defaultWebpagePreferences = preferences
+
+ let webView = WKWebView(frame: .zero, configuration: config)
+ webView.navigationDelegate = context.coordinator
+ webView.isOpaque = false
+ webView.backgroundColor = .black
+ webView.scrollView.backgroundColor = .black
+
+ // Allow back navigation via Menu button
+ webView.allowsBackForwardNavigationGestures = true
+
+ webView.load(URLRequest(url: url))
+ return webView
+ }
+
+ func updateUIView(_ uiView: WKWebView, context: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator()
+ }
+
+ class Coordinator: NSObject, WKNavigationDelegate {
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ // Inject JS to signal TV mode
+ webView.evaluateJavaScript("""
+ document.body.classList.add('tv-mode');
+ """)
+ }
+ }
+}
diff --git a/apple-tv/KVideoTV/KVideoTV/KVideoTVApp.swift b/apple-tv/KVideoTV/KVideoTV/KVideoTVApp.swift
new file mode 100644
index 0000000..e3ac681
--- /dev/null
+++ b/apple-tv/KVideoTV/KVideoTV/KVideoTVApp.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+
+@main
+struct KVideoTVApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .ignoresSafeArea()
+ }
+ }
+}
diff --git a/apple-tv/README.md b/apple-tv/README.md
new file mode 100644
index 0000000..ad939fd
--- /dev/null
+++ b/apple-tv/README.md
@@ -0,0 +1,40 @@
+# KVideo Apple TV App
+
+A lightweight tvOS WebView wrapper for KVideo.
+
+## Requirements
+
+- macOS with Xcode 15+
+- Apple Developer account (free is fine for personal device sideloading)
+
+## Setup
+
+1. Open Xcode → **File → New → Project**
+2. Select **tvOS → App**, click Next
+3. Set:
+ - Product Name: `KVideoTV`
+ - Interface: **SwiftUI**
+ - Language: **Swift**
+4. Choose a save location, click Create
+5. **Replace** the generated `KVideoTVApp.swift` with the one in this directory
+6. **Replace** the generated `ContentView.swift` with the one in this directory
+7. In `ContentView.swift`, change `kvideoURL` to your deployed KVideo instance URL:
+ ```swift
+ let kvideoURL = "https://your-kvideo-instance.com"
+ ```
+8. Set deployment target to **tvOS 16.0** or later
+9. Connect your Apple TV (or use the tvOS Simulator)
+10. Build and run (Cmd+R)
+
+## How it works
+
+- The app is a fullscreen `WKWebView` that loads your KVideo URL
+- On page load, it injects `tv-mode` CSS class to activate TV-optimized styles
+- The Apple TV remote's swipe gestures map to scroll, and click maps to tap/focus
+- Back navigation uses `allowsBackForwardNavigationGestures`
+
+## Notes
+
+- Apple TV apps **cannot** be published to the App Store if they're just web wrappers
+- This is intended for personal sideloading only
+- For AirPlay: you can also just AirPlay from iPhone/iPad/Mac without needing this app
diff --git a/components/TVNavigationInitializer.tsx b/components/TVNavigationInitializer.tsx
new file mode 100644
index 0000000..f2286d1
--- /dev/null
+++ b/components/TVNavigationInitializer.tsx
@@ -0,0 +1,29 @@
+/**
+ * TVNavigationInitializer
+ * Adds tv-mode class to body and activates spatial navigation when TV is detected.
+ */
+
+'use client';
+
+import { useEffect } from 'react';
+import { useIsTV } from '@/lib/contexts/TVContext';
+import { useSpatialNavigation } from '@/lib/hooks/useSpatialNavigation';
+
+export function TVNavigationInitializer() {
+ const isTV = useIsTV();
+
+ useEffect(() => {
+ if (isTV) {
+ document.body.classList.add('tv-mode');
+ } else {
+ document.body.classList.remove('tv-mode');
+ }
+ return () => {
+ document.body.classList.remove('tv-mode');
+ };
+ }, [isTV]);
+
+ useSpatialNavigation(isTV);
+
+ return null;
+}
diff --git a/components/home/MovieCard.tsx b/components/home/MovieCard.tsx
index 5ebea6b..cf9fbf2 100644
--- a/components/home/MovieCard.tsx
+++ b/components/home/MovieCard.tsx
@@ -36,6 +36,7 @@ export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieC
e.preventDefault();
onMovieClick(movie);
}}
+ data-focusable
className="group cursor-pointer hover:translate-y-[-2px] transition-transform duration-200 ease-out"
style={{
position: 'relative',
diff --git a/components/home/PopularFeatures.tsx b/components/home/PopularFeatures.tsx
index 74c70c9..9f910b9 100644
--- a/components/home/PopularFeatures.tsx
+++ b/components/home/PopularFeatures.tsx
@@ -1,14 +1,17 @@
/**
* PopularFeatures - Main component for popular movies section
- * Displays Douban movie recommendations with tag filtering and infinite scroll
+ * Displays Douban movie recommendations with tag filtering and infinite scroll.
+ * Includes personalized "为你推荐" tag when user has 2+ watched items.
*/
'use client';
+import { useState } from 'react';
import { TagManager } from './TagManager';
import { MovieGrid } from './MovieGrid';
import { useTagManager } from './hooks/useTagManager';
import { usePopularMovies } from './hooks/usePopularMovies';
+import { usePersonalizedRecommendations } from './hooks/usePersonalizedRecommendations';
interface PopularFeaturesProps {
onSearch?: (query: string) => void;
@@ -34,13 +37,33 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
isLoadingTags,
} = useTagManager();
+ const {
+ movies: recommendMovies,
+ loading: recommendLoading,
+ hasMore: recommendHasMore,
+ hasHistory,
+ prefetchRef: recommendPrefetchRef,
+ loadMoreRef: recommendLoadMoreRef,
+ } = usePersonalizedRecommendations(false);
+
+ // Track whether the recommendation tab is active
+ const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
+
+ // Sync default selection when hasHistory changes
+ // (on first render, if hasHistory is true, recommendation tab is pre-selected)
+ const effectiveRecommendSelected = hasHistory && isRecommendSelected;
+
const {
movies,
loading,
hasMore,
prefetchRef,
loadMoreRef,
- } = usePopularMovies(selectedTag, tags, contentType);
+ } = usePopularMovies(
+ effectiveRecommendSelected ? '' : selectedTag,
+ tags,
+ contentType
+ );
const handleMovieClick = (movie: any) => {
if (onSearch) {
@@ -48,48 +71,58 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
}
};
+ const handleRecommendSelect = () => {
+ setIsRecommendSelected(true);
+ };
+
+ const handleRegularTagSelect = (tagId: string) => {
+ if (tagId === 'custom_高级' || tags.find(t => t.id === tagId)?.label === '高级') {
+ window.location.href = '/premium';
+ return;
+ }
+ setIsRecommendSelected(false);
+ setSelectedTag(tagId);
+ };
+
return (
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
-
-
- {/* Sliding Indicator */}
-
+ {!effectiveRecommendSelected && (
+
+
+ {/* Sliding Indicator */}
+
-
-
+
+
+
-
+ )}
+
{
- if (tagId === 'custom_高级' || tags.find(t => t.id === tagId)?.label === '高级') {
- window.location.href = '/premium';
- return;
- }
- setSelectedTag(tagId);
- }}
+ onTagSelect={handleRegularTagSelect}
onTagDelete={handleDeleteTag}
onToggleManager={() => setShowTagManager(!showTagManager)}
onRestoreDefaults={handleRestoreDefaults}
@@ -98,16 +131,32 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
onDragEnd={handleDragEnd}
onJustAddedTagHandled={() => setJustAddedTag(false)}
isLoadingTags={isLoadingTags}
+ recommendTag={hasHistory ? {
+ label: '为你推荐',
+ isSelected: effectiveRecommendSelected,
+ onSelect: handleRecommendSelect,
+ } : undefined}
/>
-
+ {effectiveRecommendSelected ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/components/home/TagList.tsx b/components/home/TagList.tsx
index f34ae89..e039c18 100644
--- a/components/home/TagList.tsx
+++ b/components/home/TagList.tsx
@@ -18,6 +18,13 @@ import {
} from '@dnd-kit/sortable';
import { SortableTag, Tag } from './SortableTag';
import { useState, useRef, useEffect } from 'react';
+import { Icons } from '@/components/ui/Icon';
+
+interface RecommendTagConfig {
+ label: string;
+ isSelected: boolean;
+ onSelect: () => void;
+}
interface TagListProps {
tags: Tag[];
@@ -28,6 +35,7 @@ interface TagListProps {
onTagDelete: (tagId: string) => void;
onDragEnd: (event: DragEndEvent) => void;
onJustAddedTagHandled: () => void;
+ recommendTag?: RecommendTagConfig;
}
export function TagList({
@@ -39,6 +47,7 @@ export function TagList({
onTagDelete,
onDragEnd,
onJustAddedTagHandled,
+ recommendTag,
}: TagListProps) {
const scrollContainerRef = useRef
(null);
const [activeId, setActiveId] = useState(null);
@@ -108,6 +117,24 @@ export function TagList({
ref={scrollContainerRef}
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
>
+ {/* Recommendation Tag — non-draggable, rendered before sortable tags */}
+ {recommendTag && (
+
+
+
+ )}
t.id)}
strategy={horizontalListSortingStrategy}
diff --git a/components/home/TagManager.tsx b/components/home/TagManager.tsx
index 2242187..f7fc858 100644
--- a/components/home/TagManager.tsx
+++ b/components/home/TagManager.tsx
@@ -4,6 +4,12 @@ import { TagInput } from './TagInput';
import { TagList } from './TagList';
import { Tag } from './SortableTag';
+interface RecommendTagConfig {
+ label: string;
+ isSelected: boolean;
+ onSelect: () => void;
+}
+
interface TagManagerProps {
tags: Tag[];
selectedTag: string;
@@ -19,6 +25,7 @@ interface TagManagerProps {
onDragEnd: (event: DragEndEvent) => void;
onJustAddedTagHandled: () => void;
isLoadingTags?: boolean;
+ recommendTag?: RecommendTagConfig;
}
export function TagManager({
@@ -36,6 +43,7 @@ export function TagManager({
onDragEnd,
onJustAddedTagHandled,
isLoadingTags,
+ recommendTag,
}: TagManagerProps) {
return (
<>
@@ -84,6 +92,7 @@ export function TagManager({
onTagDelete={onTagDelete}
onDragEnd={onDragEnd}
onJustAddedTagHandled={onJustAddedTagHandled}
+ recommendTag={recommendTag}
/>
)}
>
diff --git a/components/home/hooks/usePersonalizedRecommendations.ts b/components/home/hooks/usePersonalizedRecommendations.ts
new file mode 100644
index 0000000..69c211f
--- /dev/null
+++ b/components/home/hooks/usePersonalizedRecommendations.ts
@@ -0,0 +1,188 @@
+/**
+ * usePersonalizedRecommendations
+ *
+ * Fetches personalized content based on viewing history patterns.
+ * Designed to integrate into the tag system — returns the same shape
+ * as usePopularMovies (movies, loading, hasMore, prefetchRef, loadMoreRef).
+ *
+ * Features:
+ * - Interleaves results from multiple recommendation queries into a single mixed feed
+ * - Randomizes Douban API offsets so each page load shows different content
+ * - Excludes already-watched titles
+ * - Auto-infinite-scroll via useInfiniteScroll (no "load more" button)
+ * - Caches results for 30 minutes
+ * - hasHistory = true when viewingHistory.length >= 2
+ */
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useHistoryStore, usePremiumHistoryStore } from '@/lib/store/history-store';
+import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll';
+import {
+ generateRecommendations,
+ getWatchedTitles,
+ interleaveResults,
+ type RecommendationQuery,
+} from '@/lib/utils/recommendation-engine';
+
+interface DoubanMovie {
+ id: string;
+ title: string;
+ cover: string;
+ rate: string;
+ url: string;
+}
+
+interface InterleavedMovie extends DoubanMovie {
+ sourceLabel: string;
+}
+
+const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
+const ITEMS_PER_PAGE = 18; // How many to fetch per query per page
+
+export function usePersonalizedRecommendations(isPremium = false) {
+ const normalHistory = useHistoryStore();
+ const premiumHistory = usePremiumHistoryStore();
+ const { viewingHistory } = isPremium ? premiumHistory : normalHistory;
+
+ const [movies, setMovies] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [hasMore, setHasMore] = useState(true);
+ const [page, setPage] = useState(0);
+ const queriesRef = useRef([]);
+ const cacheRef = useRef<{
+ key: string;
+ movies: InterleavedMovie[];
+ timestamp: number;
+ } | null>(null);
+
+ const hasHistory = viewingHistory.length >= 2;
+
+ // Fetch a page of results from all queries
+ const fetchPage = useCallback(async (
+ queries: RecommendationQuery[],
+ pageNum: number,
+ watchedTitles: Set,
+ ): Promise => {
+ const results = await Promise.all(
+ queries.map(async (query) => {
+ try {
+ const offset = query.pageStart + pageNum * ITEMS_PER_PAGE;
+ const res = await fetch(
+ `/api/douban/recommend?tag=${encodeURIComponent(query.tag)}&type=${query.type}&page_limit=${ITEMS_PER_PAGE}&page_start=${offset}`
+ );
+ if (!res.ok) return { label: query.label, movies: [] as DoubanMovie[] };
+ const data = await res.json();
+ const movies: DoubanMovie[] = (data.subjects || []).map((s: any) => ({
+ id: s.id,
+ title: s.title,
+ cover: s.cover,
+ rate: s.rate,
+ url: s.url,
+ }));
+ return { label: query.label, movies };
+ } catch {
+ return { label: query.label, movies: [] as DoubanMovie[] };
+ }
+ })
+ );
+
+ return interleaveResults(results, watchedTitles);
+ }, []);
+
+ // Initial load
+ useEffect(() => {
+ if (viewingHistory.length < 2) {
+ setMovies([]);
+ setHasMore(false);
+ return;
+ }
+
+ const queries = generateRecommendations(viewingHistory);
+ queriesRef.current = queries;
+
+ if (queries.length === 0) {
+ setMovies([]);
+ setHasMore(false);
+ return;
+ }
+
+ // Cache key based on query tags (not pageStart, since that's randomized)
+ const cacheKey = queries.map(q => `${q.tag}:${q.type}`).join('|');
+
+ if (
+ cacheRef.current &&
+ cacheRef.current.key === cacheKey &&
+ Date.now() - cacheRef.current.timestamp < CACHE_DURATION
+ ) {
+ setMovies(cacheRef.current.movies);
+ setHasMore(true);
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+ setPage(0);
+ setHasMore(true);
+
+ const watchedTitles = getWatchedTitles(viewingHistory);
+
+ fetchPage(queries, 0, watchedTitles).then((interleaved) => {
+ if (cancelled) return;
+ setMovies(interleaved);
+ setHasMore(interleaved.length >= queries.length * 2);
+ cacheRef.current = {
+ key: cacheKey,
+ movies: interleaved,
+ timestamp: Date.now(),
+ };
+ setLoading(false);
+ }).catch(() => {
+ if (!cancelled) setLoading(false);
+ });
+
+ return () => { cancelled = true; };
+ }, [viewingHistory.length, fetchPage]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Load more via infinite scroll
+ const handleLoadMore = useCallback(async (nextPage: number) => {
+ const queries = queriesRef.current;
+ if (queries.length === 0 || loading) return;
+
+ setLoading(true);
+ const watchedTitles = getWatchedTitles(viewingHistory);
+
+ try {
+ const newMovies = await fetchPage(queries, nextPage, watchedTitles);
+
+ // Deduplicate against existing movies
+ const existingTitles = new Set(movies.map(m => m.title.toLowerCase().trim()));
+ const uniqueNew = newMovies.filter(
+ m => !existingTitles.has(m.title.toLowerCase().trim())
+ );
+
+ if (uniqueNew.length === 0) {
+ setHasMore(false);
+ } else {
+ setMovies((prev) => [...prev, ...uniqueNew]);
+ setPage(nextPage);
+ // Update cache
+ if (cacheRef.current) {
+ cacheRef.current.movies = [...cacheRef.current.movies, ...uniqueNew];
+ }
+ }
+ } catch {
+ // Silently fail
+ } finally {
+ setLoading(false);
+ }
+ }, [loading, viewingHistory, movies, fetchPage]);
+
+ const { prefetchRef, loadMoreRef } = useInfiniteScroll({
+ hasMore,
+ loading,
+ page,
+ onLoadMore: handleLoadMore,
+ });
+
+ return { movies, loading, hasMore, hasHistory, prefetchRef, loadMoreRef };
+}
diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx
index d7c5031..2c40133 100644
--- a/components/layout/Navbar.tsx
+++ b/components/layout/Navbar.tsx
@@ -41,6 +41,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
href={isPremiumMode ? '/premium' : '/'}
className="flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity cursor-pointer min-w-0"
onClick={onReset}
+ data-focusable
>
diff --git a/components/player/VideoPlayer.tsx b/components/player/VideoPlayer.tsx
index b0b3d88..88e0ec2 100644
--- a/components/player/VideoPlayer.tsx
+++ b/components/player/VideoPlayer.tsx
@@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { useHistory } from '@/lib/store/history-store';
import { settingsStore } from '@/lib/store/settings-store';
+import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
import { CustomVideoPlayer } from './CustomVideoPlayer';
import { VideoPlayerError } from './VideoPlayerError';
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
@@ -52,14 +53,15 @@ export function VideoPlayer({
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
useEffect(() => {
- // Initial value
- const settings = settingsStore.getSettings();
+ // Initial value - use mode-specific store
+ const store = isPremium ? premiumModeSettingsStore : settingsStore;
+ const settings = store.getSettings();
setShowModeIndicator(settings.showModeIndicator);
setProxyMode(settings.proxyMode);
// Subscribe to changes
- const unsubscribe = settingsStore.subscribe(() => {
- const newSettings = settingsStore.getSettings();
+ const unsubscribe = store.subscribe(() => {
+ const newSettings = store.getSettings();
setShowModeIndicator(newSettings.showModeIndicator);
setProxyMode(newSettings.proxyMode);
});
@@ -200,6 +202,7 @@ export function VideoPlayer({
}
return (
+
{/* Mode Indicator Badge - controlled by settings */}
{showModeIndicator && (
@@ -237,5 +240,6 @@ export function VideoPlayer({
/>
)}
+
);
}
diff --git a/components/premium/PremiumContent.tsx b/components/premium/PremiumContent.tsx
index 43557fb..dd80025 100644
--- a/components/premium/PremiumContent.tsx
+++ b/components/premium/PremiumContent.tsx
@@ -1,9 +1,12 @@
'use client';
+import { useState } from 'react';
import { TagManager } from '@/components/home/TagManager';
+import { MovieGrid } from '@/components/home/MovieGrid';
import { PremiumContentGrid } from './PremiumContentGrid';
import { usePremiumTagManager } from '@/lib/hooks/usePremiumTagManager';
import { usePremiumContent } from '@/lib/hooks/usePremiumContent';
+import { usePersonalizedRecommendations } from '@/components/home/hooks/usePersonalizedRecommendations';
interface PremiumContentProps {
onSearch?: (query: string) => void;
@@ -26,6 +29,19 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
handleDragEnd,
} = usePremiumTagManager();
+ const {
+ movies: recommendMovies,
+ loading: recommendLoading,
+ hasMore: recommendHasMore,
+ hasHistory,
+ prefetchRef: recommendPrefetchRef,
+ loadMoreRef: recommendLoadMoreRef,
+ } = usePersonalizedRecommendations(true);
+
+ // Track whether the recommendation tab is active
+ const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
+ const effectiveRecommendSelected = hasHistory && isRecommendSelected;
+
// Get the category value from selected tag
const categoryValue = tags.find(t => t.id === selectedTag)?.value || '';
@@ -35,25 +51,32 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
hasMore,
prefetchRef,
loadMoreRef,
- } = usePremiumContent(categoryValue);
+ } = usePremiumContent(effectiveRecommendSelected ? '' : categoryValue);
const handleVideoClick = (video: any) => {
if (onSearch) {
- onSearch(video.vod_name);
+ onSearch(video.vod_name || video.title);
}
};
+ const handleRecommendSelect = () => {
+ setIsRecommendSelected(true);
+ };
+
+ const handleRegularTagSelect = (tagId: string) => {
+ setIsRecommendSelected(false);
+ setSelectedTag(tagId);
+ };
+
return (
{
- setSelectedTag(tagId);
- }}
+ onTagSelect={handleRegularTagSelect}
onTagDelete={handleDeleteTag}
onToggleManager={() => setShowTagManager(!showTagManager)}
onRestoreDefaults={handleRestoreDefaults}
@@ -61,16 +84,32 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
onAddTag={handleAddTag}
onDragEnd={handleDragEnd}
onJustAddedTagHandled={() => setJustAddedTag(false)}
+ recommendTag={hasHistory ? {
+ label: '为你推荐',
+ isSelected: effectiveRecommendSelected,
+ onSelect: handleRecommendSelect,
+ } : undefined}
/>
-
+ {effectiveRecommendSelected ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/components/search/SearchBox.tsx b/components/search/SearchBox.tsx
index 3a2ea98..8c1d311 100644
--- a/components/search/SearchBox.tsx
+++ b/components/search/SearchBox.tsx
@@ -82,6 +82,7 @@ export function SearchBox({ onSearch, onClear, initialQuery = '', placeholder =
aria-expanded={isDropdownOpen}
aria-controls="search-history-dropdown"
aria-autocomplete="list"
+ data-focusable
/>
diff --git a/lib/contexts/TVContext.tsx b/lib/contexts/TVContext.tsx
new file mode 100644
index 0000000..a8e3379
--- /dev/null
+++ b/lib/contexts/TVContext.tsx
@@ -0,0 +1,25 @@
+/**
+ * TVContext
+ * Provides TV mode detection to the entire app.
+ */
+
+'use client';
+
+import { createContext, useContext, type ReactNode } from 'react';
+import { useTVDetection } from '@/lib/hooks/useTVDetection';
+
+const TVContext = createContext(false);
+
+export function TVProvider({ children }: { children: ReactNode }) {
+ const isTV = useTVDetection();
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useIsTV(): boolean {
+ return useContext(TVContext);
+}
diff --git a/lib/hooks/useSpatialNavigation.ts b/lib/hooks/useSpatialNavigation.ts
new file mode 100644
index 0000000..5e90b77
--- /dev/null
+++ b/lib/hooks/useSpatialNavigation.ts
@@ -0,0 +1,151 @@
+/**
+ * useSpatialNavigation
+ * Provides D-pad/arrow key based 2D spatial navigation for TV mode.
+ * Finds all [data-focusable] elements and navigates between them
+ * based on directional arrow key presses.
+ */
+
+import { useEffect, useCallback } from 'react';
+
+function getRect(el: Element): DOMRect {
+ return el.getBoundingClientRect();
+}
+
+function getCenter(rect: DOMRect): { x: number; y: number } {
+ return {
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2,
+ };
+}
+
+type Direction = 'up' | 'down' | 'left' | 'right';
+
+function findBestCandidate(
+ current: Element,
+ candidates: Element[],
+ direction: Direction
+): Element | null {
+ const currentRect = getRect(current);
+ const currentCenter = getCenter(currentRect);
+
+ let bestElement: Element | null = null;
+ let bestScore = Infinity;
+
+ for (const candidate of candidates) {
+ if (candidate === current) continue;
+
+ const candidateRect = getRect(candidate);
+ const candidateCenter = getCenter(candidateRect);
+
+ const dx = candidateCenter.x - currentCenter.x;
+ const dy = candidateCenter.y - currentCenter.y;
+
+ // Filter by direction
+ let isInDirection = false;
+ switch (direction) {
+ case 'up':
+ isInDirection = dy < -10;
+ break;
+ case 'down':
+ isInDirection = dy > 10;
+ break;
+ case 'left':
+ isInDirection = dx < -10;
+ break;
+ case 'right':
+ isInDirection = dx > 10;
+ break;
+ }
+
+ if (!isInDirection) continue;
+
+ // Weighted distance: favor elements along the primary axis
+ let score: number;
+ if (direction === 'up' || direction === 'down') {
+ score = Math.abs(dy) + Math.abs(dx) * 3;
+ } else {
+ score = Math.abs(dx) + Math.abs(dy) * 3;
+ }
+
+ if (score < bestScore) {
+ bestScore = score;
+ bestElement = candidate;
+ }
+ }
+
+ return bestElement;
+}
+
+export function useSpatialNavigation(enabled: boolean) {
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
+ if (!enabled) return;
+
+ // Skip if target is input/textarea
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+
+ const directionMap: Record = {
+ ArrowUp: 'up',
+ ArrowDown: 'down',
+ ArrowLeft: 'left',
+ ArrowRight: 'right',
+ };
+
+ const direction = directionMap[e.key];
+
+ if (direction) {
+ // Check if the focused element is inside a [data-no-spatial] container
+ const focused = document.activeElement as HTMLElement | null;
+ if (focused?.closest('[data-no-spatial]')) return;
+
+ const focusableElements = Array.from(
+ document.querySelectorAll('[data-focusable]:not([disabled]):not([aria-hidden="true"])')
+ ).filter(el => {
+ // Filter out elements inside [data-no-spatial]
+ if (el.closest('[data-no-spatial]')) return false;
+ // Filter out hidden elements
+ const rect = getRect(el);
+ return rect.width > 0 && rect.height > 0;
+ });
+
+ if (focusableElements.length === 0) return;
+
+ const currentFocused = document.activeElement;
+ const isAlreadyFocused = currentFocused && focusableElements.includes(currentFocused);
+
+ if (!isAlreadyFocused) {
+ // Focus the first element
+ (focusableElements[0] as HTMLElement).focus();
+ e.preventDefault();
+ return;
+ }
+
+ const best = findBestCandidate(currentFocused!, focusableElements, direction);
+ if (best) {
+ (best as HTMLElement).focus();
+ best.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ e.preventDefault();
+ }
+ } else if (e.key === 'Enter') {
+ // Trigger click on focused element
+ const focused = document.activeElement as HTMLElement;
+ if (focused && focused.hasAttribute('data-focusable')) {
+ focused.click();
+ e.preventDefault();
+ }
+ }
+ }, [enabled]);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [enabled, handleKeyDown]);
+}
diff --git a/lib/hooks/useTVDetection.ts b/lib/hooks/useTVDetection.ts
new file mode 100644
index 0000000..e20125d
--- /dev/null
+++ b/lib/hooks/useTVDetection.ts
@@ -0,0 +1,49 @@
+/**
+ * useTVDetection
+ * Detects if the user is on a TV/set-top-box browser.
+ */
+
+import { useState, useEffect } from 'react';
+
+const TV_USER_AGENT_PATTERNS = [
+ /smarttv/i,
+ /tizen/i,
+ /webos/i,
+ /firetv/i,
+ /android tv/i,
+ /googletv/i,
+ /crkey/i, // Chromecast
+ /aftt/i, // Amazon Fire TV Stick
+ /aftm/i, // Amazon Fire TV
+ /bravia/i, // Sony Bravia
+ /netcast/i, // LG NetCast
+ /viera/i, // Panasonic Viera
+ /hbbtv/i,
+];
+
+export function useTVDetection(): boolean {
+ const [isTV, setIsTV] = useState(false);
+
+ useEffect(() => {
+ const ua = navigator.userAgent;
+
+ // Check UA for TV indicators
+ const uaMatch = TV_USER_AGENT_PATTERNS.some(pattern => pattern.test(ua));
+
+ if (uaMatch) {
+ setIsTV(true);
+ return;
+ }
+
+ // Fallback heuristic: large screen + no touch + low pixel density
+ const isLargeScreen = window.innerWidth >= 1280;
+ const hasNoTouch = !('ontouchstart' in window) && navigator.maxTouchPoints === 0;
+ const lowDensity = window.devicePixelRatio <= 1.5;
+
+ if (isLargeScreen && hasNoTouch && lowDensity) {
+ setIsTV(true);
+ }
+ }, []);
+
+ return isTV;
+}
diff --git a/lib/store/history-store.ts b/lib/store/history-store.ts
index 071aaf7..11e023f 100644
--- a/lib/store/history-store.ts
+++ b/lib/store/history-store.ts
@@ -25,7 +25,8 @@ interface HistoryActions {
playbackPosition: number,
duration: number,
poster?: string,
- episodes?: Episode[]
+ episodes?: Episode[],
+ metadata?: { vod_actor?: string; type_name?: string; vod_area?: string }
) => void;
removeFromHistory: (videoId: string | number, source: string) => void;
@@ -61,7 +62,8 @@ const createHistoryStore = (name: string) =>
playbackPosition,
duration,
poster,
- episodes = []
+ episodes = [],
+ metadata
) => {
const showIdentifier = generateShowIdentifier(title, source, videoId);
const timestamp = Date.now();
@@ -84,6 +86,9 @@ const createHistoryStore = (name: string) =>
duration,
timestamp,
episodes: episodes.length > 0 ? episodes : state.viewingHistory[existingIndex].episodes,
+ vod_actor: metadata?.vod_actor ?? state.viewingHistory[existingIndex].vod_actor,
+ type_name: metadata?.type_name ?? state.viewingHistory[existingIndex].type_name,
+ vod_area: metadata?.vod_area ?? state.viewingHistory[existingIndex].vod_area,
};
newHistory = [
@@ -104,6 +109,9 @@ const createHistoryStore = (name: string) =>
poster,
episodes,
showIdentifier,
+ vod_actor: metadata?.vod_actor,
+ type_name: metadata?.type_name,
+ vod_area: metadata?.vod_area,
};
newHistory = [newItem, ...state.viewingHistory];
diff --git a/lib/store/premium-mode-settings.ts b/lib/store/premium-mode-settings.ts
new file mode 100644
index 0000000..8b013ef
--- /dev/null
+++ b/lib/store/premium-mode-settings.ts
@@ -0,0 +1,172 @@
+/**
+ * Premium Mode Settings Store
+ * Stores player/display settings separately for premium mode.
+ * Mirrors the relevant subset of AppSettings but uses its own localStorage key.
+ */
+
+import type { SortOption, SearchDisplayMode, ProxyMode, AdFilterMode } from './settings-store';
+
+const PREMIUM_MODE_SETTINGS_KEY = 'kvideo-premium-mode-settings';
+
+export interface ModeSettings {
+ sortBy: SortOption;
+ autoNextEpisode: boolean;
+ autoSkipIntro: boolean;
+ skipIntroSeconds: number;
+ autoSkipOutro: boolean;
+ skipOutroSeconds: number;
+ showModeIndicator: boolean;
+ adFilterMode: AdFilterMode;
+ fullscreenType: 'auto' | 'native' | 'window';
+ proxyMode: ProxyMode;
+ realtimeLatency: boolean;
+ searchDisplayMode: SearchDisplayMode;
+ episodeReverseOrder: boolean;
+ rememberScrollPosition: boolean;
+ personalizedRecommendations: boolean;
+ danmakuEnabled: boolean;
+ danmakuApiUrl: string;
+ danmakuOpacity: number;
+ danmakuFontSize: number;
+}
+
+function getDefaultModeSettings(): ModeSettings {
+ return {
+ sortBy: 'default',
+ autoNextEpisode: true,
+ autoSkipIntro: false,
+ skipIntroSeconds: 0,
+ autoSkipOutro: false,
+ skipOutroSeconds: 0,
+ showModeIndicator: false,
+ adFilterMode: 'heuristic',
+ fullscreenType: 'auto',
+ proxyMode: 'retry',
+ realtimeLatency: false,
+ searchDisplayMode: 'normal',
+ episodeReverseOrder: false,
+ rememberScrollPosition: true,
+ personalizedRecommendations: true,
+ danmakuEnabled: false,
+ danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '',
+ danmakuOpacity: 0.7,
+ danmakuFontSize: 20,
+ };
+}
+
+export const premiumModeSettingsStore = {
+ getSettings(): ModeSettings {
+ if (typeof window === 'undefined') {
+ return getDefaultModeSettings();
+ }
+
+ const stored = localStorage.getItem(PREMIUM_MODE_SETTINGS_KEY);
+ if (!stored) {
+ return getDefaultModeSettings();
+ }
+
+ try {
+ const parsed = JSON.parse(stored);
+ return {
+ sortBy: parsed.sortBy || 'default',
+ autoNextEpisode: parsed.autoNextEpisode !== undefined ? parsed.autoNextEpisode : true,
+ autoSkipIntro: parsed.autoSkipIntro !== undefined ? parsed.autoSkipIntro : false,
+ skipIntroSeconds: typeof parsed.skipIntroSeconds === 'number' ? parsed.skipIntroSeconds : 0,
+ autoSkipOutro: parsed.autoSkipOutro !== undefined ? parsed.autoSkipOutro : false,
+ skipOutroSeconds: typeof parsed.skipOutroSeconds === 'number' ? parsed.skipOutroSeconds : 0,
+ showModeIndicator: parsed.showModeIndicator !== undefined ? parsed.showModeIndicator : false,
+ adFilterMode: parsed.adFilterMode || 'heuristic',
+ fullscreenType: (parsed.fullscreenType === 'window' || parsed.fullscreenType === 'native' || parsed.fullscreenType === 'auto') ? parsed.fullscreenType : 'auto',
+ proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry',
+ realtimeLatency: parsed.realtimeLatency !== undefined ? parsed.realtimeLatency : false,
+ searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal',
+ episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false,
+ rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true,
+ personalizedRecommendations: parsed.personalizedRecommendations !== undefined ? parsed.personalizedRecommendations : true,
+ danmakuEnabled: parsed.danmakuEnabled !== undefined ? parsed.danmakuEnabled : false,
+ danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''),
+ danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7,
+ danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20,
+ };
+ } catch {
+ return getDefaultModeSettings();
+ }
+ },
+
+ listeners: new Set<() => void>(),
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ },
+
+ notifyListeners(): void {
+ this.listeners.forEach((listener) => listener());
+ },
+
+ saveSettings(settings: ModeSettings): void {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem(PREMIUM_MODE_SETTINGS_KEY, JSON.stringify(settings));
+ this.notifyListeners();
+ }
+ },
+};
+
+/**
+ * Helper to get mode-specific settings from the correct store.
+ * Normal mode reads from settingsStore, premium mode reads from premiumModeSettingsStore.
+ */
+export function getModeSettings(isPremium: boolean): ModeSettings {
+ if (isPremium) {
+ return premiumModeSettingsStore.getSettings();
+ }
+ // For normal mode, extract ModeSettings-shaped data from the main settingsStore
+ // Import dynamically to avoid circular dependencies
+ const { settingsStore } = require('./settings-store');
+ const s = settingsStore.getSettings();
+ return {
+ sortBy: s.sortBy,
+ autoNextEpisode: s.autoNextEpisode,
+ autoSkipIntro: s.autoSkipIntro,
+ skipIntroSeconds: s.skipIntroSeconds,
+ autoSkipOutro: s.autoSkipOutro,
+ skipOutroSeconds: s.skipOutroSeconds,
+ showModeIndicator: s.showModeIndicator,
+ adFilterMode: s.adFilterMode,
+ fullscreenType: s.fullscreenType,
+ proxyMode: s.proxyMode,
+ realtimeLatency: s.realtimeLatency,
+ searchDisplayMode: s.searchDisplayMode,
+ episodeReverseOrder: s.episodeReverseOrder,
+ rememberScrollPosition: s.rememberScrollPosition,
+ personalizedRecommendations: s.personalizedRecommendations,
+ danmakuEnabled: s.danmakuEnabled,
+ danmakuApiUrl: s.danmakuApiUrl,
+ danmakuOpacity: s.danmakuOpacity,
+ danmakuFontSize: s.danmakuFontSize,
+ };
+}
+
+/**
+ * Helper to get the settings store for a given mode.
+ */
+export function getModeSettingsStore(isPremium: boolean) {
+ if (isPremium) {
+ return premiumModeSettingsStore;
+ }
+ // Return a wrapper around the main settingsStore that conforms to the same interface
+ const { settingsStore } = require('./settings-store');
+ return {
+ getSettings: () => getModeSettings(false),
+ subscribe: (listener: () => void) => settingsStore.subscribe(listener),
+ saveSettings: (modeSettings: ModeSettings) => {
+ const current = settingsStore.getSettings();
+ settingsStore.saveSettings({
+ ...current,
+ ...modeSettings,
+ });
+ },
+ };
+}
diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts
index 15c027a..9d54686 100644
--- a/lib/store/settings-store.ts
+++ b/lib/store/settings-store.ts
@@ -45,6 +45,7 @@ export interface AppSettings {
fullscreenType: 'auto' | 'native' | 'window'; // Fullscreen mode preference: 'auto' (native on desktop, window on mobile) | 'native' | 'window'
proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always'
rememberScrollPosition: boolean; // Remember scroll position when navigating back or refreshing
+ personalizedRecommendations: boolean; // Show personalized recommendations based on watch history
// Danmaku settings
danmakuEnabled: boolean; // Show danmaku overlay on video
danmakuApiUrl: string; // Self-hosted danmaku API endpoint
@@ -122,6 +123,7 @@ function getDefaultAppSettings(): AppSettings {
fullscreenType: 'auto',
proxyMode: 'retry',
rememberScrollPosition: true,
+ personalizedRecommendations: true,
danmakuEnabled: false,
danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '',
danmakuOpacity: 0.7,
@@ -202,6 +204,7 @@ export const settingsStore = {
fullscreenType: (parsed.fullscreenType === 'window' || parsed.fullscreenType === 'native' || parsed.fullscreenType === 'auto') ? parsed.fullscreenType : 'auto',
proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry',
rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true,
+ personalizedRecommendations: parsed.personalizedRecommendations !== undefined ? parsed.personalizedRecommendations : true,
danmakuEnabled: parsed.danmakuEnabled !== undefined ? parsed.danmakuEnabled : false,
danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''),
danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7,
diff --git a/lib/types/index.ts b/lib/types/index.ts
index 2a52ed5..3a69818 100644
--- a/lib/types/index.ts
+++ b/lib/types/index.ts
@@ -101,6 +101,9 @@ export interface VideoHistoryItem {
poster?: string;
episodes: Episode[];
showIdentifier: string; // Unique identifier for deduplication
+ vod_actor?: string;
+ type_name?: string;
+ vod_area?: string;
}
// Favorite Entry
diff --git a/lib/utils/recommendation-engine.ts b/lib/utils/recommendation-engine.ts
new file mode 100644
index 0000000..3e67506
--- /dev/null
+++ b/lib/utils/recommendation-engine.ts
@@ -0,0 +1,198 @@
+/**
+ * Recommendation Engine
+ * Analyzes viewing history to generate personalized content recommendations.
+ *
+ * How it works:
+ * 1. ANALYSIS: Scans all history items, counts frequency of genres (type_name),
+ * actors (vod_actor), and regions (vod_area).
+ * 2. QUERY GENERATION: Produces up to 5 recommendation queries ranked by relevance:
+ * - Top 2 genres by watch count (threshold: 1+)
+ * - Top 1-2 actors if they appear across 2+ different videos
+ * - Top region if 3+ videos are from that region
+ * 3. RANDOMIZATION: Each query gets a random page_start offset (0-40) so the
+ * Douban API returns different results on each page load.
+ * 4. INTERLEAVING: Results from all queries are round-robin interleaved with
+ * shuffled pick order per round — e.g. [B,A,C,A,C,B] instead of [A,B,C,A,B,C]
+ * 5. DEDUPLICATION: Already-watched titles are filtered out, and duplicate movies
+ * across different queries are removed.
+ * 6. PAGINATION: Supports page-based loading — each "page" fetches a new batch
+ * from all queries with incremented offsets.
+ */
+
+import type { VideoHistoryItem } from '@/lib/types';
+
+export interface RecommendationQuery {
+ label: string;
+ tag: string;
+ type: 'movie' | 'tv';
+ /** Random offset for Douban API pagination to vary results */
+ pageStart: number;
+}
+
+/**
+ * Analyze viewing history and generate recommendation queries.
+ * Returns up to 5 queries based on top genres, actors, and regions.
+ */
+export function generateRecommendations(
+ history: VideoHistoryItem[]
+): RecommendationQuery[] {
+ if (history.length === 0) return [];
+
+ const queries: RecommendationQuery[] = [];
+
+ // Count genres
+ const genreCounts = new Map();
+ // Count actors
+ const actorCounts = new Map();
+ // Count regions
+ const areaCounts = new Map();
+
+ for (const item of history) {
+ if (item.type_name) {
+ const genre = item.type_name.trim();
+ if (genre) {
+ genreCounts.set(genre, (genreCounts.get(genre) || 0) + 1);
+ }
+ }
+
+ if (item.vod_actor) {
+ const actors = item.vod_actor.split(/[,,/]/).map(s => s.trim()).filter(Boolean);
+ for (const actor of actors.slice(0, 3)) {
+ actorCounts.set(actor, (actorCounts.get(actor) || 0) + 1);
+ }
+ }
+
+ if (item.vod_area) {
+ const area = item.vod_area.trim();
+ if (area) {
+ areaCounts.set(area, (areaCounts.get(area) || 0) + 1);
+ }
+ }
+ }
+
+ // Top 2 genres
+ const sortedGenres = [...genreCounts.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 2);
+
+ for (const [genre, count] of sortedGenres) {
+ if (count >= 1) {
+ const type = genre.includes('剧') || genre.includes('电视') ? 'tv' : 'movie';
+ queries.push({
+ label: `${genre}推荐`,
+ tag: genre,
+ type,
+ pageStart: Math.floor(Math.random() * 40),
+ });
+ }
+ }
+
+ // Top 1-2 actors (if appears in 2+ videos)
+ const sortedActors = [...actorCounts.entries()]
+ .sort((a, b) => b[1] - a[1]);
+ const actorsToAdd = sortedActors.filter(([, count]) => count >= 2).slice(0, 2);
+ for (const [actor] of actorsToAdd) {
+ queries.push({
+ label: `${actor}的作品`,
+ tag: actor,
+ type: 'movie',
+ pageStart: Math.floor(Math.random() * 20),
+ });
+ }
+
+ // Top region (if 3+ videos)
+ const sortedAreas = [...areaCounts.entries()]
+ .sort((a, b) => b[1] - a[1]);
+ if (sortedAreas.length > 0 && sortedAreas[0][1] >= 3) {
+ queries.push({
+ label: `${sortedAreas[0][0]}热门`,
+ tag: sortedAreas[0][0],
+ type: 'movie',
+ pageStart: Math.floor(Math.random() * 40),
+ });
+ }
+
+ return queries.slice(0, 5);
+}
+
+/**
+ * Collect titles the user has already watched for exclusion.
+ */
+export function getWatchedTitles(history: VideoHistoryItem[]): Set {
+ const titles = new Set();
+ for (const item of history) {
+ if (item.title) {
+ titles.add(item.title.toLowerCase().trim());
+ }
+ }
+ return titles;
+}
+
+interface InterleavedMovie {
+ id: string;
+ title: string;
+ cover: string;
+ rate: string;
+ url: string;
+ /** Which recommendation query this came from */
+ sourceLabel: string;
+}
+
+/**
+ * Fisher-Yates shuffle for an array (in-place).
+ */
+function shuffleArray(arr: T[]): T[] {
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ }
+ return arr;
+}
+
+/**
+ * Round-robin interleave movies from multiple query result arrays,
+ * with shuffled pick order per round for better variety.
+ * Removes duplicates (by title) and already-watched titles.
+ *
+ * Example with 3 sources [A1,A2,A3], [B1,B2], [C1]:
+ * Round 0 (shuffled): → B1, A1, C1
+ * Round 1 (shuffled): → A2, B2
+ * Round 2 (shuffled): → A3
+ */
+export function interleaveResults(
+ resultsByQuery: { label: string; movies: Array<{ id: string; title: string; cover: string; rate: string; url: string }> }[],
+ watchedTitles: Set
+): InterleavedMovie[] {
+ const interleaved: InterleavedMovie[] = [];
+ const seenTitles = new Set();
+
+ // Find the max length across all result arrays
+ const maxLen = Math.max(...resultsByQuery.map(r => r.movies.length), 0);
+ const numQueries = resultsByQuery.length;
+
+ for (let i = 0; i < maxLen; i++) {
+ // Shuffle the pick order for this round
+ const indices = Array.from({ length: numQueries }, (_, idx) => idx);
+ shuffleArray(indices);
+
+ for (const idx of indices) {
+ const result = resultsByQuery[idx];
+ if (i >= result.movies.length) continue;
+
+ const movie = result.movies[i];
+ const titleKey = movie.title.toLowerCase().trim();
+
+ // Skip duplicates and already-watched
+ if (seenTitles.has(titleKey)) continue;
+ if (watchedTitles.has(titleKey)) continue;
+
+ seenTitles.add(titleKey);
+ interleaved.push({
+ ...movie,
+ sourceLabel: result.label,
+ });
+ }
+ }
+
+ return interleaved;
+}
diff --git a/package-lock.json b/package-lock.json
index 45ffa38..7bf2062 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "kvideo",
- "version": "4.3.1",
+ "version": "4.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
- "version": "4.3.1",
+ "version": "4.3.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
diff --git a/package.json b/package.json
index 60e46b2..d8371ab 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "kvideo",
- "version": "4.3.1",
+ "version": "4.3.2",
"private": true,
"scripts": {
"dev": "next dev",