mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
fix: enforce compliance mode for managed deployments
This commit is contained in:
@@ -23,6 +23,8 @@
|
||||
|
||||
**KVideo** 是一个高性能、现代化的视频聚合与播放应用,专注于提供极致的用户体验和视觉设计。本项目利用 Next.js 16 的最新特性,结合 React 19 和 Tailwind CSS v4,打造了一个既美观又强大的视频浏览平台。
|
||||
|
||||
> **说明**:仓库默认不内置任何视频源、高级源或 IPTV 源。部署者必须自行配置已获授权、可合法使用且允许当前部署方式访问的内容来源。
|
||||
|
||||
### 核心设计理念:Liquid Glass(液态玻璃)
|
||||
|
||||
项目的视觉设计基于 **"Liquid Glass"** 设计系统,这是一套融合了以下特性的现代化 UI 设计语言:
|
||||
@@ -500,6 +502,8 @@ docker run -d -p 3000:3000 \
|
||||
|
||||
通过环境变量预设 IPTV 直播源,应用启动时会自动添加到直播源列表中。
|
||||
|
||||
> **注意**:在 Vercel / Cloudflare 托管部署的合规模式下,IPTV 页面与流中继默认关闭,`IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` 不会生效。
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `IPTV_SOURCES` | IPTV 直播源配置(服务端) | - |
|
||||
@@ -666,7 +670,7 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
### 架构特点
|
||||
|
||||
- **App Router**:Next.js 的新路由系统,支持服务端组件和流式渲染
|
||||
- **API Routes**:内置 API 端点,处理认证、豆瓣数据、视频源代理、IPTV 代理和弹幕聚合
|
||||
- **API Routes**:内置 API 端点,处理认证、豆瓣数据、弹幕聚合,以及仅在自托管完整模式下启用的媒体代理 / IPTV 中继
|
||||
- **Server-Sent Events**:搜索 API 使用 SSE 流式返回实时结果
|
||||
- **Service Worker**:PWA 支持和资源缓存
|
||||
- **Server Components**:优化首屏加载性能
|
||||
@@ -674,45 +678,25 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
- **Edge Runtime**:API 路由运行在 Edge Runtime 上,提供更低延迟
|
||||
- **Profile-based Storage**:所有用户数据按 profileId(SHA-256 哈希)隔离存储
|
||||
|
||||
## 部署合规说明
|
||||
|
||||
Issue [#127](https://github.com/KuekHaoYang/KVideo/issues/127) 之后,仓库的公开部署策略做了调整,目的只有一个:避免把这个项目继续包装成“GitHub 账号直连第三方平台的一键导入仓库”。
|
||||
|
||||
- 不再推荐任何 `Deploy with Vercel`、`Connect GitHub`、`Fork 后直接授权第三方读取仓库` 这一类 GitHub 直连导入流程。
|
||||
- Vercel / Cloudflare 托管部署现在默认运行在**合规模式**:自动关闭外部媒体代理、热链转发和 IPTV 流中继,仅保留直连播放路径。
|
||||
- 需要 `/api/proxy`、`/api/iptv/stream`、自定义 `User-Agent` / `Referer` 转发、IPTV 中继等能力时,请使用 Docker 或传统 Node.js 自托管。
|
||||
- 无论部署到哪里,都只应接入你有权使用、且允许当前部署环境访问的内容来源。
|
||||
- 相关政策请直接阅读官方原文:
|
||||
- [GitHub Terms of Service](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service)
|
||||
- [GitHub Acceptable Use Policies](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)
|
||||
- [Vercel: why running another CDN / proxy on top of Vercel is not recommended](https://vercel.com/guides/why-running-another-cdn-on-top-of-vercel-is-not-recommended)
|
||||
- [Cloudflare abuse approach](https://www.cloudflare.com/trust-hub/abuse-approach/)
|
||||
|
||||
## 快速部署
|
||||
|
||||
### 部署到自己的服务器
|
||||
|
||||
#### 选项 1:Vercel 一键部署(推荐)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/KuekHaoYang/KVideo)
|
||||
|
||||
1. 点击上方按钮
|
||||
2. 连接你的 GitHub 账号
|
||||
3. Vercel 会自动检测 Next.js 项目并部署
|
||||
4. 几分钟后即可访问你自己的 KVideo 实例
|
||||
|
||||
#### 选项 2:Cloudflare Pages 部署 (推荐)
|
||||
|
||||
此方法完全免费且速度极快,是部署本项目的最佳选择。
|
||||
|
||||
1. **Fork 本仓库**:首先将项目 Fork 到你的 GitHub 账户。
|
||||
|
||||
2. **创建项目**:
|
||||
- 点击访问 [**Cloudflare Pages - Connect Git**](https://dash.cloudflare.com/?to=/:account/pages/new/provider/github)。
|
||||
- 如果未连接 GitHub,请点击 **Connect GitHub**;若已连接,直接选择你刚刚 Fork 的 `KVideo` 项目并点击 **Begin setup**。
|
||||
|
||||
3. **配置构建参数**:
|
||||
- **Project name**: 默认为 `kvideo` (建议保持不变,后续链接基于此名称)
|
||||
- **Framework Preset**: 选择 `Next.js`
|
||||
- **Build command**: 输入 `npm run pages:build`
|
||||
- **Build output directory**: 输入 `.vercel/output/static`
|
||||
- 点击 **Save and Deploy**。
|
||||
|
||||
4. **等待部署完成**:
|
||||
> 项目已内置 `wrangler.toml` 配置文件,其中包含 `nodejs_compat` 兼容性标志,无需手动配置。
|
||||
|
||||
5. **如果部署失败**:
|
||||
> 如果看到 `Unknown internal error occurred` 错误,这通常是 Cloudflare 的临时服务端问题。请在 **Deployments** 列表中找到最新部署,点击 `...` 菜单选择 **Retry deployment** 重试即可。
|
||||
>
|
||||
> 如果首次部署仍有问题,可尝试在 **[项目设置页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo/settings/production)** 底部的 **Compatibility flags** 中手动添加 `nodejs_compat`,然后重新部署。
|
||||
|
||||
#### 选项 3:Docker 部署
|
||||
#### 选项 1:Docker 部署(推荐,完整功能)
|
||||
|
||||
**从 Docker Hub 拉取(最简单):**
|
||||
|
||||
@@ -758,7 +742,7 @@ docker run -d -p 3000:3000 \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
#### 选项 4:传统 Node.js 部署
|
||||
#### 选项 2:传统 Node.js 部署(完整功能)
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
@@ -777,7 +761,18 @@ npm start
|
||||
|
||||
应用将在 `http://localhost:3000` 启动。
|
||||
|
||||
#### 选项 5:Android TV APK 构建
|
||||
#### 选项 3:Vercel / Cloudflare 托管部署(合规模式)
|
||||
|
||||
适用于只需要搜索、账户、设置、直连播放等基础能力,不需要外部媒体代理 / IPTV 中继的场景。
|
||||
|
||||
1. **只从本地工作副本部署**:不要使用 GitHub 一键导入、Fork 后导入、Connect GitHub 读取仓库等流程。
|
||||
2. **Vercel**:使用本地 CLI 部署现有项目,参考官方文档:[Import an existing project](https://vercel.com/docs/getting-started-with-vercel/import)。
|
||||
3. **Cloudflare**:使用官方 **Direct Upload** 或 CLI 工作流,不要使用 Git integration,参考官方文档:[Getting started with Pages](https://developers.cloudflare.com/pages/get-started)。
|
||||
4. **Cloudflare 构建参数**:本仓库保留了 `npm run pages:build` 和 `wrangler.toml`,可继续用于现有 Cloudflare 构建链路。
|
||||
5. **功能限制**:托管平台会自动禁用外部媒体代理和 IPTV 流中继;请仅使用允许浏览器直连、允许当前来源访问且具备合法授权的内容源。
|
||||
6. **如果你需要完整能力**:直接改用 Docker 或传统 Node.js 自托管,不要在托管平台上强行恢复这些能力。
|
||||
|
||||
#### 选项 4:Android TV APK 构建
|
||||
|
||||
项目内置了一个轻量的 Android TV WebView 壳应用,可以将 KVideo 打包成 APK 安装到 Android TV 或机顶盒上。
|
||||
|
||||
@@ -829,7 +824,7 @@ npm start
|
||||
>
|
||||
> **发布方式**:GitHub Actions 的 `Android TV APK` 工作流会持续验证壳应用可构建;如需对外发布预构建 APK,可手动触发该工作流并填写 `release_tag`,生成对应的 GitHub Release 资产。
|
||||
|
||||
#### 选项 6:Apple TV 应用构建
|
||||
#### 选项 5:Apple TV 应用构建
|
||||
|
||||
项目内置了一个轻量的 tvOS WKWebView 壳应用,可以将 KVideo 安装到 Apple TV 上。
|
||||
|
||||
@@ -865,7 +860,11 @@ npm start
|
||||
|
||||
### Vercel 部署
|
||||
|
||||
Vercel 会自动检测 GitHub 仓库的更新并重新部署,无需手动操作。
|
||||
不要依赖 GitHub 仓库自动导入。请在本地工作副本完成更新后,使用 Vercel CLI 重新发布当前目录。
|
||||
|
||||
### Cloudflare 托管部署
|
||||
|
||||
请在本地工作副本完成更新后,使用 Direct Upload 或 CLI 重新发布。不要使用 Git integration 直接读取 GitHub 仓库。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
@@ -916,12 +915,14 @@ Android 7.0 (API 24) 的 WebView 基于 Chrome 51,不支持本项目使用的
|
||||
|
||||
### IPTV 部分直播流无法播放
|
||||
|
||||
如果你部署在 Vercel / Cloudflare 托管平台,IPTV 在合规模式下是**默认禁用**的;这不是 Bug,而是刻意限制。下面的说明只适用于 Docker / Node.js 自托管完整模式。
|
||||
|
||||
浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持:
|
||||
- RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer)
|
||||
- 某些加密或受 DRM 保护的流
|
||||
- 需要特定客户端验证的流
|
||||
|
||||
KVideo 已内置代理服务器自动处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
|
||||
在自托管完整模式下,KVideo 的 IPTV 代理会处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
|
||||
|
||||
### IPTV CCTV 等频道只有声音没有画面
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -66,13 +67,14 @@ async function generateProfileId(password: string): Promise<string> {
|
||||
|
||||
export async function GET() {
|
||||
const hasAuth = !!(effectiveAdminPassword || ACCOUNTS);
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
return NextResponse.json({
|
||||
hasAuth,
|
||||
hasPremiumAuth: !!PREMIUM_PASSWORD,
|
||||
persistSession: PERSIST_SESSION,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
iptvSources: IPTV_SOURCES,
|
||||
iptvSources: runtimeFeatures.iptvEnabled ? IPTV_SOURCES : '',
|
||||
mergeSources: MERGE_SOURCES,
|
||||
danmakuApiUrl: DANMAKU_API_URL,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -91,6 +92,18 @@ const CORS_HEADERS = {
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
if (!runtimeFeatures.iptvEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'IPTV relay is disabled on this deployment',
|
||||
message: runtimeFeatures.restrictionSummary,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
const customUa = request.nextUrl.searchParams.get('ua');
|
||||
const customReferer = request.nextUrl.searchParams.get('referer');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { processM3u8Content } from '@/lib/utils/proxy-utils';
|
||||
import { fetchWithRetry } from '@/lib/utils/fetch-with-retry';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -9,6 +10,18 @@ export const runtime = 'edge';
|
||||
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
if (!runtimeFeatures.mediaProxyEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'External media proxy is disabled on this deployment',
|
||||
message: runtimeFeatures.restrictionSummary,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
|
||||
+28
-7
@@ -11,11 +11,13 @@ import { IPTVChannelGrid } from '@/components/iptv/IPTVChannelGrid';
|
||||
import { IPTVPlayer } from '@/components/iptv/IPTVPlayer';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { hasPermission, getSession } from '@/lib/store/auth-store';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import Link from 'next/link';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
|
||||
export default function IPTVPage() {
|
||||
const { sources, cachedChannels, cachedChannelsBySource, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
|
||||
const { iptvEnabled, restrictionSummary } = useRuntimeFeatures();
|
||||
const { sources, cachedChannels, cachedChannelsBySource, refreshSources, isLoading } = useIPTVStore();
|
||||
const [activeChannel, setActiveChannel] = useState<M3UChannel | null>(null);
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
|
||||
@@ -45,6 +47,17 @@ export default function IPTVPage() {
|
||||
[visibleSources, cachedChannelsBySource]
|
||||
);
|
||||
|
||||
// Auto-refresh on first load if we have sources but no cached channels
|
||||
useEffect(() => {
|
||||
if (!iptvEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) {
|
||||
refreshSources();
|
||||
}
|
||||
}, [iptvEnabled, sources.length, cachedChannels.length, isLoading, refreshSources]);
|
||||
|
||||
// If auth is configured and user doesn't have iptv_access, show access denied
|
||||
if (!canAccessIPTV && getSession()) {
|
||||
return (
|
||||
@@ -59,12 +72,20 @@ export default function IPTVPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-refresh on first load if we have sources but no cached channels
|
||||
useEffect(() => {
|
||||
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) {
|
||||
refreshSources();
|
||||
}
|
||||
}, [sources.length, cachedChannels.length, isLoading, refreshSources]);
|
||||
if (!iptvEnabled) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)]">
|
||||
<div className="max-w-xl mx-auto px-6 py-8 text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)]">
|
||||
<Icons.TV size={48} className="mx-auto mb-4 text-[var(--text-color-secondary)] opacity-40" />
|
||||
<p className="text-[var(--text-color)] font-medium mb-2">当前部署已禁用 IPTV</p>
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
{restrictionSummary}
|
||||
</p>
|
||||
<Link href="/" className="text-sm text-[var(--accent-color)] hover:underline">返回首页</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
|
||||
|
||||
+20
-14
@@ -14,6 +14,8 @@ import { AdKeywordsInjector } from "@/components/AdKeywordsInjector";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { ScrollPositionManager } from "@/components/ScrollPositionManager";
|
||||
import { LocaleProvider } from "@/components/LocaleProvider";
|
||||
import { RuntimeFeaturesProvider } from "@/components/RuntimeFeaturesProvider";
|
||||
import { getRuntimeFeatures } from "@/lib/server/runtime-features";
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -80,6 +82,8 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
@@ -100,21 +104,23 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider>
|
||||
{/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */}
|
||||
<AutoSync />
|
||||
<LocaleProvider />
|
||||
<RuntimeFeaturesProvider initialFeatures={runtimeFeatures}>
|
||||
{/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */}
|
||||
<AutoSync />
|
||||
<LocaleProvider />
|
||||
|
||||
<TVProvider>
|
||||
<TVNavigationInitializer />
|
||||
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
|
||||
<AdKeywordsWrapper />
|
||||
{children}
|
||||
<BackToTop />
|
||||
<ScrollPositionManager />
|
||||
</PasswordGate>
|
||||
</TVProvider>
|
||||
<Analytics />
|
||||
<ServiceWorkerRegister />
|
||||
<TVProvider>
|
||||
<TVNavigationInitializer />
|
||||
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
|
||||
<AdKeywordsWrapper />
|
||||
{children}
|
||||
<BackToTop />
|
||||
<ScrollPositionManager />
|
||||
</PasswordGate>
|
||||
</TVProvider>
|
||||
<Analytics />
|
||||
<ServiceWorkerRegister />
|
||||
</RuntimeFeaturesProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
{/* ARIA Live Region for Screen Reader Announcements */}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
import type { RuntimeFeatures } from '@/lib/config/runtime-features';
|
||||
|
||||
const defaultRuntimeFeatures: RuntimeFeatures = {
|
||||
deploymentProvider: 'self-hosted',
|
||||
deploymentProviderLabel: '自托管',
|
||||
restrictedManagedDeployment: false,
|
||||
mediaProxyEnabled: true,
|
||||
iptvEnabled: true,
|
||||
restrictionSummary: null,
|
||||
};
|
||||
|
||||
const RuntimeFeaturesContext = createContext<RuntimeFeatures>(defaultRuntimeFeatures);
|
||||
|
||||
interface RuntimeFeaturesProviderProps {
|
||||
initialFeatures: RuntimeFeatures;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RuntimeFeaturesProvider({ initialFeatures, children }: RuntimeFeaturesProviderProps) {
|
||||
return (
|
||||
<RuntimeFeaturesContext.Provider value={initialFeatures}>
|
||||
{children}
|
||||
</RuntimeFeaturesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRuntimeFeatures(): RuntimeFeatures {
|
||||
return useContext(RuntimeFeaturesContext);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { siteConfig } from '@/lib/config/site-config';
|
||||
import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -16,11 +17,8 @@ interface NavbarProps {
|
||||
|
||||
export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
const settingsHref = isPremiumMode ? '/premium/settings' : '/settings';
|
||||
const [session, setSessionState] = useState<AuthSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionState(getSession());
|
||||
}, []);
|
||||
const [session] = useState<AuthSession | null>(() => getSession());
|
||||
const { iptvEnabled } = useRuntimeFeatures();
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
@@ -61,7 +59,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* IPTV Link - only show if user has iptv_access or no auth configured */}
|
||||
{hasPermission('iptv_access') && (
|
||||
{iptvEnabled && hasPermission('iptv_access') && (
|
||||
<Link
|
||||
href="/iptv"
|
||||
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"
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
|
||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||
import { VideoPlayerError } from './VideoPlayerError';
|
||||
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
|
||||
import { usePlayerSettings } from './hooks/usePlayerSettings';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
playUrl: string;
|
||||
@@ -53,38 +52,8 @@ export function VideoPlayer({
|
||||
const durationRef = useRef(0);
|
||||
const SAVE_INTERVAL = 5000; // 5 seconds throttle
|
||||
|
||||
// Get showModeIndicator setting
|
||||
// Get showModeIndicator and proxyMode settings
|
||||
const [showModeIndicator, setShowModeIndicator] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
|
||||
|
||||
useEffect(() => {
|
||||
// Initial value - use mode-specific store
|
||||
const store = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
const settings = store.getSettings();
|
||||
setShowModeIndicator(settings.showModeIndicator);
|
||||
setProxyMode(settings.proxyMode);
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
const newSettings = store.getSettings();
|
||||
setShowModeIndicator(newSettings.showModeIndicator);
|
||||
setProxyMode(newSettings.proxyMode);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Initialize useProxy based on proxyMode when the component mounts or proxyMode changes
|
||||
// We use a separate effect for this to react to setting changes
|
||||
useEffect(() => {
|
||||
if (proxyMode === 'always') {
|
||||
setUseProxy(true);
|
||||
} else if (proxyMode === 'none') {
|
||||
setUseProxy(false);
|
||||
}
|
||||
// For 'retry', we assume it starts as false (direct), which is the default state of useProxy
|
||||
}, [proxyMode]);
|
||||
const { showModeIndicator, proxyMode } = usePlayerSettings(isPremium);
|
||||
const effectiveUseProxy = proxyMode === 'always' ? true : proxyMode === 'none' ? false : useProxy;
|
||||
|
||||
|
||||
// Use reactive hook to subscribe to history updates
|
||||
@@ -174,7 +143,7 @@ export function VideoPlayer({
|
||||
// 3. Proxy mode is 'retry' (specifically for the auto-switch logic)
|
||||
// Note: If mode is 'always', we are already using proxy. If it fails, we show error.
|
||||
|
||||
if (!useProxy && proxyMode === 'retry') {
|
||||
if (!effectiveUseProxy && proxyMode === 'retry') {
|
||||
setUseProxy(true);
|
||||
setShouldAutoPlay(true); // Force autoplay after proxy retry
|
||||
setVideoError('');
|
||||
@@ -200,10 +169,10 @@ export function VideoPlayer({
|
||||
// Let's toggle it to give best chance.
|
||||
// Actually requirement says "try no proxy and proxy and same as before".
|
||||
// So simple toggle is fine.
|
||||
setUseProxy(prev => !prev);
|
||||
setUseProxy(prev => proxyMode === 'none' ? false : !prev);
|
||||
};
|
||||
|
||||
const finalPlayUrl = useProxy || proxyMode === 'always'
|
||||
const finalPlayUrl = effectiveUseProxy
|
||||
? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
|
||||
: playUrl;
|
||||
|
||||
@@ -217,11 +186,11 @@ export function VideoPlayer({
|
||||
{/* Mode Indicator Badge - controlled by settings */}
|
||||
{showModeIndicator && (
|
||||
<div className="absolute top-3 right-3 z-30">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${useProxy
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${effectiveUseProxy
|
||||
? 'bg-orange-500/80 text-white'
|
||||
: 'bg-green-500/80 text-white'
|
||||
}`}>
|
||||
{useProxy ? '代理模式' : '直连模式'}
|
||||
{effectiveUseProxy ? '代理模式' : '直连模式'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -235,7 +204,7 @@ export function VideoPlayer({
|
||||
/>
|
||||
) : (
|
||||
<CustomVideoPlayer
|
||||
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
|
||||
key={`${effectiveUseProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
|
||||
src={finalPlayUrl}
|
||||
onError={handleVideoError}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { usePlayerSettings } from './usePlayerSettings';
|
||||
import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
|
||||
interface UseHlsPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
@@ -22,6 +23,7 @@ export function useHlsPlayer({
|
||||
}: UseHlsPlayerProps) {
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
|
||||
const { mediaProxyEnabled } = useRuntimeFeatures();
|
||||
const isAdFilterEnabled = adFilterMode !== 'off';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,6 +227,9 @@ export function useHlsPlayer({
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.text();
|
||||
} catch (e) {
|
||||
if (!mediaProxyEnabled) {
|
||||
throw e;
|
||||
}
|
||||
console.warn(`[HLS Native] Fetch failed for ${url}, trying proxy...`, e);
|
||||
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
const res = await fetch(proxiedUrl);
|
||||
@@ -397,6 +402,10 @@ export function useHlsPlayer({
|
||||
const handleError = () => {
|
||||
if (directFailed) return;
|
||||
directFailed = true;
|
||||
if (!mediaProxyEnabled) {
|
||||
onError?.('当前浏览器不支持 HLS 视频播放。建议使用 Chrome、Edge 或 Safari 浏览器。');
|
||||
return;
|
||||
}
|
||||
// Try proxied URL as final attempt
|
||||
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(src)}`;
|
||||
video.src = proxiedUrl;
|
||||
@@ -415,5 +424,5 @@ export function useHlsPlayer({
|
||||
}
|
||||
extraBlobs.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords]);
|
||||
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
premiumModeSettingsStore,
|
||||
type ModeSettings,
|
||||
} from '@/lib/store/premium-mode-settings';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
|
||||
interface PlayerSettingsSnapshot {
|
||||
autoNextEpisode: boolean;
|
||||
@@ -30,7 +31,7 @@ interface PlayerSettingsSnapshot {
|
||||
danmakuDisplayArea: number;
|
||||
}
|
||||
|
||||
function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolean): PlayerSettingsSnapshot {
|
||||
const globalSettings = settingsStore.getSettings();
|
||||
const modeSettings = isPremium ? premiumModeSettingsStore.getSettings() : globalSettings;
|
||||
|
||||
@@ -45,7 +46,7 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
adFilterMode: modeSettings.adFilterMode,
|
||||
adKeywords: globalSettings.adKeywords,
|
||||
fullscreenType: modeSettings.fullscreenType,
|
||||
proxyMode: modeSettings.proxyMode,
|
||||
proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none',
|
||||
danmakuEnabled: modeSettings.danmakuEnabled,
|
||||
danmakuApiUrl: modeSettings.danmakuApiUrl,
|
||||
danmakuOpacity: modeSettings.danmakuOpacity,
|
||||
@@ -59,12 +60,13 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
* Provides reactive updates when settings change
|
||||
*/
|
||||
export function usePlayerSettings(isPremium: boolean = false) {
|
||||
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium));
|
||||
const { mediaProxyEnabled } = useRuntimeFeatures();
|
||||
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
|
||||
|
||||
// Subscribe to settings changes
|
||||
useEffect(() => {
|
||||
const syncSettings = () => {
|
||||
setSettings(getPlayerSettingsSnapshot(isPremium));
|
||||
setSettings(getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
|
||||
};
|
||||
|
||||
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
@@ -77,7 +79,7 @@ export function usePlayerSettings(isPremium: boolean = false) {
|
||||
unsubscribeModeStore();
|
||||
unsubscribeGlobalStore?.();
|
||||
};
|
||||
}, [isPremium]);
|
||||
}, [isPremium, mediaProxyEnabled]);
|
||||
|
||||
const updateModeSettings = useCallback((partial: Partial<ModeSettings>) => {
|
||||
if (isPremium) {
|
||||
@@ -145,8 +147,8 @@ export function usePlayerSettings(isPremium: boolean = false) {
|
||||
}, [updateModeSettings]);
|
||||
|
||||
const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => {
|
||||
updateModeSettings({ proxyMode: value });
|
||||
}, [updateModeSettings]);
|
||||
updateModeSettings({ proxyMode: mediaProxyEnabled ? value : 'none' });
|
||||
}, [mediaProxyEnabled, updateModeSettings]);
|
||||
|
||||
const setDanmakuEnabled = useCallback((value: boolean) => {
|
||||
updateModeSettings({ danmakuEnabled: value });
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import {
|
||||
type ProxyMode,
|
||||
DEFAULT_SEEK_STEP_SECONDS,
|
||||
@@ -57,6 +58,9 @@ export function PlayerSettings({
|
||||
onDanmakuDisplayAreaChange,
|
||||
showDanmakuApi = true,
|
||||
}: PlayerSettingsProps) {
|
||||
const { mediaProxyEnabled, restrictionSummary } = useRuntimeFeatures();
|
||||
const effectiveProxyMode = mediaProxyEnabled ? proxyMode : 'none';
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4">播放器设置</h2>
|
||||
@@ -160,38 +164,47 @@ export function PlayerSettings({
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
控制视频播放时的网络请求策略
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => onProxyModeChange('retry')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'retry'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">智能重试 (推荐)</div>
|
||||
<div className="text-sm opacity-80 mt-1">直连优先,失败时尝试代理</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('none')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'none'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">仅直连</div>
|
||||
<div className="text-sm opacity-80 mt-1">不使用代理,失败则报错</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('always')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'always'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">总是代理</div>
|
||||
<div className="text-sm opacity-80 mt-1">所有请求都通过代理转发</div>
|
||||
</button>
|
||||
</div>
|
||||
{mediaProxyEnabled ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => onProxyModeChange('retry')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'retry'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">智能重试 (推荐)</div>
|
||||
<div className="text-sm opacity-80 mt-1">直连优先,失败时尝试代理</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('none')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'none'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">仅直连</div>
|
||||
<div className="text-sm opacity-80 mt-1">不使用代理,失败则报错</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('always')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'always'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">总是代理</div>
|
||||
<div className="text-sm opacity-80 mt-1">所有请求都通过代理转发</div>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-2xl)] border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="font-semibold text-[var(--text-color)]">当前部署仅支持直连播放</div>
|
||||
<div className="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
{restrictionSummary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--glass-border)]" />
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export type DeploymentProvider = 'self-hosted' | 'vercel' | 'cloudflare';
|
||||
|
||||
export interface RuntimeFeatures {
|
||||
deploymentProvider: DeploymentProvider;
|
||||
deploymentProviderLabel: string;
|
||||
restrictedManagedDeployment: boolean;
|
||||
mediaProxyEnabled: boolean;
|
||||
iptvEnabled: boolean;
|
||||
restrictionSummary: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'server-only';
|
||||
|
||||
import type { RuntimeFeatures } from '@/lib/config/runtime-features';
|
||||
|
||||
function isVercelDeployment(): boolean {
|
||||
return process.env.VERCEL === '1' || Boolean(process.env.VERCEL_ENV);
|
||||
}
|
||||
|
||||
function isCloudflareDeployment(): boolean {
|
||||
return (
|
||||
process.env.CF_PAGES === '1' ||
|
||||
Boolean(process.env.CF_PAGES_URL) ||
|
||||
Boolean(process.env.CLOUDFLARE_ACCOUNT_ID) ||
|
||||
Boolean(process.env.CF_ACCOUNT_ID) ||
|
||||
Boolean(process.env.WORKERS_CI)
|
||||
);
|
||||
}
|
||||
|
||||
function getRestrictedFeatures(
|
||||
deploymentProvider: RuntimeFeatures['deploymentProvider'],
|
||||
deploymentProviderLabel: string
|
||||
): RuntimeFeatures {
|
||||
return {
|
||||
deploymentProvider,
|
||||
deploymentProviderLabel,
|
||||
restrictedManagedDeployment: true,
|
||||
mediaProxyEnabled: false,
|
||||
iptvEnabled: false,
|
||||
restrictionSummary: `${deploymentProviderLabel} 托管部署会启用合规模式:关闭外部媒体代理、热链转发和 IPTV 流中继。需要这些能力时请改用 Docker 或传统 Node.js 自托管。`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeFeatures(): RuntimeFeatures {
|
||||
if (isVercelDeployment()) {
|
||||
return getRestrictedFeatures('vercel', 'Vercel');
|
||||
}
|
||||
|
||||
if (isCloudflareDeployment()) {
|
||||
return getRestrictedFeatures('cloudflare', 'Cloudflare');
|
||||
}
|
||||
|
||||
return {
|
||||
deploymentProvider: 'self-hosted',
|
||||
deploymentProviderLabel: '自托管',
|
||||
restrictedManagedDeployment: false,
|
||||
mediaProxyEnabled: true,
|
||||
iptvEnabled: true,
|
||||
restrictionSummary: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,17 +103,24 @@ export function parseSourcesFromJson(jsonString: string): ImportResult {
|
||||
* Fetch and parse sources from a URL
|
||||
*/
|
||||
export async function fetchSourcesFromUrl(url: string): Promise<ImportResult> {
|
||||
// If we're in the browser and it's an external URL, use our proxy to avoid CORS issues
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
const isExternal = url.startsWith('http') && (typeof window !== 'undefined' && !url.includes(window.location.host));
|
||||
const fetchUrl = isExternal
|
||||
? `/api/proxy?url=${encodeURIComponent(url)}`
|
||||
: url;
|
||||
|
||||
const response = await fetch(fetchUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, { headers });
|
||||
} catch (directError) {
|
||||
if (!isExternal) {
|
||||
throw directError;
|
||||
}
|
||||
|
||||
response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`, {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取失败: ${response.status} ${response.statusText}`);
|
||||
|
||||
Reference in New Issue
Block a user