mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Enhance IPTV stream proxy with robust M3U8 detection and add Cloudflare Pages configuration, alongside minor UI layout adjustments.
This commit is contained in:
@@ -561,18 +561,13 @@ docker run -d -p 3000:3000 \
|
||||
- **Build output directory**: 输入 `.vercel/output/static`
|
||||
- 点击 **Save and Deploy**。
|
||||
|
||||
4. **关键步骤:修复运行时环境**
|
||||
> *注意:此时部署虽然显示"Success",但你会发现访问网页会报错。这是因为缺少必要的兼容性配置。请按以下步骤修复:*
|
||||
4. **等待部署完成**:
|
||||
> 项目已内置 `wrangler.toml` 配置文件,其中包含 `nodejs_compat` 兼容性标志,无需手动配置。
|
||||
|
||||
- 进入 **[项目设置页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo/settings/production)** (如果你的项目名不是 kvideo,请在控制台手动查找 Settings -> Functions)。
|
||||
- 拉到页面底部找到 **Compatibility flags** 部分。
|
||||
- 添加标志:`nodejs_compat`
|
||||
|
||||
5. **重试部署 (生效配置)**:
|
||||
- 回到 **[项目概览页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo)**。
|
||||
- 在 **Deployments** 列表中,找到最新的那次部署。
|
||||
- 点击右侧的三个点 `...` 菜单,选择 **Retry deployment**。
|
||||
- 等待新的部署完成后,你的 KVideo 就部署成功了!
|
||||
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 部署
|
||||
|
||||
@@ -672,6 +667,8 @@ npm start
|
||||
或通过 U 盘、文件管理器等方式侧载安装。
|
||||
|
||||
> **注意**:此 APK 是一个 WebView 壳应用,需要你的 KVideo 实例已经部署并可访问。APK 本身不包含 KVideo 代码,仅作为 TV 端的浏览器入口。
|
||||
>
|
||||
> **最低系统要求**:Android 8.0 (API 26) 及以上。Android 7.0 及更低版本的 WebView 不支持本项目使用的 ES2017+ JavaScript 特性和现代 CSS,可能导致白屏。如遇白屏问题,请升级系统 WebView 或使用 Android 8.0+ 设备。
|
||||
|
||||
#### 选项 6:Apple TV 应用构建
|
||||
|
||||
@@ -739,6 +736,34 @@ npm start
|
||||
|
||||
> **自动化部署**:本项目使用 GitHub Actions 自动构建和发布 Docker 镜像。每次代码推送到 main 分支时,会自动构建多架构镜像并推送到 Docker Hub。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Cloudflare Pages 部署报 "Unknown internal error"
|
||||
|
||||
这是 Cloudflare 的临时服务端错误,与代码无关。请在 Deployments 列表中重试部署即可。项目已内置 `wrangler.toml` 配置 `nodejs_compat` 兼容性标志。
|
||||
|
||||
### IPv6 环境下 HTTPS 访问视频无法播放
|
||||
|
||||
如果你的网络使用 IPv6 访问,且通过路由器端口映射(如 20443 → 443),请确保:
|
||||
- 反向代理(如 Caddy/Nginx)正确监听 IPv6 地址
|
||||
- 路由器的 IPv6 端口映射规则与 IPv4 一致
|
||||
- 如使用非标准端口,确保 IPv6 防火墙规则也已放行
|
||||
|
||||
这是网络/反向代理配置问题,非 KVideo 代码问题。
|
||||
|
||||
### Android 7.0 设备白屏
|
||||
|
||||
Android 7.0 (API 24) 的 WebView 基于 Chrome 51,不支持本项目使用的现代 JavaScript(ES2017+)和 CSS 特性。最低要求 Android 8.0 (API 26) 及以上。
|
||||
|
||||
### IPTV 部分直播流无法播放
|
||||
|
||||
浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持:
|
||||
- RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer)
|
||||
- 某些加密或受 DRM 保护的流
|
||||
- 需要特定客户端验证的流
|
||||
|
||||
KVideo 已内置代理服务器自动处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
|
||||
|
||||
## 贡献代码
|
||||
|
||||
我们非常欢迎各种形式的贡献!无论是报告 Bug、提出新功能建议、改进文档,还是提交代码,你的每一份贡献都让这个项目变得更好。
|
||||
|
||||
+108
-67
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* IPTV Stream Proxy API Route
|
||||
* Proxies HLS manifests and media segments to avoid CORS issues.
|
||||
* For .m3u8 manifests, rewrites URLs to also route through this proxy.
|
||||
* For .m3u8/.m3u manifests, rewrites URLs to also route through this proxy.
|
||||
* Supports HLS, MPEG-TS, and other stream formats with automatic content detection.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const STREAM_TIMEOUT_MS = 20000;
|
||||
const REALISTIC_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
|
||||
function resolveUrl(base: string, relative: string): string {
|
||||
if (relative.startsWith('http://') || relative.startsWith('https://')) {
|
||||
return relative;
|
||||
@@ -21,10 +25,18 @@ function resolveUrl(base: string, relative: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function buildProxyBase(customUa?: string | null, customReferer?: string | null): string {
|
||||
let base = '/api/iptv/stream?';
|
||||
if (customUa) base += `ua=${encodeURIComponent(customUa)}&`;
|
||||
if (customReferer) base += `referer=${encodeURIComponent(customReferer)}&`;
|
||||
base += 'url=';
|
||||
return base;
|
||||
}
|
||||
|
||||
function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): string {
|
||||
return content.split('\n').map(line => {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines and comments (but process URI= in EXT tags)
|
||||
// Skip empty lines
|
||||
if (!trimmed) return line;
|
||||
|
||||
// Rewrite URI="..." in EXT-X-KEY, EXT-X-MAP, etc.
|
||||
@@ -44,6 +56,40 @@ function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): strin
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function isM3u8Url(url: string): boolean {
|
||||
const lower = url.toLowerCase().split('?')[0];
|
||||
return lower.endsWith('.m3u8') || lower.endsWith('.m3u');
|
||||
}
|
||||
|
||||
function isM3u8ContentType(contentType: string): boolean {
|
||||
const lower = contentType.toLowerCase();
|
||||
return lower.includes('mpegurl') ||
|
||||
lower.includes('x-mpegurl') ||
|
||||
lower.includes('vnd.apple.mpegurl') ||
|
||||
lower.includes('x-scpls');
|
||||
}
|
||||
|
||||
function isAmbiguousContentType(contentType: string): boolean {
|
||||
if (!contentType) return true;
|
||||
const lower = contentType.toLowerCase();
|
||||
return lower.includes('text/plain') ||
|
||||
lower.includes('application/octet-stream') ||
|
||||
lower.includes('binary/octet-stream') ||
|
||||
lower.includes('text/html');
|
||||
}
|
||||
|
||||
function isM3u8Content(text: string): boolean {
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed.startsWith('#EXTM3U') || trimmed.startsWith('#EXT-X-');
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
const customUa = request.nextUrl.searchParams.get('ua');
|
||||
@@ -56,10 +102,11 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const fetchHeaders: Record<string, string> = {
|
||||
'User-Agent': customUa || 'Mozilla/5.0 (compatible; KVideo/1.0)',
|
||||
'User-Agent': customUa || REALISTIC_USER_AGENT,
|
||||
'Accept': '*/*',
|
||||
'Referer': customReferer || `${parsedUrl.protocol}//${parsedUrl.host}/`,
|
||||
'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
'Connection': 'keep-alive',
|
||||
};
|
||||
|
||||
// Forward Range header for partial content requests
|
||||
@@ -69,7 +116,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
const timeout = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: fetchHeaders,
|
||||
@@ -78,103 +125,101 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response.ok && response.status !== 206) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch: ${response.status}` },
|
||||
{ error: `Upstream returned ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let isM3u8 = url.includes('.m3u8') ||
|
||||
contentType.includes('mpegurl') ||
|
||||
contentType.includes('x-mpegURL');
|
||||
let isM3u8 = isM3u8Url(url) || isM3u8ContentType(contentType);
|
||||
const proxyBase = buildProxyBase(customUa, customReferer);
|
||||
|
||||
// If content-type is ambiguous, check the response body for M3U header
|
||||
// Use clone() to avoid consuming the original body for binary streams
|
||||
if (!isM3u8 && (contentType.includes('text/plain') || contentType.includes('application/octet-stream') || !contentType)) {
|
||||
if (!isM3u8 && isAmbiguousContentType(contentType)) {
|
||||
const cloned = response.clone();
|
||||
const text = await cloned.text();
|
||||
if (text.trimStart().startsWith('#EXTM3U') || text.trimStart().startsWith('#EXT-X-')) {
|
||||
isM3u8 = true;
|
||||
// Read first 1KB to check for M3U8 header without consuming too much
|
||||
const reader = cloned.body?.getReader();
|
||||
if (reader) {
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
if (value) {
|
||||
const text = new TextDecoder().decode(value.slice(0, 1024));
|
||||
if (isM3u8Content(text)) {
|
||||
isM3u8 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// For detected M3U8 from body check, process inline
|
||||
|
||||
if (isM3u8) {
|
||||
const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`;
|
||||
const rewritten = rewriteM3u8(text, url, proxyBase);
|
||||
// Re-read the full body for M3U8 rewriting
|
||||
const fullText = await response.text();
|
||||
const rewritten = rewriteM3u8(fullText, url, proxyBase);
|
||||
return new NextResponse(rewritten, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Not M3U8, stream original binary body directly to preserve data integrity
|
||||
const body = response.body;
|
||||
return new NextResponse(body, {
|
||||
|
||||
// Not M3U8 — stream original binary body directly
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType || 'video/mp2t',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Cache-Control': 'no-cache',
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
};
|
||||
|
||||
if (isM3u8) {
|
||||
// Parse and rewrite manifest
|
||||
const text = await response.text();
|
||||
const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`;
|
||||
const rewritten = rewriteM3u8(text, url, proxyBase);
|
||||
|
||||
return new NextResponse(rewritten, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache',
|
||||
...corsHeaders,
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Pipe through media segments directly
|
||||
const body = response.body;
|
||||
const forwardContentType = contentType || 'video/mp2t';
|
||||
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': forwardContentType,
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
...corsHeaders,
|
||||
};
|
||||
|
||||
// Forward range-related headers
|
||||
const contentRange = response.headers.get('content-range');
|
||||
if (contentRange) responseHeaders['Content-Range'] = contentRange;
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
if (acceptRanges) responseHeaders['Accept-Ranges'] = acceptRanges;
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) responseHeaders['Content-Length'] = contentLength;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// Non-M3U8 media content — pipe through directly
|
||||
const body = response.body;
|
||||
const forwardContentType = contentType || 'video/mp2t';
|
||||
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': forwardContentType,
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
...CORS_HEADERS,
|
||||
};
|
||||
|
||||
// Forward range-related headers
|
||||
const contentRange = response.headers.get('content-range');
|
||||
if (contentRange) responseHeaders['Content-Range'] = contentRange;
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
if (acceptRanges) responseHeaders['Accept-Ranges'] = acceptRanges;
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) responseHeaders['Content-Length'] = contentLength;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown error';
|
||||
const isTimeout = message.includes('abort');
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to proxy stream' },
|
||||
{ status: 500 }
|
||||
{ error: isTimeout ? 'Stream request timed out' : 'Failed to proxy stream' },
|
||||
{ status: isTimeout ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -182,10 +227,6 @@ export async function GET(request: NextRequest) {
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
},
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function TagInput({
|
||||
onAddTag,
|
||||
}: TagInputProps) {
|
||||
return (
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="mb-6 flex gap-2 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagInput}
|
||||
|
||||
@@ -688,7 +688,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<div className="text-center" data-controls>
|
||||
<p className="text-red-400 text-sm mb-3">{error}</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<div className="flex gap-2 flex-wrap justify-center">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); loadChannel(currentUrl); }}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm transition-colors cursor-pointer"
|
||||
|
||||
@@ -70,7 +70,7 @@ export function IPTVSourceManager() {
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)]">
|
||||
直播源管理
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => refreshSources()}
|
||||
disabled={isLoading || sources.length === 0}
|
||||
|
||||
@@ -36,6 +36,14 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
|
||||
const badgeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded(prev => {
|
||||
const next = !prev;
|
||||
localStorage.setItem(TYPE_EXPAND_KEY, String(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboardNavigation({
|
||||
enabled: true,
|
||||
@@ -58,7 +66,8 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
|
||||
}, [badges, onToggleType]),
|
||||
});
|
||||
|
||||
// Check if content has overflow on mount and when badges change
|
||||
// Check if content has overflow on mount and when badge count changes
|
||||
const hasCheckedOverflow = useRef(false);
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (badgeContainerRef.current) {
|
||||
@@ -68,10 +77,13 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
// Recheck after a short delay to account for animations
|
||||
const timeout = setTimeout(checkOverflow, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [badges]);
|
||||
// Only do delayed recheck on first measurement
|
||||
if (!hasCheckedOverflow.current) {
|
||||
hasCheckedOverflow.current = true;
|
||||
const timeout = setTimeout(checkOverflow, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [badges.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -104,13 +116,7 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
|
||||
</div>
|
||||
{hasOverflow && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded(prev => {
|
||||
const next = !prev;
|
||||
localStorage.setItem(TYPE_EXPAND_KEY, String(next));
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onClick={toggleExpanded}
|
||||
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors self-start cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function SettingsSection({
|
||||
<div className="bg-[var(--glass-bg)] backdrop-filter backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6 mb-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) hover:translate-y-[-5px] hover:scale-[1.02] hover:shadow-[0_8px_24px_var(--shadow-color)] hover:z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)]">{title}</h2>
|
||||
{headerAction && <div className="flex gap-2">{headerAction}</div>}
|
||||
{headerAction && <div className="flex gap-2 flex-wrap">{headerAction}</div>}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-6 leading-[1.6]">
|
||||
|
||||
@@ -109,7 +109,7 @@ export function JsonImportTab() {
|
||||
</p>
|
||||
<div className="mt-2 space-y-1 max-h-[150px] overflow-y-auto">
|
||||
{preview.map((s, i) => (
|
||||
<div key={i} className="text-xs text-[var(--text-color-secondary)] flex gap-2">
|
||||
<div key={i} className="text-xs text-[var(--text-color-secondary)] flex gap-2 flex-wrap">
|
||||
<span className="font-medium text-[var(--text-color)]">{s.name}</span>
|
||||
<span className="truncate">{s.baseUrl}</span>
|
||||
</div>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.8",
|
||||
"version": "4.4.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.4.8",
|
||||
"version": "4.4.9",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.8",
|
||||
"version": "4.4.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Cloudflare Pages Wrangler Configuration
|
||||
# This file provides compatibility settings for Cloudflare Pages deployment.
|
||||
# It ensures nodejs_compat is always set, so users don't need to configure it manually.
|
||||
|
||||
name = "kvideo"
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[vars]
|
||||
# Environment variables can be set here or in Cloudflare Dashboard
|
||||
Reference in New Issue
Block a user