cc,优化界面和bug

This commit is contained in:
wdvipa
2026-02-11 22:15:16 +08:00
parent 28040495c8
commit 5b3aae981e
7 changed files with 1071 additions and 1188 deletions

View File

@@ -3,6 +3,14 @@ import { Card, Spin } from 'antd'
import { useSelector } from 'react-redux'
import type { RootState } from '../../store/store'
/** 画质档位参考billd-desk的参数化控制 */
const QUALITY_PROFILES = [
{ key: 'low', label: '低画质', fps: 5, quality: 30, resolution: '360P' },
{ key: 'medium', label: '中画质', fps: 10, quality: 45, resolution: '480P' },
{ key: 'high', label: '高画质', fps: 15, quality: 60, resolution: '720P' },
{ key: 'ultra', label: '超高画质', fps: 20, quality: 75, resolution: '1080P' },
]
interface DeviceScreenProps {
deviceId: string
onScreenSizeChange?: (size: { width: number, height: number }) => void
@@ -14,17 +22,26 @@ interface DeviceScreenProps {
const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChange }) => {
// const dispatch = useDispatch<AppDispatch>()
const canvasRef = useRef<HTMLCanvasElement>(null)
const fullscreenContainerRef = useRef<HTMLDivElement>(null)
const [isLoading, setIsLoading] = useState(true)
const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null)
const [isFullscreen, setIsFullscreen] = useState(false)
// ✅ FPS 计算:使用滑动窗口统计真实帧率
const fpsFrameTimesRef = useRef<number[]>([])
const [displayFps, setDisplayFps] = useState(0)
const lastFpsUpdateRef = useRef(0)
// ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染
const latestFrameRef = useRef<any>(null)
const rafIdRef = useRef<number>(0)
const isRenderingRef = useRef(false)
const imageSizeRef = useRef<{ width: number, height: number } | null>(null)
// ✅ 尺寸稳定性防止偶发异常帧导致canvas闪烁
const pendingSizeRef = useRef<{ width: number, height: number } | null>(null)
const pendingSizeCountRef = useRef(0)
const SIZE_STABLE_THRESHOLD = 3 // 连续3帧相同尺寸才更新
// ✅ 添加控制权状态跟踪,避免重复申请
const [isControlRequested, setIsControlRequested] = useState(false)
@@ -37,15 +54,105 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
const device = connectedDevices.find(d => d.id === deviceId)
// 📊 画质控制状态
const [currentProfile, setCurrentProfile] = useState('medium')
const [showQualityPanel, setShowQualityPanel] = useState(false)
const [networkStats, setNetworkStats] = useState({ fps: 0, dropRate: 0, avgFrameSize: 0 })
const frameCountRef = useRef(0)
const droppedFrameCountRef = useRef(0)
const feedbackTimerRef = useRef<number>(0)
const lastFeedbackTimeRef = useRef(0)
// ✅ 安全地通知父组件屏幕尺寸变化(在 useEffect 中而非渲染期间)
useEffect(() => {
if (imageSize && onScreenSizeChange) {
onScreenSizeChange(imageSize)
}
}, [imageSize, onScreenSizeChange])
// 📊 画质反馈:定期向服务端报告网络质量
useEffect(() => {
if (!webSocket || !deviceId) return
const sendFeedback = () => {
const now = Date.now()
if (now - lastFeedbackTimeRef.current < 3000) return // 3秒发一次
lastFeedbackTimeRef.current = now
const totalFrames = frameCountRef.current
const droppedFrames = droppedFrameCountRef.current
const dropRate = totalFrames > 0 ? droppedFrames / totalFrames : 0
const fps = displayFps
webSocket.emit('quality_feedback', {
deviceId,
fps,
dropRate,
})
setNetworkStats({ fps, dropRate, avgFrameSize: 0 })
// 重置计数
frameCountRef.current = 0
droppedFrameCountRef.current = 0
}
feedbackTimerRef.current = window.setInterval(sendFeedback, 3000)
// 监听服务端画质变更通知
const handleQualityChanged = (data: any) => {
if (data.deviceId === deviceId) {
if (data.profile) setCurrentProfile(data.profile)
}
}
webSocket.on('quality_changed', handleQualityChanged)
return () => {
if (feedbackTimerRef.current) clearInterval(feedbackTimerRef.current)
webSocket.off('quality_changed', handleQualityChanged)
}
}, [webSocket, deviceId, displayFps])
// 📊 切换画质档位
const handleSetProfile = useCallback((profileKey: string) => {
if (!webSocket) return
webSocket.emit('set_quality_profile', { deviceId, profile: profileKey })
setCurrentProfile(profileKey)
}, [webSocket, deviceId])
// ✅ 监听屏幕数据的独立useEffect避免与控制权逻辑混合
useEffect(() => {
if (!webSocket) return
const handleScreenData = (data: any) => {
if (data.deviceId === deviceId) {
// 📊 帧计数用于质量反馈
frameCountRef.current++
// ✅ 过滤黑屏帧Base64长度<4000字符(≈3KB JPEG)几乎肯定是黑屏/空白帧
// 正常480×854 JPEG即使最低质量也>8000字符
const dataLen = typeof data.data === 'string' ? data.data.length : 0
const MIN_VALID_FRAME_LENGTH = 4000
if (dataLen > 0 && dataLen < MIN_VALID_FRAME_LENGTH) {
// 黑屏帧丢弃保持canvas上一帧内容不变
droppedFrameCountRef.current++
if (frameCountRef.current % 30 === 0) {
console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 丢弃黑屏帧(${dataLen}字符 < ${MIN_VALID_FRAME_LENGTH}), 已丢弃: ${droppedFrameCountRef.current}`)
}
return
}
// 🔍 诊断:记录数据到达频率
if (frameCountRef.current % 30 === 0) {
console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 数据大小: ${dataLen}, 渲染中: ${isRenderingRef.current}, 解码中: ${decodingRef.current}`)
}
// ✅ 只保存最新帧引用,不立即解码
latestFrameRef.current = data
setIsLoading(false)
// 只在首帧时更新loading状态避免每帧触发重渲染
if (isLoading) setIsLoading(false)
// ✅ 如果没有正在进行的渲染循环,启动一个
if (!isRenderingRef.current) {
@@ -136,18 +243,21 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
}
}, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 🔧 不包含lastControlRequestTime避免重复执行
// ✅ rAF 驱动的渲染函数:取最新帧解码并绘制,一次只解码一帧
// ✅ 高性能渲染createImageBitmap 离屏解码 + 持续 rAF 循环
const decodingRef = useRef(false)
const renderLatestFrame = useCallback(() => {
const doRender = () => {
const frameData = latestFrameRef.current
if (!frameData) {
// 没有新帧,停止循环,等 handleScreenData 重新启动
isRenderingRef.current = false
return
}
// 取走帧数据,清空引用
// 取走帧数据
latestFrameRef.current = null
const canvas = canvasRef.current
if (!canvas) {
isRenderingRef.current = false
@@ -155,120 +265,135 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
}
if (!frameData?.data || !frameData?.format) {
// 无效帧,检查是否有新帧等待
if (latestFrameRef.current) {
rafIdRef.current = requestAnimationFrame(doRender)
} else {
isRenderingRef.current = false
}
rafIdRef.current = requestAnimationFrame(doRender)
return
}
const img = new Image()
img.onload = () => {
try {
// 上一帧还在解码,把数据放回去,下个 rAF 再试
if (decodingRef.current) {
latestFrameRef.current = frameData
rafIdRef.current = requestAnimationFrame(doRender)
return
}
decodingRef.current = true
let blobPromise: Promise<Blob>
if (typeof frameData.data === 'string') {
const binaryStr = atob(frameData.data)
const len = binaryStr.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
blobPromise = Promise.resolve(new Blob([bytes], { type: `image/${frameData.format.toLowerCase()}` }))
} else {
blobPromise = Promise.resolve(new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` }))
}
blobPromise
.then(blob => createImageBitmap(blob))
.then(bitmap => {
decodingRef.current = false
const ctx = canvas.getContext('2d')
if (!ctx) {
if (latestFrameRef.current) rafIdRef.current = requestAnimationFrame(doRender)
else isRenderingRef.current = false
bitmap.close()
rafIdRef.current = requestAnimationFrame(doRender)
return
}
// ✅ 只在尺寸变化时才重设 canvas 尺寸(避免清空画布导致黑屏)
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width
canvas.height = img.height
// 只在canvas内部分辨率需要增大时才更新避免偶发小帧清空画布导致闪烁
if (canvas.width < bitmap.width || canvas.height < bitmap.height) {
canvas.width = Math.max(canvas.width, bitmap.width)
canvas.height = Math.max(canvas.height, bitmap.height)
}
// 直接绘制,覆盖上一帧
// 每帧绘制前清除canvas可能比bitmap大
ctx.clearRect(0, 0, canvas.width, canvas.height)
switch (screenDisplay.fitMode) {
case 'fit':
drawFitMode(ctx, img, canvas)
drawFitMode(ctx, bitmap, canvas)
break
case 'fill':
drawFillMode(ctx, img, canvas)
drawFillMode(ctx, bitmap, canvas)
break
case 'stretch':
drawStretchMode(ctx, img, canvas)
drawStretchMode(ctx, bitmap, canvas)
break
case 'original':
drawOriginalMode(ctx, img, canvas)
drawOriginalMode(ctx, bitmap, canvas)
break
}
// 更新图片尺寸
setImageSize(prev => {
if (prev && prev.width === img.width && prev.height === img.height) return prev
if (onScreenSizeChange) onScreenSizeChange({ width: img.width, height: img.height })
return { width: img.width, height: img.height }
})
// ✅ 更新FPS统计
const bw = bitmap.width
const bh = bitmap.height
const prevSize = imageSizeRef.current
if (!prevSize || prevSize.width !== bw || prevSize.height !== bh) {
// 尺寸稳定性检查:只有连续多帧相同尺寸才更新,防止偶发异常帧闪烁
const pending = pendingSizeRef.current
if (pending && pending.width === bw && pending.height === bh) {
pendingSizeCountRef.current++
} else {
pendingSizeRef.current = { width: bw, height: bh }
pendingSizeCountRef.current = 1
}
if (pendingSizeCountRef.current >= SIZE_STABLE_THRESHOLD || !prevSize) {
imageSizeRef.current = { width: bw, height: bh }
setImageSize({ width: bw, height: bh })
pendingSizeRef.current = null
pendingSizeCountRef.current = 0
}
} else {
// 尺寸未变重置pending
pendingSizeRef.current = null
pendingSizeCountRef.current = 0
}
const now = Date.now()
fpsFrameTimesRef.current.push(now)
const cutoff = now - 2000
fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff)
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
} catch (drawError) {
console.error('绘制图像失败:', drawError)
}
// 这帧画完了,检查是否有新帧等待
if (latestFrameRef.current) {
if (now - lastFpsUpdateRef.current > 500) {
lastFpsUpdateRef.current = now
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
}
bitmap.close()
// 解码完成后始终调度下一帧,保持循环活跃
rafIdRef.current = requestAnimationFrame(doRender)
} else {
isRenderingRef.current = false
}
}
img.onerror = () => {
console.error('图像加载失败')
if (latestFrameRef.current) {
})
.catch(err => {
decodingRef.current = false
console.error('图像解码失败:', err)
rafIdRef.current = requestAnimationFrame(doRender)
} else {
isRenderingRef.current = false
}
}
// 设置图像数据源
if (typeof frameData.data === 'string') {
img.src = `data:image/${frameData.format.toLowerCase()};base64,${frameData.data}`
} else {
const blob = new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` })
const url = URL.createObjectURL(blob)
const origOnload = img.onload as (() => void)
img.onload = () => {
URL.revokeObjectURL(url)
origOnload()
}
img.src = url
}
})
}
doRender()
}, [screenDisplay.fitMode])
const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
const scale = Math.min(canvas.width / img.width, canvas.height / img.height)
const x = (canvas.width - img.width * scale) / 2
const y = (canvas.height - img.height * scale) / 2
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
}
const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
const scale = Math.max(canvas.width / img.width, canvas.height / img.height)
const x = (canvas.width - img.width * scale) / 2
const y = (canvas.height - img.height * scale) / 2
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
}
const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}
const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
const x = (canvas.width - img.width) / 2
const y = (canvas.height - img.height) / 2
ctx.drawImage(img, x, y)
@@ -890,6 +1015,65 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
setDragPath([]) // 🔧 清理拖拽路径
}, [])
// 🔍 全屏切换
const toggleFullscreen = useCallback(() => {
const container = fullscreenContainerRef.current
if (!container) return
if (!document.fullscreenElement) {
container.requestFullscreen().catch(err => {
console.warn('进入全屏失败:', err)
})
} else {
document.exitFullscreen()
}
}, [])
// 监听 fullscreenchange 事件同步状态
useEffect(() => {
const onFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', onFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', onFullscreenChange)
}
}, [])
// 全屏模式下计算 canvas 的 CSS 尺寸(保持宽高比,适配屏幕)
// 使用 state 存储全屏容器尺寸确保全屏切换和窗口resize时触发重渲染
const [containerSize, setContainerSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
useEffect(() => {
if (!isFullscreen) return
const updateSize = () => {
setContainerSize({ w: window.innerWidth, h: window.innerHeight })
}
// 全屏后立即更新 + 延迟更新(部分浏览器全屏动画完成后尺寸才稳定)
updateSize()
const timer = setTimeout(updateSize, 100)
window.addEventListener('resize', updateSize)
return () => {
clearTimeout(timer)
window.removeEventListener('resize', updateSize)
}
}, [isFullscreen])
const getCanvasStyle = useCallback((): React.CSSProperties => {
if (!isFullscreen || !imageSize || containerSize.w === 0) {
return {
width: imageSize ? `${imageSize.width}px` : '100%',
height: imageSize ? `${imageSize.height}px` : 'auto',
}
}
// 全屏时canvas 按宽高比缩放填满屏幕
const scale = Math.min(containerSize.w / imageSize.width, containerSize.h / imageSize.height)
return {
width: `${Math.round(imageSize.width * scale)}px`,
height: `${Math.round(imageSize.height * scale)}px`,
}
}, [isFullscreen, imageSize, containerSize])
if (!device) {
@@ -916,6 +1100,7 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
</style>
<div
ref={fullscreenContainerRef}
style={{
position: 'relative',
width: '100%',
@@ -923,29 +1108,12 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
overflow: 'hidden'
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: isFullscreen ? '#000' : undefined,
}}
>
{/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */}
{!device?.screenReader?.enabled && (
<div style={{
position: 'absolute',
top: '-10px',
left: '20px',
zIndex: 20,
color: '#fff',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: '8px 16px',
borderRadius: '4px',
fontSize: '14px'
}}>
<div style={{ fontSize: '12px', opacity: 0.8 }}>
FPS: {displayFps}
</div>
</div>
)}
{/* 操作状态指示器 */}
{!operationEnabled && (
@@ -984,16 +1152,14 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
</div>
)}
{/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */}
<div style={{ position: 'relative' }}>
<canvas
ref={canvasRef}
width={imageSize?.width || device?.screenWidth || 360}
height={imageSize?.height || device?.screenHeight || 640}
style={{
width: imageSize ? `${imageSize.width}px` : '100%',
height: imageSize ? `${imageSize.height}px` : 'auto',
objectFit: 'none',
...getCanvasStyle(),
cursor: 'pointer',
display: 'block'
display: 'block',
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
@@ -1001,6 +1167,91 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
onMouseLeave={handleMouseLeave}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
{/* 📊 画质控制面板 + 🔍 全屏按钮 */}
<div style={{
position: 'absolute',
bottom: '8px',
right: '8px',
zIndex: 20,
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<span style={{
color: '#fff',
fontSize: '11px',
background: 'rgba(0,0,0,0.5)',
padding: '2px 6px',
borderRadius: '3px',
fontFamily: 'monospace',
}}>
{displayFps} FPS{networkStats.dropRate > 0.05 ? `${(networkStats.dropRate * 100).toFixed(0)}%丢帧` : ''}
</span>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowQualityPanel(!showQualityPanel)}
style={{
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: 'none',
padding: '2px 8px',
borderRadius: '3px',
fontSize: '11px',
cursor: 'pointer',
}}
>
{QUALITY_PROFILES.find(p => p.key === currentProfile)?.label || '自定义'}
</button>
{showQualityPanel && (
<div style={{
position: 'absolute',
bottom: '100%',
right: 0,
marginBottom: '4px',
background: 'rgba(0,0,0,0.85)',
borderRadius: '6px',
padding: '8px',
minWidth: '120px',
}}>
{QUALITY_PROFILES.map(p => (
<div
key={p.key}
onClick={() => { handleSetProfile(p.key); setShowQualityPanel(false) }}
style={{
color: currentProfile === p.key ? '#1890ff' : '#fff',
fontSize: '12px',
padding: '4px 8px',
cursor: 'pointer',
borderRadius: '3px',
background: currentProfile === p.key ? 'rgba(24,144,255,0.15)' : 'transparent',
}}
>
{p.label} ({p.fps}fps / {p.resolution})
</div>
))}
</div>
)}
</div>
{/* 🔍 全屏/退出全屏按钮 */}
<button
onClick={toggleFullscreen}
title={isFullscreen ? '退出全屏 (ESC)' : '全屏显示'}
style={{
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: 'none',
padding: '2px 8px',
borderRadius: '3px',
fontSize: '13px',
cursor: 'pointer',
lineHeight: 1,
}}
>
{isFullscreen ? '⮌' : '⛶'}
</button>
</div>
</div>
</>
)