cc,优化界面和bug
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user