From 338981dadbbfff059d6ab109f811f7f9937f2fb4 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 17 Feb 2026 12:00:07 +0800 Subject: [PATCH] feat: Add initial support for Android TV and Apple TV platforms, introduce a dedicated premium mode settings store, and implement personalized video recommendations. --- README.md | 108 +++++++++- android-tv/app/build.gradle.kts | 41 ++++ android-tv/app/src/main/AndroidManifest.xml | 44 ++++ .../main/java/com/kvideo/tv/MainActivity.kt | 97 +++++++++ .../app/src/main/res/layout/activity_main.xml | 11 + .../app/src/main/res/values/strings.xml | 3 + android-tv/app/src/main/res/values/styles.xml | 7 + android-tv/build.gradle.kts | 4 + android-tv/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android-tv/settings.gradle.kts | 17 ++ app/layout.tsx | 17 +- app/page.tsx | 6 +- app/player/page.tsx | 15 +- app/premium/page.tsx | 4 +- .../settings/hooks/usePremiumSettingsPage.ts | 102 ++++++++- app/premium/settings/page.tsx | 51 ++++- app/styles/base.css | 30 +++ apple-tv/KVideoTV/KVideoTV/ContentView.swift | 53 +++++ apple-tv/KVideoTV/KVideoTV/KVideoTVApp.swift | 11 + apple-tv/README.md | 40 ++++ components/TVNavigationInitializer.tsx | 29 +++ components/home/MovieCard.tsx | 1 + components/home/PopularFeatures.tsx | 133 ++++++++---- components/home/TagList.tsx | 27 +++ components/home/TagManager.tsx | 9 + .../hooks/usePersonalizedRecommendations.ts | 188 +++++++++++++++++ components/layout/Navbar.tsx | 2 + components/player/VideoMetadata.tsx | 44 +++- components/player/VideoPlayer.tsx | 12 +- components/premium/PremiumContent.tsx | 67 ++++-- components/search/SearchBox.tsx | 1 + lib/contexts/TVContext.tsx | 25 +++ lib/hooks/useSpatialNavigation.ts | 151 +++++++++++++ lib/hooks/useTVDetection.ts | 49 +++++ lib/store/history-store.ts | 12 +- lib/store/premium-mode-settings.ts | 172 +++++++++++++++ lib/store/settings-store.ts | 3 + lib/types/index.ts | 3 + lib/utils/recommendation-engine.ts | 198 ++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 42 files changed, 1706 insertions(+), 95 deletions(-) create mode 100644 android-tv/app/build.gradle.kts create mode 100644 android-tv/app/src/main/AndroidManifest.xml create mode 100644 android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt create mode 100644 android-tv/app/src/main/res/layout/activity_main.xml create mode 100644 android-tv/app/src/main/res/values/strings.xml create mode 100644 android-tv/app/src/main/res/values/styles.xml create mode 100644 android-tv/build.gradle.kts create mode 100644 android-tv/gradle.properties create mode 100644 android-tv/gradle/wrapper/gradle-wrapper.properties create mode 100644 android-tv/settings.gradle.kts create mode 100644 apple-tv/KVideoTV/KVideoTV/ContentView.swift create mode 100644 apple-tv/KVideoTV/KVideoTV/KVideoTVApp.swift create mode 100644 apple-tv/README.md create mode 100644 components/TVNavigationInitializer.tsx create mode 100644 components/home/hooks/usePersonalizedRecommendations.ts create mode 100644 lib/contexts/TVContext.tsx create mode 100644 lib/hooks/useSpatialNavigation.ts create mode 100644 lib/hooks/useTVDetection.ts create mode 100644 lib/store/premium-mode-settings.ts create mode 100644 lib/utils/recommendation-engine.ts diff --git a/README.md b/README.md index 45e3056..9b490a1 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,16 @@ - **详细影视信息**:自动获取豆瓣评分、演员阵容、剧情简介等详细信息 - **推荐系统**:基于豆瓣数据的相关推荐 - **自定义标签管理**:支持拖拽排序的标签管理器,自定义首页推荐分类 +- **可点击演员/导演**:播放页面的演员、导演名字可直接点击搜索其他作品 + +### 个性化推荐 + +- **基于观看历史**:根据观看历史自动分析偏好,推荐相关影视内容 +- **智能分析**:分析最常观看的类型、演员和地区,生成精准推荐 +- **标签集成**:推荐作为首个标签「为你推荐」出现在标签栏中(需观看 2 部以上作品) +- **自动加载**:无限滚动自动加载更多推荐内容 +- **独立模式**:普通模式和高级模式的推荐互相独立 +- **缓存优化**:推荐结果缓存 30 分钟,避免重复请求 ### 收藏管理 @@ -76,9 +86,10 @@ ### 响应式设计 -- **全端适配**:完美支持桌面、平板和移动设备 +- **全端适配**:完美支持桌面、平板、移动设备和 TV/机顶盒 - **移动优先**:专门的移动端组件和交互设计 - **触摸优化**:针对触摸屏优化的手势和交互 +- **TV 适配**:遥控器方向键导航和大屏 UI 优化 ### 主题系统 @@ -102,7 +113,31 @@ - **独立入口**:在浏览器地址栏直接输入 `/premium` 即可进入独立的高级视频专区 - **内容隔离**:高级内容与普通内容完全物理隔离,互不干扰 -- **专属设置**:拥有独立的内容源管理和功能设置 +- **专属设置**:拥有独立的内容源管理和功能设置(播放器、显示、弹幕等设置完全独立) +- **独立推荐**:基于高级模式观看历史的个性化推荐,与普通模式互不影响 + +### TV/大屏适配 + +- **自动检测**:自动检测 TV 浏览器(Smart TV、Tizen、WebOS、Fire TV 等) +- **空间导航**:支持遥控器/方向键在页面元素间导航 +- **10 英尺 UI**:TV 模式下自动放大字体、交互元素和间距 +- **焦点高亮**:TV 模式下聚焦元素显示醒目的高亮边框和缩放效果 +- **播放器兼容**:播放器区域不受空间导航干扰,方向键正常控制播放 + +### Android TV 应用 + +- **WebView 封装**:基于 Android WebView 的轻量 APK,直接加载 KVideo 网页 +- **遥控器支持**:D-pad 中心键映射为 Enter,Back 键映射为网页后退 +- **全屏沉浸**:自动横屏、全屏、硬件加速 +- **Leanback 启动器**:支持从 Android TV 主屏直接启动 +- **可配置 URL**:在 `MainActivity.kt` 中修改 `KVIDEO_URL` 常量指向你的部署实例 + +### Apple TV 应用 + +- **WKWebView 封装**:基于 tvOS WKWebView 的轻量 SwiftUI 应用,直接加载 KVideo 网页 +- **遥控器支持**:滑动手势映射为滚动,点击映射为聚焦/选择,Menu 按钮支持网页后退 +- **TV 模式注入**:页面加载后自动注入 `tv-mode` CSS 类,激活大屏优化样式 +- **可配置 URL**:在 `ContentView.swift` 中修改 `kvideoURL` 常量指向你的部署实例 ### 广告过滤 @@ -502,6 +537,75 @@ npm start 应用将在 `http://localhost:3000` 启动。 +#### 选项 5:Android TV APK 构建 + +项目内置了一个轻量的 Android TV WebView 壳应用,可以将 KVideo 打包成 APK 安装到 Android TV 或机顶盒上。 + +**前置要求:** + +- [Android Studio](https://developer.android.com/studio)(推荐)或 Android SDK Command-line Tools +- JDK 17+ + +**步骤:** + +1. **修改目标 URL**:编辑 `android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt`,将 `KVIDEO_URL` 改为你的部署地址: + ```kotlin + private const val KVIDEO_URL = "https://your-kvideo-instance.com" + ``` + +2. **使用 Android Studio 构建(推荐)**: + - 用 Android Studio 打开 `android-tv/` 目录 + - 等待 Gradle 同步完成 + - 点击 **Build → Build Bundle(s) / APK(s) → Build APK(s)** + - APK 输出在 `android-tv/app/build/outputs/apk/debug/app-debug.apk` + +3. **使用命令行构建**: + ```bash + cd android-tv + ./gradlew assembleDebug + ``` + APK 输出在 `app/build/outputs/apk/debug/app-debug.apk` + +4. **安装到 Android TV**: + ```bash + adb install app/build/outputs/apk/debug/app-debug.apk + ``` + 或通过 U 盘、文件管理器等方式侧载安装。 + +> **注意**:此 APK 是一个 WebView 壳应用,需要你的 KVideo 实例已经部署并可访问。APK 本身不包含 KVideo 代码,仅作为 TV 端的浏览器入口。 + +#### 选项 6:Apple TV 应用构建 + +项目内置了一个轻量的 tvOS WKWebView 壳应用,可以将 KVideo 安装到 Apple TV 上。 + +**前置要求:** + +- macOS + Xcode 15+ +- Apple Developer 账号(免费账号即可侧载到个人设备) + +**步骤:** + +1. **创建 Xcode 项目**:打开 Xcode → **File → New → Project** → 选择 **tvOS → App** → 设置 Product Name 为 `KVideoTV`,Interface 选 **SwiftUI**,Language 选 **Swift** + +2. **替换源文件**:将项目中 `apple-tv/KVideoTV/KVideoTV/` 目录下的 `KVideoTVApp.swift` 和 `ContentView.swift` 复制替换 Xcode 生成的同名文件 + +3. **修改目标 URL**:编辑 `ContentView.swift`,将 `kvideoURL` 改为你的部署地址: + ```swift + let kvideoURL = "https://your-kvideo-instance.com" + ``` + +4. **设置部署目标**:将 Deployment Target 设置为 **tvOS 16.0** 或更高 + +5. **构建运行**:连接 Apple TV(或使用 tvOS 模拟器),按 **Cmd+R** 构建运行 + +**工作原理:** +- 全屏 `WKWebView` 加载 KVideo URL +- 页面加载后自动注入 `tv-mode` CSS 类,激活 TV 优化样式 +- Apple TV 遥控器滑动手势映射为滚动,点击映射为聚焦/选择 +- Menu 按钮支持网页后退导航 + +> **注意**:Apple TV 应用如果仅是 Web 壳应用,不可上架 App Store。此功能仅供个人侧载使用。也可以直接从 iPhone/iPad/Mac 使用 AirPlay 投屏,无需此应用。 + ## 如何更新 ### Vercel 部署 diff --git a/android-tv/app/build.gradle.kts b/android-tv/app/build.gradle.kts new file mode 100644 index 0000000..b8bc021 --- /dev/null +++ b/android-tv/app/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id("com.android.application") version "8.2.0" + id("org.jetbrains.kotlin.android") version "1.9.22" +} + +android { + namespace = "com.kvideo.tv" + compileSdk = 34 + + defaultConfig { + applicationId = "com.kvideo.tv" + minSdk = 21 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt") + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.activity:activity-ktx:1.8.2") + implementation("androidx.webkit:webkit:1.9.0") +} diff --git a/android-tv/app/src/main/AndroidManifest.xml b/android-tv/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2dfe8bc --- /dev/null +++ b/android-tv/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt b/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt new file mode 100644 index 0000000..8d29560 --- /dev/null +++ b/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt @@ -0,0 +1,97 @@ +package com.kvideo.tv + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import android.view.WindowManager +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity + +class MainActivity : ComponentActivity() { + + companion object { + /** + * The URL of your deployed KVideo instance. + * Change this to your own domain or IP address. + */ + private const val KVIDEO_URL = "https://kvideo.example.com" + } + + private lateinit var webView: WebView + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Fullscreen immersive mode + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + ) + + setContentView(R.layout.activity_main) + webView = findViewById(R.id.webview) + + webView.apply { + setLayerType(View.LAYER_TYPE_HARDWARE, null) + + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + mediaPlaybackRequiresUserGesture = false + loadWithOverviewMode = true + useWideViewPort = true + cacheMode = WebSettings.LOAD_DEFAULT + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + databaseEnabled = true + } + + webViewClient = WebViewClient() + webChromeClient = WebChromeClient() + + loadUrl(KVIDEO_URL) + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + // Map D-pad center to Enter for spatial navigation + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { + webView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + return true + } + return super.onKeyDown(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { + webView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + return true + } + return super.onKeyUp(keyCode, event) + } + + @Deprecated("Use OnBackPressedDispatcher") + override fun onBackPressed() { + if (webView.canGoBack()) { + webView.goBack() + } else { + @Suppress("DEPRECATION") + super.onBackPressed() + } + } + + override fun onDestroy() { + webView.destroy() + super.onDestroy() + } +} diff --git a/android-tv/app/src/main/res/layout/activity_main.xml b/android-tv/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..298970e --- /dev/null +++ b/android-tv/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android-tv/app/src/main/res/values/strings.xml b/android-tv/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..393f0ef --- /dev/null +++ b/android-tv/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + KVideo + diff --git a/android-tv/app/src/main/res/values/styles.xml b/android-tv/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..b1402e1 --- /dev/null +++ b/android-tv/app/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + diff --git a/android-tv/build.gradle.kts b/android-tv/build.gradle.kts new file mode 100644 index 0000000..0836ea8 --- /dev/null +++ b/android-tv/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.2.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} diff --git a/android-tv/gradle.properties b/android-tv/gradle.properties new file mode 100644 index 0000000..32a0c14 --- /dev/null +++ b/android-tv/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048m diff --git a/android-tv/gradle/wrapper/gradle-wrapper.properties b/android-tv/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a595206 --- /dev/null +++ b/android-tv/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-tv/settings.gradle.kts b/android-tv/settings.gradle.kts new file mode 100644 index 0000000..2a4af01 --- /dev/null +++ b/android-tv/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolution { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "KVideo-TV" +include(":app") diff --git a/app/layout.tsx b/app/layout.tsx index bdc8f34..2baf886 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/ThemeProvider"; +import { TVProvider } from "@/lib/contexts/TVContext"; +import { TVNavigationInitializer } from "@/components/TVNavigationInitializer"; import { Analytics } from "@vercel/analytics/react"; import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister"; import { PasswordGate } from "@/components/PasswordGate"; @@ -95,12 +97,15 @@ export default function RootLayout({ suppressHydrationWarning > - - - {children} - - - + + + + + {children} + + + + diff --git a/app/page.tsx b/app/page.tsx index 8133927..7102e61 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -69,7 +69,11 @@ function HomePage() { )} {/* Popular Features - Homepage */} - {!loading && !hasSearched && } + {!loading && !hasSearched && ( + <> + + + )} {/* No Results */} {!loading && hasSearched && results.length === 0 && ( diff --git a/app/player/page.tsx b/app/player/page.tsx index 4b9acdc..6a30e4f 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -14,6 +14,7 @@ import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; import { FavoriteButton } from '@/components/favorites/FavoriteButton'; import { PlayerNavbar } from '@/components/player/PlayerNavbar'; import { settingsStore } from '@/lib/store/settings-store'; +import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings'; import { SegmentedControl } from '@/components/ui/SegmentedControl'; import Image from 'next/image'; @@ -29,9 +30,10 @@ function PlayerContent() { const episodeParam = searchParams.get('episode'); const groupedSourcesParam = searchParams.get('groupedSources'); - // Track settings + // Track settings - use mode-specific store + const modeStore = isPremium ? premiumModeSettingsStore : settingsStore; const [isReversed, setIsReversed] = useState(() => - typeof window !== 'undefined' ? settingsStore.getSettings().episodeReverseOrder : false + typeof window !== 'undefined' ? modeStore.getSettings().episodeReverseOrder : false ); // Mobile tab state @@ -39,7 +41,7 @@ function PlayerContent() { // Sync with store changes if any (though usually it's one-way from UI to store) useEffect(() => { - setIsReversed(settingsStore.getSettings().episodeReverseOrder); + setIsReversed(modeStore.getSettings().episodeReverseOrder); }, []); // Redirect if no video ID or source @@ -105,7 +107,8 @@ function PlayerContent() { 0, // Initial playback position 0, // Will be updated by VideoPlayer videoData.vod_pic, - mappedEpisodes + mappedEpisodes, + { vod_actor: videoData.vod_actor, type_name: videoData.type_name, vod_area: videoData.vod_area } ); } }, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]); @@ -123,8 +126,8 @@ function PlayerContent() { const handleToggleReverse = (reversed: boolean) => { setIsReversed(reversed); - const settings = settingsStore.getSettings(); - settingsStore.saveSettings({ + const settings = modeStore.getSettings(); + modeStore.saveSettings({ ...settings, episodeReverseOrder: reversed }); diff --git a/app/premium/page.tsx b/app/premium/page.tsx index 9408fe3..1e2661f 100644 --- a/app/premium/page.tsx +++ b/app/premium/page.tsx @@ -64,7 +64,9 @@ function PremiumHomePage() { {/* Premium Content - Trending and Latest */} {!loading && !hasSearched && ( - + <> + + )} diff --git a/app/premium/settings/hooks/usePremiumSettingsPage.ts b/app/premium/settings/hooks/usePremiumSettingsPage.ts index 2426411..3f51ec1 100644 --- a/app/premium/settings/hooks/usePremiumSettingsPage.ts +++ b/app/premium/settings/hooks/usePremiumSettingsPage.ts @@ -1,20 +1,45 @@ import { useState, useEffect } from 'react'; -import { settingsStore, getDefaultPremiumSources, type SortOption } from '@/lib/store/settings-store'; +import { settingsStore, getDefaultPremiumSources, type SortOption, type SearchDisplayMode, type ProxyMode } from '@/lib/store/settings-store'; +import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings'; import type { VideoSource } from '@/lib/types'; export function usePremiumSettingsPage() { const [premiumSources, setPremiumSources] = useState([]); - const [sortBy, setSortBy] = useState('default'); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false); const [editingSource, setEditingSource] = useState(null); + // Display settings (from premium mode settings store) + const [realtimeLatency, setRealtimeLatency] = useState(false); + const [searchDisplayMode, setSearchDisplayMode] = useState('normal'); + const [fullscreenType, setFullscreenType] = useState<'auto' | 'native' | 'window'>('auto'); + const [proxyMode, setProxyMode] = useState('retry'); + const [rememberScrollPosition, setRememberScrollPosition] = useState(true); + + // Danmaku settings + const [danmakuApiUrl, setDanmakuApiUrl] = useState(''); + const [danmakuOpacity, setDanmakuOpacity] = useState(0.7); + const [danmakuFontSize, setDanmakuFontSize] = useState(20); + useEffect(() => { + // Sources come from main settings store const settings = settingsStore.getSettings(); setPremiumSources(settings.premiumSources || []); - setSortBy(settings.sortBy); + + // Mode-specific settings come from premium mode settings store + const modeSettings = premiumModeSettingsStore.getSettings(); + setRealtimeLatency(modeSettings.realtimeLatency); + setSearchDisplayMode(modeSettings.searchDisplayMode); + setFullscreenType(modeSettings.fullscreenType); + setProxyMode(modeSettings.proxyMode); + setRememberScrollPosition(modeSettings.rememberScrollPosition); + setDanmakuApiUrl(modeSettings.danmakuApiUrl); + setDanmakuOpacity(modeSettings.danmakuOpacity); + setDanmakuFontSize(modeSettings.danmakuFontSize); }, []); + // --- Source management (uses main settingsStore) --- + const handleSourcesChange = (newSources: VideoSource[]) => { setPremiumSources(newSources); const currentSettings = settingsStore.getSettings(); @@ -44,9 +69,60 @@ export function usePremiumSettingsPage() { setIsRestoreDefaultsDialogOpen(false); }; + // --- Premium mode settings helpers --- + + const savePremiumModeSetting = (partial: Record) => { + const current = premiumModeSettingsStore.getSettings(); + premiumModeSettingsStore.saveSettings({ ...current, ...partial }); + }; + + // --- Display settings handlers --- + + const handleRealtimeLatencyChange = (enabled: boolean) => { + setRealtimeLatency(enabled); + savePremiumModeSetting({ realtimeLatency: enabled }); + }; + + const handleSearchDisplayModeChange = (mode: SearchDisplayMode) => { + setSearchDisplayMode(mode); + savePremiumModeSetting({ searchDisplayMode: mode }); + }; + + const handleFullscreenTypeChange = (type: 'auto' | 'native' | 'window') => { + setFullscreenType(type); + savePremiumModeSetting({ fullscreenType: type }); + }; + + const handleProxyModeChange = (mode: ProxyMode) => { + setProxyMode(mode); + savePremiumModeSetting({ proxyMode: mode }); + }; + + const handleRememberScrollPositionChange = (enabled: boolean) => { + setRememberScrollPosition(enabled); + savePremiumModeSetting({ rememberScrollPosition: enabled }); + }; + + // --- Danmaku settings handlers --- + + const handleDanmakuApiUrlChange = (url: string) => { + setDanmakuApiUrl(url); + savePremiumModeSetting({ danmakuApiUrl: url }); + }; + + const handleDanmakuOpacityChange = (value: number) => { + const clamped = Math.max(0.1, Math.min(1, value)); + setDanmakuOpacity(clamped); + savePremiumModeSetting({ danmakuOpacity: clamped }); + }; + + const handleDanmakuFontSizeChange = (value: number) => { + setDanmakuFontSize(value); + savePremiumModeSetting({ danmakuFontSize: value }); + }; + return { premiumSources, - sortBy, isAddModalOpen, isRestoreDefaultsDialogOpen, setIsAddModalOpen, @@ -57,5 +133,23 @@ export function usePremiumSettingsPage() { handleRestoreDefaults, editingSource, handleEditSource, + // Display settings + realtimeLatency, + searchDisplayMode, + fullscreenType, + proxyMode, + rememberScrollPosition, + handleRealtimeLatencyChange, + handleSearchDisplayModeChange, + handleFullscreenTypeChange, + handleProxyModeChange, + handleRememberScrollPositionChange, + // Danmaku settings + danmakuApiUrl, + handleDanmakuApiUrlChange, + danmakuOpacity, + handleDanmakuOpacityChange, + danmakuFontSize, + handleDanmakuFontSizeChange, }; } diff --git a/app/premium/settings/page.tsx b/app/premium/settings/page.tsx index 3cb96a2..f59005d 100644 --- a/app/premium/settings/page.tsx +++ b/app/premium/settings/page.tsx @@ -3,7 +3,8 @@ import { AddSourceModal } from '@/components/settings/AddSourceModal'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { PremiumSourceSettings } from '@/components/settings/PremiumSourceSettings'; -import { SettingsHeader } from '@/components/settings/SettingsHeader'; +import { DisplaySettings } from '@/components/settings/DisplaySettings'; +import { PlayerSettings } from '@/components/settings/PlayerSettings'; import { AdminGate } from '@/components/AdminGate'; import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage'; import Link from 'next/link'; @@ -21,13 +22,31 @@ export default function PremiumSettingsPage() { editingSource, handleEditSource, setEditingSource, + // Display settings + realtimeLatency, + searchDisplayMode, + fullscreenType, + proxyMode, + rememberScrollPosition, + handleRealtimeLatencyChange, + handleSearchDisplayModeChange, + handleFullscreenTypeChange, + handleProxyModeChange, + handleRememberScrollPositionChange, + // Danmaku settings + danmakuApiUrl, + handleDanmakuApiUrlChange, + danmakuOpacity, + handleDanmakuOpacityChange, + danmakuFontSize, + handleDanmakuFontSizeChange, } = usePremiumSettingsPage(); return (
- {/* Custom Header for Secret Settings */} + {/* Custom Header for Premium Settings */}
@@ -41,13 +60,37 @@ export default function PremiumSettingsPage() {
-

高级源设置

-

管理高级内容来源

+

高级模式设置

+

管理高级模式的内容源和偏好设置

+ {/* Player Settings */} + + + {/* Display Settings */} + + {/* Premium Source Management */} 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/VideoMetadata.tsx b/components/player/VideoMetadata.tsx index 109eca5..9cba8dc 100644 --- a/components/player/VideoMetadata.tsx +++ b/components/player/VideoMetadata.tsx @@ -1,10 +1,20 @@ 'use client'; +import Link from 'next/link'; import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; import { getSourceName } from '@/lib/utils/source-names'; +/** + * Split person names by common delimiters (comma, Chinese comma, slash). + * Does NOT split by space — Chinese names contain no spaces, and splitting + * by space would break English names like "Tom Hanks". + */ +function splitPersonNames(str: string): string[] { + return str.split(/[,,/]/).map(s => s.trim()).filter(Boolean); +} + interface VideoMetadataProps { videoData: any; source: string | null; @@ -61,16 +71,38 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)

)} {videoData?.vod_actor && ( -

+

主演: - {videoData.vod_actor} -

+ + {splitPersonNames(videoData.vod_actor).map((name) => ( + + {name} + + ))} + +
)} {videoData?.vod_director && ( -

+

导演: - {videoData.vod_director} -

+ + {splitPersonNames(videoData.vod_director).map((name) => ( + + {name} + + ))} + +
)}
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",