优化超分,优化弹幕弹窗

This commit is contained in:
mtvpls
2025-12-03 23:49:46 +08:00
parent 820fc972df
commit 6f83ef731a
8 changed files with 304 additions and 539 deletions

View File

@@ -35,7 +35,7 @@ function VersionDisplay() {
return (
<button
onClick={() =>
window.open('https://github.com/MoonTechLab/LunaTV', '_blank')
window.open('https://github.com/mtvpls/MoonTVPlus', '_blank')
}
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
>

View File

@@ -39,6 +39,7 @@ import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout';
import DoubanComments from '@/components/DoubanComments';
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
import Toast, { ToastProps } from '@/components/Toast';
import { useEnableComments } from '@/hooks/useEnableComments';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
@@ -186,6 +187,7 @@ function PlayPageClient() {
const [danmakuMatches, setDanmakuMatches] = useState<DanmakuAnime[]>([]);
const [showDanmakuSourceSelector, setShowDanmakuSourceSelector] = useState(false);
const [showDanmakuFilterSettings, setShowDanmakuFilterSettings] = useState(false);
const [toast, setToast] = useState<ToastProps | null>(null);
useEffect(() => {
danmakuSettingsRef.current = danmakuSettings;
@@ -667,6 +669,9 @@ function PlayPageClient() {
const initAnime4K = async () => {
if (!artPlayerRef.current?.video) return;
let frameRequestId: number | null = null; // 在外层声明,以便错误处理中使用
let outputCanvas: HTMLCanvasElement | null = null; // 在外层声明,以便错误处理中清理
try {
if (anime4kRef.current) {
anime4kRef.current.stop?.();
@@ -697,42 +702,138 @@ function PlayPageClient() {
throw new Error('无法获取视频尺寸');
}
const canvas = document.createElement('canvas');
// 检查视频是否正在播放
console.log('视频播放状态:', {
paused: video.paused,
ended: video.ended,
readyState: video.readyState,
currentTime: video.currentTime,
});
// 检测是否为Firefox
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
console.log('浏览器检测:', isFirefox ? 'Firefox' : 'Chrome/Edge/其他');
// 创建输出canvas显示给用户的
outputCanvas = document.createElement('canvas');
const container = artPlayerRef.current.template.$video.parentElement;
// 使用用户选择的超分倍数
const scale = anime4kScaleRef.current;
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.objectFit = 'contain';
canvas.style.cursor = 'pointer';
outputCanvas.width = Math.floor(video.videoWidth * scale); // 确保是整数
outputCanvas.height = Math.floor(video.videoHeight * scale);
// 在canvas上监听点击事件触发播放器的暂停/播放切换
// 验证outputCanvas尺寸
console.log('outputCanvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
if (!outputCanvas.width || !outputCanvas.height ||
!isFinite(outputCanvas.width) || !isFinite(outputCanvas.height)) {
throw new Error(`outputCanvas尺寸无效: ${outputCanvas.width}x${outputCanvas.height}, scale: ${scale}`);
}
outputCanvas.style.position = 'absolute';
outputCanvas.style.top = '0';
outputCanvas.style.left = '0';
outputCanvas.style.width = '100%';
outputCanvas.style.height = '100%';
outputCanvas.style.objectFit = 'contain';
outputCanvas.style.cursor = 'pointer';
outputCanvas.style.zIndex = '1';
// 确保canvas背景透明避免Firefox中的渲染问题
outputCanvas.style.backgroundColor = 'transparent';
// Firefox兼容性处理创建中间canvas
let sourceCanvas: HTMLCanvasElement | null = null;
let sourceCtx: CanvasRenderingContext2D | null = null;
if (isFirefox) {
// Firefox的WebGPU不支持直接使用HTMLVideoElement
// 使用标准HTMLCanvasElement更好的兼容性
sourceCanvas = document.createElement('canvas');
// 获取视频尺寸并记录
const videoW = video.videoWidth;
const videoH = video.videoHeight;
console.log('Firefox准备创建canvas - 视频尺寸:', videoW, 'x', videoH);
// 设置canvas尺寸
const canvasW = Math.floor(videoW);
const canvasH = Math.floor(videoH);
console.log('Firefox计算后的canvas尺寸:', canvasW, 'x', canvasH);
sourceCanvas.width = canvasW;
sourceCanvas.height = canvasH;
// 立即验证赋值结果
console.log('FirefoxCanvas创建后立即检查:');
console.log(' - sourceCanvas.width:', sourceCanvas.width);
console.log(' - sourceCanvas.height:', sourceCanvas.height);
console.log(' - 赋值是否成功:', sourceCanvas.width === canvasW && sourceCanvas.height === canvasH);
// 验证sourceCanvas尺寸
if (!sourceCanvas.width || !sourceCanvas.height ||
!isFinite(sourceCanvas.width) || !isFinite(sourceCanvas.height)) {
throw new Error(`sourceCanvas尺寸无效: ${sourceCanvas.width}x${sourceCanvas.height}`);
}
if (sourceCanvas.width !== canvasW || sourceCanvas.height !== canvasH) {
throw new Error(`sourceCanvas尺寸赋值异常: 期望 ${canvasW}x${canvasH}, 实际 ${sourceCanvas.width}x${sourceCanvas.height}`);
}
sourceCtx = sourceCanvas.getContext('2d', {
willReadFrequently: true,
alpha: false // 禁用alpha通道提高性能
});
if (!sourceCtx) {
throw new Error('无法创建2D上下文');
}
// 先绘制一帧到canvas确保有内容
if (video.readyState >= video.HAVE_CURRENT_DATA) {
sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height);
console.log('Firefox已绘制初始帧到sourceCanvas');
}
console.log('Firefox检测使用HTMLCanvasElement中转方案');
}
// 在outputCanvas上监听点击事件触发播放器的暂停/播放切换
const handleCanvasClick = () => {
if (artPlayerRef.current) {
artPlayerRef.current.toggle();
}
};
canvas.addEventListener('click', handleCanvasClick);
outputCanvas.addEventListener('click', handleCanvasClick);
// 在canvas上监听双击事件触发全屏切换
// 在outputCanvas上监听双击事件触发全屏切换
const handleCanvasDblClick = () => {
if (artPlayerRef.current) {
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
}
};
canvas.addEventListener('dblclick', handleCanvasDblClick);
outputCanvas.addEventListener('dblclick', handleCanvasDblClick);
// 隐藏原始video元素
video.style.display = 'none';
// 隐藏原始video元素使用opacity而不是display:none以保持视频解码
// Firefox在display:none时可能会停止视频解码导致黑屏
video.style.opacity = '0';
video.style.pointerEvents = 'none';
video.style.position = 'absolute';
video.style.zIndex = '-1';
// 插入canvas到容器
container.insertBefore(canvas, video);
// 插入outputCanvas到容器
container.insertBefore(outputCanvas, video);
// Firefox兼容性创建视频帧捕获循环
if (isFirefox && sourceCtx && sourceCanvas) {
const captureVideoFrame = () => {
if (sourceCtx && sourceCanvas && video.readyState >= video.HAVE_CURRENT_DATA) {
sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height);
}
frameRequestId = requestAnimationFrame(captureVideoFrame);
};
captureVideoFrame();
console.log('Firefox视频帧捕获循环已启动');
}
// 动态导入 anime4k-webgpu 及对应的模式
const { render: anime4kRender, ModeA, ModeB, ModeC, ModeAA, ModeBB, ModeCA } = await import('anime4k-webgpu');
@@ -764,28 +865,66 @@ function PlayPageClient() {
}
// 使用anime4k-webgpu的render函数
// Firefox使用sourceCanvas其他浏览器直接使用video
const renderConfig: any = {
video,
canvas,
video: isFirefox ? sourceCanvas : video, // Firefox使用canvas中转其他浏览器直接使用video
canvas: outputCanvas,
pipelineBuilder: (device: GPUDevice, inputTexture: GPUTexture) => {
if (!outputCanvas) {
throw new Error('outputCanvas is null in pipelineBuilder');
}
const mode = new ModeClass({
device,
inputTexture,
nativeDimensions: {
width: video.videoWidth,
height: video.videoHeight,
width: Math.floor(video.videoWidth), // 确保是整数
height: Math.floor(video.videoHeight),
},
targetDimensions: {
width: canvas.width,
height: canvas.height,
width: Math.floor(outputCanvas.width), // 确保是整数
height: Math.floor(outputCanvas.height),
},
});
return [mode];
},
};
console.log('开始初始化Anime4K渲染器...');
console.log('输入源:', isFirefox ? 'HTMLCanvasElement (Firefox兼容)' : 'video (原生)');
console.log('视频尺寸:', video.videoWidth, 'x', video.videoHeight);
console.log('输出Canvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
console.log('nativeDimensions:', Math.floor(video.videoWidth), 'x', Math.floor(video.videoHeight));
console.log('targetDimensions:', Math.floor(outputCanvas.width), 'x', Math.floor(outputCanvas.height));
// Firefox调试检查sourceCanvas状态
if (isFirefox && sourceCanvas) {
console.log('sourceCanvas详细信息:');
console.log(' - width:', sourceCanvas.width, 'height:', sourceCanvas.height);
console.log(' - clientWidth:', sourceCanvas.clientWidth, 'clientHeight:', sourceCanvas.clientHeight);
console.log(' - offsetWidth:', sourceCanvas.offsetWidth, 'offsetHeight:', sourceCanvas.offsetHeight);
// 尝试读取一个像素确认canvas有内容
if (sourceCtx) {
try {
const imageData = sourceCtx.getImageData(0, 0, 1, 1);
console.log(' - 像素数据可读:', imageData.data.length > 0);
} catch (err) {
console.error(' - 无法读取像素数据:', err);
}
}
}
const controller = await anime4kRender(renderConfig);
anime4kRef.current = { controller, canvas, handleCanvasClick, handleCanvasDblClick };
console.log('Anime4K渲染器初始化成功');
anime4kRef.current = {
controller,
canvas: outputCanvas,
sourceCanvas: isFirefox ? sourceCanvas : null,
frameRequestId: isFirefox ? frameRequestId : null,
handleCanvasClick,
handleCanvasDblClick,
};
console.log('Anime4K超分已启用模式:', anime4kModeRef.current, '倍数:', scale);
if (artPlayerRef.current) {
@@ -796,9 +935,23 @@ function PlayPageClient() {
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '超分启用失败:' + (err instanceof Error ? err.message : '未知错误');
}
// 停止帧捕获循环
if (frameRequestId) {
cancelAnimationFrame(frameRequestId);
}
// 移除outputCanvas如果已创建
if (outputCanvas && outputCanvas.parentNode) {
outputCanvas.parentNode.removeChild(outputCanvas);
}
// 恢复video显示
if (artPlayerRef.current?.video) {
artPlayerRef.current.video.style.display = 'block';
artPlayerRef.current.video.style.opacity = '1';
artPlayerRef.current.video.style.pointerEvents = 'auto';
artPlayerRef.current.video.style.position = '';
artPlayerRef.current.video.style.zIndex = '';
}
}
};
@@ -807,6 +960,12 @@ function PlayPageClient() {
const cleanupAnime4K = async () => {
if (anime4kRef.current) {
try {
// 停止帧捕获循环仅Firefox
if (anime4kRef.current.frameRequestId) {
cancelAnimationFrame(anime4kRef.current.frameRequestId);
console.log('Firefox帧捕获循环已停止');
}
// 停止渲染循环
anime4kRef.current.controller?.stop?.();
@@ -825,11 +984,33 @@ function PlayPageClient() {
anime4kRef.current.canvas.parentNode.removeChild(anime4kRef.current.canvas);
}
// 清理sourceCanvas仅Firefox
if (anime4kRef.current.sourceCanvas) {
if (anime4kRef.current.sourceCanvas instanceof OffscreenCanvas) {
// OffscreenCanvas的清理
const ctx = anime4kRef.current.sourceCanvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height);
}
console.log('FirefoxOffscreenCanvas已清理');
} else {
// HTMLCanvasElement的清理
const ctx = anime4kRef.current.sourceCanvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height);
}
console.log('FirefoxHTMLCanvasElement已清理');
}
}
anime4kRef.current = null;
// 恢复原始video显示
if (artPlayerRef.current?.video) {
artPlayerRef.current.video.style.display = 'block';
artPlayerRef.current.video.style.opacity = '1';
artPlayerRef.current.video.style.pointerEvents = 'auto';
artPlayerRef.current.video.style.position = '';
artPlayerRef.current.video.style.zIndex = '';
}
console.log('Anime4K已清理');
@@ -3397,6 +3578,9 @@ function PlayPageClient() {
)}
</div>
{/* Toast通知 */}
{toast && <Toast {...toast} />}
{/* 弹幕过滤设置对话框 */}
<DanmakuFilterSettings
isOpen={showDanmakuFilterSettings}
@@ -3405,6 +3589,13 @@ function PlayPageClient() {
setDanmakuFilterConfig(config);
danmakuFilterConfigRef.current = config;
}}
onShowToast={(message, type) => {
setToast({
message,
type,
onClose: () => setToast(null),
});
}}
/>
</PageLayout>
);

View File

@@ -10,12 +10,14 @@ interface DanmakuFilterSettingsProps {
isOpen: boolean;
onClose: () => void;
onConfigUpdate?: (config: DanmakuFilterConfig) => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export default function DanmakuFilterSettings({
isOpen,
onClose,
onConfigUpdate,
onShowToast,
}: DanmakuFilterSettingsProps) {
const [config, setConfig] = useState<DanmakuFilterConfig>({ rules: [] });
const [newKeyword, setNewKeyword] = useState('');
@@ -54,10 +56,18 @@ export default function DanmakuFilterSettings({
if (onConfigUpdate) {
onConfigUpdate(config);
}
alert('保存成功!');
if (onShowToast) {
onShowToast('保存成功!', 'success');
}
// 延迟关闭面板让用户看到toast
setTimeout(() => {
onClose();
}, 300);
} catch (error) {
console.error('保存弹幕过滤配置失败:', error);
alert('保存失败,请重试');
if (onShowToast) {
onShowToast('保存失败,请重试', 'error');
}
} finally {
setSaving(false);
}
@@ -66,7 +76,9 @@ export default function DanmakuFilterSettings({
// 添加规则
const handleAddRule = () => {
if (!newKeyword.trim()) {
alert('请输入关键字');
if (onShowToast) {
onShowToast('请输入关键字', 'info');
}
return;
}
@@ -105,7 +117,7 @@ export default function DanmakuFilterSettings({
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/70">
<div className="bg-gray-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">

64
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,64 @@
'use client';
import { useEffect, useState } from 'react';
import { CheckCircle, XCircle, Info, X } from 'lucide-react';
export interface ToastProps {
message: string;
type?: 'success' | 'error' | 'info';
duration?: number;
onClose?: () => void;
}
export default function Toast({ message, type = 'info', duration = 3000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(() => {
onClose?.();
}, 300); // 等待动画完成
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const handleClose = () => {
setIsVisible(false);
setTimeout(() => {
onClose?.();
}, 300);
};
const icons = {
success: <CheckCircle className="w-5 h-5" />,
error: <XCircle className="w-5 h-5" />,
info: <Info className="w-5 h-5" />,
};
const colors = {
success: 'bg-green-500/90',
error: 'bg-red-500/90',
info: 'bg-blue-500/90',
};
return (
<div
className={`fixed top-20 left-1/2 -translate-x-1/2 z-[9999] transition-all duration-300 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
}`}
>
<div className={`${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px]`}>
<div className="flex-shrink-0">{icons[type]}</div>
<div className="flex-1 text-sm font-medium">{message}</div>
<button
onClick={handleClose}
className="flex-shrink-0 hover:bg-white/20 rounded p-1 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -13,7 +13,7 @@ export enum UpdateStatus {
// 远程版本检查URL配置
const VERSION_CHECK_URLS = [
'https://raw.githubusercontent.com/MoonTechLab/LunaTV/main/VERSION.txt',
'https://raw.githubusercontent.com/mtvpls/MoonTVPlus/main/VERSION.txt',
];
/**