mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +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:
@@ -59,6 +59,16 @@
|
|||||||
- **详细影视信息**:自动获取豆瓣评分、演员阵容、剧情简介等详细信息
|
- **详细影视信息**:自动获取豆瓣评分、演员阵容、剧情简介等详细信息
|
||||||
- **推荐系统**:基于豆瓣数据的相关推荐
|
- **推荐系统**:基于豆瓣数据的相关推荐
|
||||||
- **自定义标签管理**:支持拖拽排序的标签管理器,自定义首页推荐分类
|
- **自定义标签管理**:支持拖拽排序的标签管理器,自定义首页推荐分类
|
||||||
|
- **可点击演员/导演**:播放页面的演员、导演名字可直接点击搜索其他作品
|
||||||
|
|
||||||
|
### 个性化推荐
|
||||||
|
|
||||||
|
- **基于观看历史**:根据观看历史自动分析偏好,推荐相关影视内容
|
||||||
|
- **智能分析**:分析最常观看的类型、演员和地区,生成精准推荐
|
||||||
|
- **标签集成**:推荐作为首个标签「为你推荐」出现在标签栏中(需观看 2 部以上作品)
|
||||||
|
- **自动加载**:无限滚动自动加载更多推荐内容
|
||||||
|
- **独立模式**:普通模式和高级模式的推荐互相独立
|
||||||
|
- **缓存优化**:推荐结果缓存 30 分钟,避免重复请求
|
||||||
|
|
||||||
### 收藏管理
|
### 收藏管理
|
||||||
|
|
||||||
@@ -76,9 +86,10 @@
|
|||||||
|
|
||||||
### 响应式设计
|
### 响应式设计
|
||||||
|
|
||||||
- **全端适配**:完美支持桌面、平板和移动设备
|
- **全端适配**:完美支持桌面、平板、移动设备和 TV/机顶盒
|
||||||
- **移动优先**:专门的移动端组件和交互设计
|
- **移动优先**:专门的移动端组件和交互设计
|
||||||
- **触摸优化**:针对触摸屏优化的手势和交互
|
- **触摸优化**:针对触摸屏优化的手势和交互
|
||||||
|
- **TV 适配**:遥控器方向键导航和大屏 UI 优化
|
||||||
|
|
||||||
### 主题系统
|
### 主题系统
|
||||||
|
|
||||||
@@ -102,7 +113,31 @@
|
|||||||
|
|
||||||
- **独立入口**:在浏览器地址栏直接输入 `/premium` 即可进入独立的高级视频专区
|
- **独立入口**:在浏览器地址栏直接输入 `/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` 启动。
|
应用将在 `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 部署
|
### Vercel 部署
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.leanback"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.touchscreen"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="landscape">
|
||||||
|
|
||||||
|
<!-- Leanback launcher (Android TV home) -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Standard launcher fallback -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#000000">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:id="@+id/webview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">KVideo</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowFullscreen">true</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:colorBackground">#000000</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolution {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "KVideo-TV"
|
||||||
|
include(":app")
|
||||||
+11
-6
@@ -3,6 +3,8 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
|
import { TVProvider } from "@/lib/contexts/TVContext";
|
||||||
|
import { TVNavigationInitializer } from "@/components/TVNavigationInitializer";
|
||||||
import { Analytics } from "@vercel/analytics/react";
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
|
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
|
||||||
import { PasswordGate } from "@/components/PasswordGate";
|
import { PasswordGate } from "@/components/PasswordGate";
|
||||||
@@ -95,12 +97,15 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
|
<TVProvider>
|
||||||
<AdKeywordsWrapper />
|
<TVNavigationInitializer />
|
||||||
{children}
|
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
|
||||||
<BackToTop />
|
<AdKeywordsWrapper />
|
||||||
<ScrollPositionManager />
|
{children}
|
||||||
</PasswordGate>
|
<BackToTop />
|
||||||
|
<ScrollPositionManager />
|
||||||
|
</PasswordGate>
|
||||||
|
</TVProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<ServiceWorkerRegister />
|
<ServiceWorkerRegister />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
+5
-1
@@ -69,7 +69,11 @@ function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Popular Features - Homepage */}
|
{/* Popular Features - Homepage */}
|
||||||
{!loading && !hasSearched && <PopularFeatures onSearch={handleSearch} />}
|
{!loading && !hasSearched && (
|
||||||
|
<>
|
||||||
|
<PopularFeatures onSearch={handleSearch} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* No Results */}
|
{/* No Results */}
|
||||||
{!loading && hasSearched && results.length === 0 && (
|
{!loading && hasSearched && results.length === 0 && (
|
||||||
|
|||||||
+9
-6
@@ -14,6 +14,7 @@ import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
|
|||||||
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
||||||
import { PlayerNavbar } from '@/components/player/PlayerNavbar';
|
import { PlayerNavbar } from '@/components/player/PlayerNavbar';
|
||||||
import { settingsStore } from '@/lib/store/settings-store';
|
import { settingsStore } from '@/lib/store/settings-store';
|
||||||
|
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
|
||||||
import { SegmentedControl } from '@/components/ui/SegmentedControl';
|
import { SegmentedControl } from '@/components/ui/SegmentedControl';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
@@ -29,9 +30,10 @@ function PlayerContent() {
|
|||||||
const episodeParam = searchParams.get('episode');
|
const episodeParam = searchParams.get('episode');
|
||||||
const groupedSourcesParam = searchParams.get('groupedSources');
|
const groupedSourcesParam = searchParams.get('groupedSources');
|
||||||
|
|
||||||
// Track settings
|
// Track settings - use mode-specific store
|
||||||
|
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||||
const [isReversed, setIsReversed] = useState(() =>
|
const [isReversed, setIsReversed] = useState(() =>
|
||||||
typeof window !== 'undefined' ? settingsStore.getSettings().episodeReverseOrder : false
|
typeof window !== 'undefined' ? modeStore.getSettings().episodeReverseOrder : false
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mobile tab state
|
// 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)
|
// Sync with store changes if any (though usually it's one-way from UI to store)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsReversed(settingsStore.getSettings().episodeReverseOrder);
|
setIsReversed(modeStore.getSettings().episodeReverseOrder);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Redirect if no video ID or source
|
// Redirect if no video ID or source
|
||||||
@@ -105,7 +107,8 @@ function PlayerContent() {
|
|||||||
0, // Initial playback position
|
0, // Initial playback position
|
||||||
0, // Will be updated by VideoPlayer
|
0, // Will be updated by VideoPlayer
|
||||||
videoData.vod_pic,
|
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]);
|
}, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]);
|
||||||
@@ -123,8 +126,8 @@ function PlayerContent() {
|
|||||||
|
|
||||||
const handleToggleReverse = (reversed: boolean) => {
|
const handleToggleReverse = (reversed: boolean) => {
|
||||||
setIsReversed(reversed);
|
setIsReversed(reversed);
|
||||||
const settings = settingsStore.getSettings();
|
const settings = modeStore.getSettings();
|
||||||
settingsStore.saveSettings({
|
modeStore.saveSettings({
|
||||||
...settings,
|
...settings,
|
||||||
episodeReverseOrder: reversed
|
episodeReverseOrder: reversed
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ function PremiumHomePage() {
|
|||||||
|
|
||||||
{/* Premium Content - Trending and Latest */}
|
{/* Premium Content - Trending and Latest */}
|
||||||
{!loading && !hasSearched && (
|
{!loading && !hasSearched && (
|
||||||
<PremiumContent onSearch={handleSearch} />
|
<>
|
||||||
|
<PremiumContent onSearch={handleSearch} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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';
|
import type { VideoSource } from '@/lib/types';
|
||||||
|
|
||||||
export function usePremiumSettingsPage() {
|
export function usePremiumSettingsPage() {
|
||||||
const [premiumSources, setPremiumSources] = useState<VideoSource[]>([]);
|
const [premiumSources, setPremiumSources] = useState<VideoSource[]>([]);
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('default');
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
|
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
|
||||||
const [editingSource, setEditingSource] = useState<VideoSource | null>(null);
|
const [editingSource, setEditingSource] = useState<VideoSource | null>(null);
|
||||||
|
|
||||||
|
// Display settings (from premium mode settings store)
|
||||||
|
const [realtimeLatency, setRealtimeLatency] = useState(false);
|
||||||
|
const [searchDisplayMode, setSearchDisplayMode] = useState<SearchDisplayMode>('normal');
|
||||||
|
const [fullscreenType, setFullscreenType] = useState<'auto' | 'native' | 'window'>('auto');
|
||||||
|
const [proxyMode, setProxyMode] = useState<ProxyMode>('retry');
|
||||||
|
const [rememberScrollPosition, setRememberScrollPosition] = useState(true);
|
||||||
|
|
||||||
|
// Danmaku settings
|
||||||
|
const [danmakuApiUrl, setDanmakuApiUrl] = useState('');
|
||||||
|
const [danmakuOpacity, setDanmakuOpacity] = useState(0.7);
|
||||||
|
const [danmakuFontSize, setDanmakuFontSize] = useState(20);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Sources come from main settings store
|
||||||
const settings = settingsStore.getSettings();
|
const settings = settingsStore.getSettings();
|
||||||
setPremiumSources(settings.premiumSources || []);
|
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[]) => {
|
const handleSourcesChange = (newSources: VideoSource[]) => {
|
||||||
setPremiumSources(newSources);
|
setPremiumSources(newSources);
|
||||||
const currentSettings = settingsStore.getSettings();
|
const currentSettings = settingsStore.getSettings();
|
||||||
@@ -44,9 +69,60 @@ export function usePremiumSettingsPage() {
|
|||||||
setIsRestoreDefaultsDialogOpen(false);
|
setIsRestoreDefaultsDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Premium mode settings helpers ---
|
||||||
|
|
||||||
|
const savePremiumModeSetting = (partial: Record<string, any>) => {
|
||||||
|
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 {
|
return {
|
||||||
premiumSources,
|
premiumSources,
|
||||||
sortBy,
|
|
||||||
isAddModalOpen,
|
isAddModalOpen,
|
||||||
isRestoreDefaultsDialogOpen,
|
isRestoreDefaultsDialogOpen,
|
||||||
setIsAddModalOpen,
|
setIsAddModalOpen,
|
||||||
@@ -57,5 +133,23 @@ export function usePremiumSettingsPage() {
|
|||||||
handleRestoreDefaults,
|
handleRestoreDefaults,
|
||||||
editingSource,
|
editingSource,
|
||||||
handleEditSource,
|
handleEditSource,
|
||||||
|
// Display settings
|
||||||
|
realtimeLatency,
|
||||||
|
searchDisplayMode,
|
||||||
|
fullscreenType,
|
||||||
|
proxyMode,
|
||||||
|
rememberScrollPosition,
|
||||||
|
handleRealtimeLatencyChange,
|
||||||
|
handleSearchDisplayModeChange,
|
||||||
|
handleFullscreenTypeChange,
|
||||||
|
handleProxyModeChange,
|
||||||
|
handleRememberScrollPositionChange,
|
||||||
|
// Danmaku settings
|
||||||
|
danmakuApiUrl,
|
||||||
|
handleDanmakuApiUrlChange,
|
||||||
|
danmakuOpacity,
|
||||||
|
handleDanmakuOpacityChange,
|
||||||
|
danmakuFontSize,
|
||||||
|
handleDanmakuFontSizeChange,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { AddSourceModal } from '@/components/settings/AddSourceModal';
|
import { AddSourceModal } from '@/components/settings/AddSourceModal';
|
||||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||||
import { PremiumSourceSettings } from '@/components/settings/PremiumSourceSettings';
|
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 { AdminGate } from '@/components/AdminGate';
|
||||||
import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage';
|
import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -21,13 +22,31 @@ export default function PremiumSettingsPage() {
|
|||||||
editingSource,
|
editingSource,
|
||||||
handleEditSource,
|
handleEditSource,
|
||||||
setEditingSource,
|
setEditingSource,
|
||||||
|
// Display settings
|
||||||
|
realtimeLatency,
|
||||||
|
searchDisplayMode,
|
||||||
|
fullscreenType,
|
||||||
|
proxyMode,
|
||||||
|
rememberScrollPosition,
|
||||||
|
handleRealtimeLatencyChange,
|
||||||
|
handleSearchDisplayModeChange,
|
||||||
|
handleFullscreenTypeChange,
|
||||||
|
handleProxyModeChange,
|
||||||
|
handleRememberScrollPositionChange,
|
||||||
|
// Danmaku settings
|
||||||
|
danmakuApiUrl,
|
||||||
|
handleDanmakuApiUrlChange,
|
||||||
|
danmakuOpacity,
|
||||||
|
handleDanmakuOpacityChange,
|
||||||
|
danmakuFontSize,
|
||||||
|
handleDanmakuFontSizeChange,
|
||||||
} = usePremiumSettingsPage();
|
} = usePremiumSettingsPage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminGate>
|
<AdminGate>
|
||||||
<div className="min-h-screen bg-black">
|
<div className="min-h-screen bg-black">
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
|
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
|
||||||
{/* Custom Header for Secret Settings */}
|
{/* Custom Header for Premium Settings */}
|
||||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
|
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -41,13 +60,37 @@ export default function PremiumSettingsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--text-color)]">高级源设置</h1>
|
<h1 className="text-2xl font-bold text-[var(--text-color)]">高级模式设置</h1>
|
||||||
<p className="text-sm text-[var(--text-color-secondary)]">管理高级内容来源</p>
|
<p className="text-sm text-[var(--text-color-secondary)]">管理高级模式的内容源和偏好设置</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Player Settings */}
|
||||||
|
<PlayerSettings
|
||||||
|
fullscreenType={fullscreenType}
|
||||||
|
onFullscreenTypeChange={handleFullscreenTypeChange}
|
||||||
|
proxyMode={proxyMode}
|
||||||
|
onProxyModeChange={handleProxyModeChange}
|
||||||
|
danmakuApiUrl={danmakuApiUrl}
|
||||||
|
onDanmakuApiUrlChange={handleDanmakuApiUrlChange}
|
||||||
|
danmakuOpacity={danmakuOpacity}
|
||||||
|
onDanmakuOpacityChange={handleDanmakuOpacityChange}
|
||||||
|
danmakuFontSize={danmakuFontSize}
|
||||||
|
onDanmakuFontSizeChange={handleDanmakuFontSizeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Display Settings */}
|
||||||
|
<DisplaySettings
|
||||||
|
realtimeLatency={realtimeLatency}
|
||||||
|
searchDisplayMode={searchDisplayMode}
|
||||||
|
rememberScrollPosition={rememberScrollPosition}
|
||||||
|
onRealtimeLatencyChange={handleRealtimeLatencyChange}
|
||||||
|
onSearchDisplayModeChange={handleSearchDisplayModeChange}
|
||||||
|
onRememberScrollPositionChange={handleRememberScrollPositionChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Premium Source Management */}
|
{/* Premium Source Management */}
|
||||||
<PremiumSourceSettings
|
<PremiumSourceSettings
|
||||||
sources={premiumSources}
|
sources={premiumSources}
|
||||||
|
|||||||
@@ -152,4 +152,34 @@ nav {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TV Mode Styles */
|
||||||
|
body.tv-mode [data-focusable]:focus {
|
||||||
|
outline: 3px solid var(--accent-color);
|
||||||
|
outline-offset: 4px;
|
||||||
|
box-shadow: 0 0 0 6px rgba(0, 122, 255, 0.2);
|
||||||
|
transform: scale(1.03);
|
||||||
|
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.tv-mode {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.tv-mode [data-focusable] {
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.tv-mode [class*="grid"] {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.tv-mode [class*="gap-3"] {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.tv-mode [class*="gap-4"] {
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// Change this to your deployed KVideo instance URL
|
||||||
|
let kvideoURL = "https://kvideo.example.com"
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
var body: some View {
|
||||||
|
WebView(url: URL(string: kvideoURL)!)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WebView: UIViewRepresentable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> 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');
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct KVideoTVApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieC
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onMovieClick(movie);
|
onMovieClick(movie);
|
||||||
}}
|
}}
|
||||||
|
data-focusable
|
||||||
className="group cursor-pointer hover:translate-y-[-2px] transition-transform duration-200 ease-out"
|
className="group cursor-pointer hover:translate-y-[-2px] transition-transform duration-200 ease-out"
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* PopularFeatures - Main component for popular movies section
|
* 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';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { TagManager } from './TagManager';
|
import { TagManager } from './TagManager';
|
||||||
import { MovieGrid } from './MovieGrid';
|
import { MovieGrid } from './MovieGrid';
|
||||||
import { useTagManager } from './hooks/useTagManager';
|
import { useTagManager } from './hooks/useTagManager';
|
||||||
import { usePopularMovies } from './hooks/usePopularMovies';
|
import { usePopularMovies } from './hooks/usePopularMovies';
|
||||||
|
import { usePersonalizedRecommendations } from './hooks/usePersonalizedRecommendations';
|
||||||
|
|
||||||
interface PopularFeaturesProps {
|
interface PopularFeaturesProps {
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
@@ -34,13 +37,33 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
|||||||
isLoadingTags,
|
isLoadingTags,
|
||||||
} = useTagManager();
|
} = 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 {
|
const {
|
||||||
movies,
|
movies,
|
||||||
loading,
|
loading,
|
||||||
hasMore,
|
hasMore,
|
||||||
prefetchRef,
|
prefetchRef,
|
||||||
loadMoreRef,
|
loadMoreRef,
|
||||||
} = usePopularMovies(selectedTag, tags, contentType);
|
} = usePopularMovies(
|
||||||
|
effectiveRecommendSelected ? '' : selectedTag,
|
||||||
|
tags,
|
||||||
|
contentType
|
||||||
|
);
|
||||||
|
|
||||||
const handleMovieClick = (movie: any) => {
|
const handleMovieClick = (movie: any) => {
|
||||||
if (onSearch) {
|
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 (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
|
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
|
||||||
<div className="mb-10 flex justify-center">
|
{!effectiveRecommendSelected && (
|
||||||
<div className="relative w-80 p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
|
<div className="mb-10 flex justify-center">
|
||||||
{/* Sliding Indicator */}
|
<div className="relative w-80 p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
|
||||||
<div
|
{/* Sliding Indicator */}
|
||||||
className="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-[var(--accent-color)] rounded-full transition-transform duration-400 cubic-bezier(0.4, 0, 0.2, 1) shadow-[0_0_15px_rgba(0,122,255,0.4)]"
|
<div
|
||||||
style={{
|
className="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-[var(--accent-color)] rounded-full transition-transform duration-400 cubic-bezier(0.4, 0, 0.2, 1) shadow-[0_0_15px_rgba(0,122,255,0.4)]"
|
||||||
transform: `translateX(${contentType === 'movie' ? '4px' : 'calc(100% + 4px)'})`,
|
style={{
|
||||||
}}
|
transform: `translateX(${contentType === 'movie' ? '4px' : 'calc(100% + 4px)'})`,
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setContentType('movie')}
|
onClick={() => setContentType('movie')}
|
||||||
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
电影
|
电影
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setContentType('tv')}
|
onClick={() => setContentType('tv')}
|
||||||
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
电视剧
|
电视剧
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<TagManager
|
<TagManager
|
||||||
tags={tags}
|
tags={tags}
|
||||||
selectedTag={selectedTag}
|
selectedTag={effectiveRecommendSelected ? '' : selectedTag}
|
||||||
showTagManager={showTagManager}
|
showTagManager={showTagManager}
|
||||||
newTagInput={newTagInput}
|
newTagInput={newTagInput}
|
||||||
justAddedTag={justAddedTag}
|
justAddedTag={justAddedTag}
|
||||||
onTagSelect={(tagId) => {
|
onTagSelect={handleRegularTagSelect}
|
||||||
if (tagId === 'custom_高级' || tags.find(t => t.id === tagId)?.label === '高级') {
|
|
||||||
window.location.href = '/premium';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedTag(tagId);
|
|
||||||
}}
|
|
||||||
onTagDelete={handleDeleteTag}
|
onTagDelete={handleDeleteTag}
|
||||||
onToggleManager={() => setShowTagManager(!showTagManager)}
|
onToggleManager={() => setShowTagManager(!showTagManager)}
|
||||||
onRestoreDefaults={handleRestoreDefaults}
|
onRestoreDefaults={handleRestoreDefaults}
|
||||||
@@ -98,16 +131,32 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onJustAddedTagHandled={() => setJustAddedTag(false)}
|
onJustAddedTagHandled={() => setJustAddedTag(false)}
|
||||||
isLoadingTags={isLoadingTags}
|
isLoadingTags={isLoadingTags}
|
||||||
|
recommendTag={hasHistory ? {
|
||||||
|
label: '为你推荐',
|
||||||
|
isSelected: effectiveRecommendSelected,
|
||||||
|
onSelect: handleRecommendSelect,
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MovieGrid
|
{effectiveRecommendSelected ? (
|
||||||
movies={movies}
|
<MovieGrid
|
||||||
loading={loading}
|
movies={recommendMovies}
|
||||||
hasMore={hasMore}
|
loading={recommendLoading}
|
||||||
onMovieClick={handleMovieClick}
|
hasMore={recommendHasMore}
|
||||||
prefetchRef={prefetchRef}
|
onMovieClick={handleMovieClick}
|
||||||
loadMoreRef={loadMoreRef}
|
prefetchRef={recommendPrefetchRef}
|
||||||
/>
|
loadMoreRef={recommendLoadMoreRef}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MovieGrid
|
||||||
|
movies={movies}
|
||||||
|
loading={loading}
|
||||||
|
hasMore={hasMore}
|
||||||
|
onMovieClick={handleMovieClick}
|
||||||
|
prefetchRef={prefetchRef}
|
||||||
|
loadMoreRef={loadMoreRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { SortableTag, Tag } from './SortableTag';
|
import { SortableTag, Tag } from './SortableTag';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Icons } from '@/components/ui/Icon';
|
||||||
|
|
||||||
|
interface RecommendTagConfig {
|
||||||
|
label: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface TagListProps {
|
interface TagListProps {
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
@@ -28,6 +35,7 @@ interface TagListProps {
|
|||||||
onTagDelete: (tagId: string) => void;
|
onTagDelete: (tagId: string) => void;
|
||||||
onDragEnd: (event: DragEndEvent) => void;
|
onDragEnd: (event: DragEndEvent) => void;
|
||||||
onJustAddedTagHandled: () => void;
|
onJustAddedTagHandled: () => void;
|
||||||
|
recommendTag?: RecommendTagConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagList({
|
export function TagList({
|
||||||
@@ -39,6 +47,7 @@ export function TagList({
|
|||||||
onTagDelete,
|
onTagDelete,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onJustAddedTagHandled,
|
onJustAddedTagHandled,
|
||||||
|
recommendTag,
|
||||||
}: TagListProps) {
|
}: TagListProps) {
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
@@ -108,6 +117,24 @@ export function TagList({
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
|
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 && (
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={recommendTag.onSelect}
|
||||||
|
className={`
|
||||||
|
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none flex items-center gap-1.5
|
||||||
|
${recommendTag.isSelected
|
||||||
|
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
|
||||||
|
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icons.Sparkles size={14} />
|
||||||
|
{recommendTag.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={tags.map((t) => t.id)}
|
items={tags.map((t) => t.id)}
|
||||||
strategy={horizontalListSortingStrategy}
|
strategy={horizontalListSortingStrategy}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { TagInput } from './TagInput';
|
|||||||
import { TagList } from './TagList';
|
import { TagList } from './TagList';
|
||||||
import { Tag } from './SortableTag';
|
import { Tag } from './SortableTag';
|
||||||
|
|
||||||
|
interface RecommendTagConfig {
|
||||||
|
label: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface TagManagerProps {
|
interface TagManagerProps {
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
selectedTag: string;
|
selectedTag: string;
|
||||||
@@ -19,6 +25,7 @@ interface TagManagerProps {
|
|||||||
onDragEnd: (event: DragEndEvent) => void;
|
onDragEnd: (event: DragEndEvent) => void;
|
||||||
onJustAddedTagHandled: () => void;
|
onJustAddedTagHandled: () => void;
|
||||||
isLoadingTags?: boolean;
|
isLoadingTags?: boolean;
|
||||||
|
recommendTag?: RecommendTagConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagManager({
|
export function TagManager({
|
||||||
@@ -36,6 +43,7 @@ export function TagManager({
|
|||||||
onDragEnd,
|
onDragEnd,
|
||||||
onJustAddedTagHandled,
|
onJustAddedTagHandled,
|
||||||
isLoadingTags,
|
isLoadingTags,
|
||||||
|
recommendTag,
|
||||||
}: TagManagerProps) {
|
}: TagManagerProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -84,6 +92,7 @@ export function TagManager({
|
|||||||
onTagDelete={onTagDelete}
|
onTagDelete={onTagDelete}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onJustAddedTagHandled={onJustAddedTagHandled}
|
onJustAddedTagHandled={onJustAddedTagHandled}
|
||||||
|
recommendTag={recommendTag}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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<InterleavedMovie[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const queriesRef = useRef<RecommendationQuery[]>([]);
|
||||||
|
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<string>,
|
||||||
|
): Promise<InterleavedMovie[]> => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
|||||||
href={isPremiumMode ? '/premium' : '/'}
|
href={isPremiumMode ? '/premium' : '/'}
|
||||||
className="flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity cursor-pointer min-w-0"
|
className="flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity cursor-pointer min-w-0"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
|
data-focusable
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
|
<div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
@@ -95,6 +96,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
|||||||
href={settingsHref}
|
href={settingsHref}
|
||||||
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
|
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
|
||||||
aria-label="设置"
|
aria-label="设置"
|
||||||
|
data-focusable
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 -960 960 960" fill="currentColor">
|
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 -960 960 960" fill="currentColor">
|
||||||
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
|
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Icons } from '@/components/ui/Icon';
|
import { Icons } from '@/components/ui/Icon';
|
||||||
import { getSourceName } from '@/lib/utils/source-names';
|
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 {
|
interface VideoMetadataProps {
|
||||||
videoData: any;
|
videoData: any;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
@@ -61,16 +71,38 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{videoData?.vod_actor && (
|
{videoData?.vod_actor && (
|
||||||
<p className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-2">
|
<div className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-2">
|
||||||
<span className="font-semibold">主演:</span>
|
<span className="font-semibold">主演:</span>
|
||||||
{videoData.vod_actor}
|
<span className="inline-flex flex-wrap gap-1">
|
||||||
</p>
|
{splitPersonNames(videoData.vod_actor).map((name) => (
|
||||||
|
<Link
|
||||||
|
key={name}
|
||||||
|
href={`/?q=${encodeURIComponent(name)}`}
|
||||||
|
data-focusable
|
||||||
|
className="inline-block px-2 py-0.5 rounded-full bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)] transition-all duration-200"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{videoData?.vod_director && (
|
{videoData?.vod_director && (
|
||||||
<p className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-1">
|
<div className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-1">
|
||||||
<span className="font-semibold">导演:</span>
|
<span className="font-semibold">导演:</span>
|
||||||
{videoData.vod_director}
|
<span className="inline-flex flex-wrap gap-1">
|
||||||
</p>
|
{splitPersonNames(videoData.vod_director).map((name) => (
|
||||||
|
<Link
|
||||||
|
key={name}
|
||||||
|
href={`/?q=${encodeURIComponent(name)}`}
|
||||||
|
data-focusable
|
||||||
|
className="inline-block px-2 py-0.5 rounded-full bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)] transition-all duration-200"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { useHistory } from '@/lib/store/history-store';
|
import { useHistory } from '@/lib/store/history-store';
|
||||||
import { settingsStore } from '@/lib/store/settings-store';
|
import { settingsStore } from '@/lib/store/settings-store';
|
||||||
|
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
|
||||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||||
import { VideoPlayerError } from './VideoPlayerError';
|
import { VideoPlayerError } from './VideoPlayerError';
|
||||||
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
|
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
|
||||||
@@ -52,14 +53,15 @@ export function VideoPlayer({
|
|||||||
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
|
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial value
|
// Initial value - use mode-specific store
|
||||||
const settings = settingsStore.getSettings();
|
const store = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||||
|
const settings = store.getSettings();
|
||||||
setShowModeIndicator(settings.showModeIndicator);
|
setShowModeIndicator(settings.showModeIndicator);
|
||||||
setProxyMode(settings.proxyMode);
|
setProxyMode(settings.proxyMode);
|
||||||
|
|
||||||
// Subscribe to changes
|
// Subscribe to changes
|
||||||
const unsubscribe = settingsStore.subscribe(() => {
|
const unsubscribe = store.subscribe(() => {
|
||||||
const newSettings = settingsStore.getSettings();
|
const newSettings = store.getSettings();
|
||||||
setShowModeIndicator(newSettings.showModeIndicator);
|
setShowModeIndicator(newSettings.showModeIndicator);
|
||||||
setProxyMode(newSettings.proxyMode);
|
setProxyMode(newSettings.proxyMode);
|
||||||
});
|
});
|
||||||
@@ -200,6 +202,7 @@ export function VideoPlayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div data-no-spatial>
|
||||||
<Card hover={false} className="p-0 relative">
|
<Card hover={false} className="p-0 relative">
|
||||||
{/* Mode Indicator Badge - controlled by settings */}
|
{/* Mode Indicator Badge - controlled by settings */}
|
||||||
{showModeIndicator && (
|
{showModeIndicator && (
|
||||||
@@ -237,5 +240,6 @@ export function VideoPlayer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { TagManager } from '@/components/home/TagManager';
|
import { TagManager } from '@/components/home/TagManager';
|
||||||
|
import { MovieGrid } from '@/components/home/MovieGrid';
|
||||||
import { PremiumContentGrid } from './PremiumContentGrid';
|
import { PremiumContentGrid } from './PremiumContentGrid';
|
||||||
import { usePremiumTagManager } from '@/lib/hooks/usePremiumTagManager';
|
import { usePremiumTagManager } from '@/lib/hooks/usePremiumTagManager';
|
||||||
import { usePremiumContent } from '@/lib/hooks/usePremiumContent';
|
import { usePremiumContent } from '@/lib/hooks/usePremiumContent';
|
||||||
|
import { usePersonalizedRecommendations } from '@/components/home/hooks/usePersonalizedRecommendations';
|
||||||
|
|
||||||
interface PremiumContentProps {
|
interface PremiumContentProps {
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
@@ -26,6 +29,19 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
|
|||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
} = usePremiumTagManager();
|
} = 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
|
// Get the category value from selected tag
|
||||||
const categoryValue = tags.find(t => t.id === selectedTag)?.value || '';
|
const categoryValue = tags.find(t => t.id === selectedTag)?.value || '';
|
||||||
|
|
||||||
@@ -35,25 +51,32 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
|
|||||||
hasMore,
|
hasMore,
|
||||||
prefetchRef,
|
prefetchRef,
|
||||||
loadMoreRef,
|
loadMoreRef,
|
||||||
} = usePremiumContent(categoryValue);
|
} = usePremiumContent(effectiveRecommendSelected ? '' : categoryValue);
|
||||||
|
|
||||||
const handleVideoClick = (video: any) => {
|
const handleVideoClick = (video: any) => {
|
||||||
if (onSearch) {
|
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 (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<TagManager
|
<TagManager
|
||||||
tags={tags}
|
tags={tags}
|
||||||
selectedTag={selectedTag}
|
selectedTag={effectiveRecommendSelected ? '' : selectedTag}
|
||||||
showTagManager={showTagManager}
|
showTagManager={showTagManager}
|
||||||
newTagInput={newTagInput}
|
newTagInput={newTagInput}
|
||||||
justAddedTag={justAddedTag}
|
justAddedTag={justAddedTag}
|
||||||
onTagSelect={(tagId) => {
|
onTagSelect={handleRegularTagSelect}
|
||||||
setSelectedTag(tagId);
|
|
||||||
}}
|
|
||||||
onTagDelete={handleDeleteTag}
|
onTagDelete={handleDeleteTag}
|
||||||
onToggleManager={() => setShowTagManager(!showTagManager)}
|
onToggleManager={() => setShowTagManager(!showTagManager)}
|
||||||
onRestoreDefaults={handleRestoreDefaults}
|
onRestoreDefaults={handleRestoreDefaults}
|
||||||
@@ -61,16 +84,32 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
|
|||||||
onAddTag={handleAddTag}
|
onAddTag={handleAddTag}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onJustAddedTagHandled={() => setJustAddedTag(false)}
|
onJustAddedTagHandled={() => setJustAddedTag(false)}
|
||||||
|
recommendTag={hasHistory ? {
|
||||||
|
label: '为你推荐',
|
||||||
|
isSelected: effectiveRecommendSelected,
|
||||||
|
onSelect: handleRecommendSelect,
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PremiumContentGrid
|
{effectiveRecommendSelected ? (
|
||||||
videos={videos}
|
<MovieGrid
|
||||||
loading={loading}
|
movies={recommendMovies}
|
||||||
hasMore={hasMore}
|
loading={recommendLoading}
|
||||||
onVideoClick={handleVideoClick}
|
hasMore={recommendHasMore}
|
||||||
prefetchRef={prefetchRef}
|
onMovieClick={handleVideoClick}
|
||||||
loadMoreRef={loadMoreRef}
|
prefetchRef={recommendPrefetchRef}
|
||||||
/>
|
loadMoreRef={recommendLoadMoreRef}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PremiumContentGrid
|
||||||
|
videos={videos}
|
||||||
|
loading={loading}
|
||||||
|
hasMore={hasMore}
|
||||||
|
onVideoClick={handleVideoClick}
|
||||||
|
prefetchRef={prefetchRef}
|
||||||
|
loadMoreRef={loadMoreRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function SearchBox({ onSearch, onClear, initialQuery = '', placeholder =
|
|||||||
aria-expanded={isDropdownOpen}
|
aria-expanded={isDropdownOpen}
|
||||||
aria-controls="search-history-dropdown"
|
aria-controls="search-history-dropdown"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
|
data-focusable
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 z-10">
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 z-10">
|
||||||
|
|||||||
@@ -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,
|
playbackPosition: number,
|
||||||
duration: number,
|
duration: number,
|
||||||
poster?: string,
|
poster?: string,
|
||||||
episodes?: Episode[]
|
episodes?: Episode[],
|
||||||
|
metadata?: { vod_actor?: string; type_name?: string; vod_area?: string }
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
removeFromHistory: (videoId: string | number, source: string) => void;
|
removeFromHistory: (videoId: string | number, source: string) => void;
|
||||||
@@ -61,7 +62,8 @@ const createHistoryStore = (name: string) =>
|
|||||||
playbackPosition,
|
playbackPosition,
|
||||||
duration,
|
duration,
|
||||||
poster,
|
poster,
|
||||||
episodes = []
|
episodes = [],
|
||||||
|
metadata
|
||||||
) => {
|
) => {
|
||||||
const showIdentifier = generateShowIdentifier(title, source, videoId);
|
const showIdentifier = generateShowIdentifier(title, source, videoId);
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
@@ -84,6 +86,9 @@ const createHistoryStore = (name: string) =>
|
|||||||
duration,
|
duration,
|
||||||
timestamp,
|
timestamp,
|
||||||
episodes: episodes.length > 0 ? episodes : state.viewingHistory[existingIndex].episodes,
|
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 = [
|
newHistory = [
|
||||||
@@ -104,6 +109,9 @@ const createHistoryStore = (name: string) =>
|
|||||||
poster,
|
poster,
|
||||||
episodes,
|
episodes,
|
||||||
showIdentifier,
|
showIdentifier,
|
||||||
|
vod_actor: metadata?.vod_actor,
|
||||||
|
type_name: metadata?.type_name,
|
||||||
|
vod_area: metadata?.vod_area,
|
||||||
};
|
};
|
||||||
|
|
||||||
newHistory = [newItem, ...state.viewingHistory];
|
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'
|
fullscreenType: 'auto' | 'native' | 'window'; // Fullscreen mode preference: 'auto' (native on desktop, window on mobile) | 'native' | 'window'
|
||||||
proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always'
|
proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always'
|
||||||
rememberScrollPosition: boolean; // Remember scroll position when navigating back or refreshing
|
rememberScrollPosition: boolean; // Remember scroll position when navigating back or refreshing
|
||||||
|
personalizedRecommendations: boolean; // Show personalized recommendations based on watch history
|
||||||
// Danmaku settings
|
// Danmaku settings
|
||||||
danmakuEnabled: boolean; // Show danmaku overlay on video
|
danmakuEnabled: boolean; // Show danmaku overlay on video
|
||||||
danmakuApiUrl: string; // Self-hosted danmaku API endpoint
|
danmakuApiUrl: string; // Self-hosted danmaku API endpoint
|
||||||
@@ -122,6 +123,7 @@ function getDefaultAppSettings(): AppSettings {
|
|||||||
fullscreenType: 'auto',
|
fullscreenType: 'auto',
|
||||||
proxyMode: 'retry',
|
proxyMode: 'retry',
|
||||||
rememberScrollPosition: true,
|
rememberScrollPosition: true,
|
||||||
|
personalizedRecommendations: true,
|
||||||
danmakuEnabled: false,
|
danmakuEnabled: false,
|
||||||
danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '',
|
danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '',
|
||||||
danmakuOpacity: 0.7,
|
danmakuOpacity: 0.7,
|
||||||
@@ -202,6 +204,7 @@ export const settingsStore = {
|
|||||||
fullscreenType: (parsed.fullscreenType === 'window' || parsed.fullscreenType === 'native' || parsed.fullscreenType === 'auto') ? parsed.fullscreenType : 'auto',
|
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',
|
proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry',
|
||||||
rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true,
|
rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true,
|
||||||
|
personalizedRecommendations: parsed.personalizedRecommendations !== undefined ? parsed.personalizedRecommendations : true,
|
||||||
danmakuEnabled: parsed.danmakuEnabled !== undefined ? parsed.danmakuEnabled : false,
|
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 || ''),
|
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,
|
danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7,
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ export interface VideoHistoryItem {
|
|||||||
poster?: string;
|
poster?: string;
|
||||||
episodes: Episode[];
|
episodes: Episode[];
|
||||||
showIdentifier: string; // Unique identifier for deduplication
|
showIdentifier: string; // Unique identifier for deduplication
|
||||||
|
vod_actor?: string;
|
||||||
|
type_name?: string;
|
||||||
|
vod_area?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorite Entry
|
// 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;
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.3.1",
|
"version": "4.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.3.1",
|
"version": "4.3.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.3.1",
|
"version": "4.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user