mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 04:03:42 +08:00
feat: Add initial support for Android TV and Apple TV platforms, introduce a dedicated premium mode settings store, and implement personalized video recommendations.
This commit is contained in:
@@ -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 (
|
||||
<TVContext.Provider value={isTV}>
|
||||
{children}
|
||||
</TVContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsTV(): boolean {
|
||||
return useContext(TVContext);
|
||||
}
|
||||
@@ -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<string, Direction> = {
|
||||
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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, number>();
|
||||
// Count actors
|
||||
const actorCounts = new Map<string, number>();
|
||||
// Count regions
|
||||
const areaCounts = new Map<string, number>();
|
||||
|
||||
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<string> {
|
||||
const titles = new Set<string>();
|
||||
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<T>(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<string>
|
||||
): InterleavedMovie[] {
|
||||
const interleaved: InterleavedMovie[] = [];
|
||||
const seenTitles = new Set<string>();
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user