Files
web-client/src/components/Device/DeviceScreen.tsx
wdvipa 5ce99d8708 fix: Web端投屏画面缩小闪烁问题
- canvas尺寸锁定策略:首次有效帧锁定canvas尺寸,后续帧统一drawImage缩放绘制
- 消除不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致的canvas反复resize
- 设备切换或断开时重置锁定尺寸,下次连接重新锁定
- 移除renderLatestFrame对screenDisplay.fitMode的无效依赖
2026-02-15 18:41:47 +08:00

1249 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useRef, useEffect, useState, useCallback } from 'react'
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
}
/**
* 设备屏幕显示组件
*/
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锁定尺寸首次有效帧确定canvas尺寸后不再变化
// 避免不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致闪烁
const lockedCanvasSizeRef = useRef<{ width: number, height: number } | null>(null)
// ✅ 添加控制权状态跟踪,避免重复申请
const [isControlRequested, setIsControlRequested] = useState(false)
const [currentWebSocket, setCurrentWebSocket] = useState<any>(null)
const [lastControlRequestTime, setLastControlRequestTime] = useState<number>(0)
const { webSocket } = useSelector((state: RootState) => state.connection)
const { connectedDevices } = useSelector((state: RootState) => state.devices)
const { screenDisplay, operationEnabled } = useSelector((state: RootState) => state.ui)
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
// 只在首帧时更新loading状态避免每帧触发重渲染
if (isLoading) setIsLoading(false)
// ✅ 如果没有正在进行的渲染循环,启动一个
if (!isRenderingRef.current) {
isRenderingRef.current = true
renderLatestFrame()
}
}
}
webSocket.on('screen_data', handleScreenData)
return () => {
webSocket.off('screen_data', handleScreenData)
// 清理 rAF
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = 0
}
isRenderingRef.current = false
// 设备切换或断开时重置锁定尺寸,下次连接重新锁定
lockedCanvasSizeRef.current = null
}
}, [webSocket, deviceId])
// ✅ 控制权管理的独立useEffect只在必要时申请/释放
useEffect(() => {
if (!webSocket || !deviceId) return
// 如果WebSocket实例发生变化重置控制权状态
if (currentWebSocket !== webSocket) {
setIsControlRequested(false)
setCurrentWebSocket(webSocket)
}
// 只在未申请过控制权时申请,且防止重复发送
if (!isControlRequested) {
const now = Date.now()
// 🔧 防止短时间内重复发送请求2秒内只能发送一次
if (now - lastControlRequestTime < 2000) {
console.log('⚠️ 控制权请求过于频繁,跳过')
return
}
console.log('🎮 申请设备控制权:', deviceId)
setLastControlRequestTime(now)
webSocket.emit('client_event', {
type: 'REQUEST_DEVICE_CONTROL',
data: { deviceId }
})
setIsControlRequested(true)
}
// 监听控制权响应
const handleControlResponse = (data: any) => {
if (data.deviceId === deviceId) {
if (data.success) {
console.log('✅ 控制权获取成功:', deviceId)
} else {
console.warn('❌ 控制权获取失败:', data.message)
// 如果失败,允许重新申请
setIsControlRequested(false)
}
}
}
// ✅ 监听控制错误,自动重新申请控制权
const handleControlError = (data: any) => {
if (data.deviceId === deviceId && data.error === 'NO_PERMISSION') {
console.warn('⚠️ 检测到权限丢失,重新申请控制权:', deviceId)
setIsControlRequested(false)
}
}
webSocket.on('device_control_response', handleControlResponse)
webSocket.on('control_error', handleControlError)
// 清理函数只在组件卸载或deviceId变化时释放控制权
return () => {
webSocket.off('device_control_response', handleControlResponse)
webSocket.off('control_error', handleControlError)
// 只有在已申请过控制权时才释放
if (isControlRequested) {
console.log('🔓 释放设备控制权:', deviceId)
webSocket.emit('client_event', {
type: 'RELEASE_DEVICE_CONTROL',
data: { deviceId }
})
}
}
}, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 🔧 不包含lastControlRequestTime避免重复执行
// ✅ 高性能渲染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
return
}
if (!frameData?.data || !frameData?.format) {
rafIdRef.current = requestAnimationFrame(doRender)
return
}
// 上一帧还在解码,把数据放回去,下个 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) {
bitmap.close()
rafIdRef.current = requestAnimationFrame(doRender)
return
}
// canvas尺寸锁定策略
// 首次有效帧确定canvas尺寸后锁定后续帧直接绘制到固定尺寸canvas上
// 避免不同采集模式帧尺寸不一致导致canvas反复resize闪烁
const locked = lockedCanvasSizeRef.current
if (!locked) {
// 首次帧锁定canvas尺寸
lockedCanvasSizeRef.current = { width: bitmap.width, height: bitmap.height }
canvas.width = bitmap.width
canvas.height = bitmap.height
} else if (canvas.width !== locked.width || canvas.height !== locked.height) {
// canvas被外部重置了恢复锁定尺寸
canvas.width = locked.width
canvas.height = locked.height
}
// 始终将bitmap绘制到整个canvas区域浏览器自动缩放
// 这样不同尺寸的帧都能正确显示,不会闪烁
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
// 使用锁定的canvas尺寸上报保持稳定
const reportSize = lockedCanvasSizeRef.current
if (reportSize) {
const prevSize = imageSizeRef.current
if (!prevSize || prevSize.width !== reportSize.width || prevSize.height !== reportSize.height) {
imageSizeRef.current = { width: reportSize.width, height: reportSize.height }
setImageSize({ width: reportSize.width, height: reportSize.height })
}
}
const now = Date.now()
fpsFrameTimesRef.current.push(now)
const cutoff = now - 2000
fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff)
if (now - lastFpsUpdateRef.current > 500) {
lastFpsUpdateRef.current = now
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
}
bitmap.close()
// 解码完成后始终调度下一帧,保持循环活跃
rafIdRef.current = requestAnimationFrame(doRender)
})
.catch(err => {
decodingRef.current = false
console.error('图像解码失败:', err)
rafIdRef.current = requestAnimationFrame(doRender)
})
}
doRender()
}, [])
const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
const scale = Math.min(canvas.width / img.width, canvas.height / img.height)
const w = img.width * scale
const h = img.height * scale
const x = (canvas.width - w) / 2
const y = (canvas.height - h) / 2
// 只清除图像未覆盖的边缘区域避免全画布clearRect导致闪烁
if (x > 0 || y > 0) {
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
ctx.drawImage(img, x, y, w, h)
}
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 | ImageBitmap, canvas: HTMLCanvasElement) => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}
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)
}
// 坐标转换函数将canvas坐标转换为设备坐标
const convertCanvasToDeviceCoords = useCallback((canvasX: number, canvasY: number, canvas: HTMLCanvasElement, device: any) => {
if (!device) return null
const deviceWidth = device.screenWidth
const deviceHeight = device.screenHeight
// 使用canvas的实际显示尺寸而不是内部分辨率
const rect = canvas.getBoundingClientRect()
const canvasWidth = rect.width
const canvasHeight = rect.height
let imageX: number, imageY: number, imageWidth: number, imageHeight: number
// 由于canvas使用了objectFit: 'contain',浏览器会自动保持宽高比
// 我们需要计算图像在canvas中的实际显示位置和尺寸
const scale = Math.min(canvasWidth / deviceWidth, canvasHeight / deviceHeight)
imageWidth = deviceWidth * scale
imageHeight = deviceHeight * scale
imageX = (canvasWidth - imageWidth) / 2
imageY = (canvasHeight - imageHeight) / 2
// 检查点击是否在图像区域内
if (canvasX < imageX || canvasX > imageX + imageWidth ||
canvasY < imageY || canvasY > imageY + imageHeight) {
console.warn('点击在图像区域外')
return null // 点击在图像区域外
}
// 将canvas坐标转换为图像内的相对坐标
const relativeX = canvasX - imageX
const relativeY = canvasY - imageY
// 转换为设备坐标
const deviceX = (relativeX / imageWidth) * deviceWidth
const deviceY = (relativeY / imageHeight) * deviceHeight
console.log('deviceX', deviceX)
console.log('deviceY', deviceY)
// 确保坐标在设备范围内
const clampedX = Math.max(0, Math.min(deviceWidth - 1, deviceX))
const clampedY = Math.max(0, Math.min(deviceHeight - 1, deviceY))
return { x: clampedX, y: clampedY }
}, [])
// const handleMouseEvent = useCallback((event: React.MouseEvent, action: string) => {
// if (!webSocket || !device) return
// const canvas = canvasRef.current
// if (!canvas) return
// const rect = canvas.getBoundingClientRect()
// const canvasX = event.clientX - rect.left
// const canvasY = event.clientY - rect.top
// // 根据显示模式计算正确的设备坐标
// const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device)
// if (!deviceCoords) {
// console.warn('无法转换坐标,可能点击在图像区域外')
// return
// }
// console.log(`点击位置转换: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`)
// webSocket.emit('control_message', {
// type: action,
// deviceId,
// data: { x: deviceCoords.x, y: deviceCoords.y },
// timestamp: Date.now()
// })
// // 显示触摸指示器
// if (screenDisplay.showTouchIndicator) {
// showTouchIndicator(canvasX, canvasY)
// }
// }, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords])
const showTouchIndicator = (x: number, y: number) => {
const indicator = document.createElement('div')
indicator.style.position = 'absolute'
indicator.style.left = `${x - 10}px`
indicator.style.top = `${y - 10}px`
indicator.style.width = '20px'
indicator.style.height = '20px'
indicator.style.borderRadius = '50%'
indicator.style.backgroundColor = 'rgba(24, 144, 255, 0.6)'
indicator.style.border = '2px solid #1890ff'
indicator.style.pointerEvents = 'none'
indicator.style.zIndex = '1000'
const container = canvasRef.current?.parentElement
if (container) {
container.style.position = 'relative'
container.appendChild(indicator)
setTimeout(() => {
container.removeChild(indicator)
}, 500)
}
}
const showSwipeIndicator = (startX: number, startY: number, endX: number, endY: number) => {
const container = canvasRef.current?.parentElement
if (!container) return
// 创建滑动轨迹线
const line = document.createElement('div')
const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2))
const angle = Math.atan2(endY - startY, endX - startX) * 180 / Math.PI
line.style.position = 'absolute'
line.style.left = `${startX}px`
line.style.top = `${startY}px`
line.style.width = `${length}px`
line.style.height = '2px'
line.style.backgroundColor = '#ff4d4f'
line.style.transformOrigin = '0 50%'
line.style.transform = `rotate(${angle}deg)`
line.style.pointerEvents = 'none'
line.style.zIndex = '1000'
// 创建箭头
const arrow = document.createElement('div')
arrow.style.position = 'absolute'
arrow.style.left = `${endX - 5}px`
arrow.style.top = `${endY - 5}px`
arrow.style.width = '10px'
arrow.style.height = '10px'
arrow.style.backgroundColor = '#ff4d4f'
arrow.style.transform = 'rotate(45deg)'
arrow.style.pointerEvents = 'none'
arrow.style.zIndex = '1000'
container.style.position = 'relative'
container.appendChild(line)
container.appendChild(arrow)
setTimeout(() => {
if (container.contains(line)) container.removeChild(line)
if (container.contains(arrow)) container.removeChild(arrow)
}, 800)
}
const showLongPressIndicator = (x: number, y: number) => {
const indicator = document.createElement('div')
indicator.style.position = 'absolute'
indicator.style.left = `${x - 15}px`
indicator.style.top = `${y - 15}px`
indicator.style.width = '30px'
indicator.style.height = '30px'
indicator.style.borderRadius = '50%'
indicator.style.backgroundColor = 'rgba(255, 77, 79, 0.6)'
indicator.style.border = '3px solid #ff4d4f'
indicator.style.pointerEvents = 'none'
indicator.style.zIndex = '1000'
indicator.style.animation = 'pulse 1s infinite'
const container = canvasRef.current?.parentElement
if (container) {
container.style.position = 'relative'
container.appendChild(indicator)
setTimeout(() => {
if (container.contains(indicator)) {
container.removeChild(indicator)
}
}, 1000)
}
}
// 🆕 显示长按拖拽开始指示器
const showLongPressDragStartIndicator = (x: number, y: number) => {
const container = canvasRef.current?.parentElement
if (!container) return
// 创建长按拖拽开始指示器(较大的圆圈,橙色)
const indicator = document.createElement('div')
indicator.style.position = 'absolute'
indicator.style.left = `${x - 20}px`
indicator.style.top = `${y - 20}px`
indicator.style.width = '40px'
indicator.style.height = '40px'
indicator.style.borderRadius = '50%'
indicator.style.backgroundColor = 'rgba(255, 165, 0, 0.7)'
indicator.style.border = '4px solid #ff8c00'
indicator.style.pointerEvents = 'none'
indicator.style.zIndex = '1000'
indicator.style.animation = 'pulse 0.8s infinite'
indicator.className = 'long-press-drag-start'
// 添加文字标识
const label = document.createElement('div')
label.style.position = 'absolute'
label.style.left = '50%'
label.style.top = '50%'
label.style.transform = 'translate(-50%, -50%)'
label.style.fontSize = '10px'
label.style.fontWeight = 'bold'
label.style.color = '#fff'
label.style.textShadow = '1px 1px 2px rgba(0,0,0,0.8)'
label.textContent = '拖'
indicator.appendChild(label)
container.style.position = 'relative'
container.appendChild(indicator)
return indicator
}
// 🆕 显示长按拖拽路径指示器
const showLongPressDragPath = (startX: number, startY: number, endX: number, endY: number) => {
const container = canvasRef.current?.parentElement
if (!container) return
// 清除之前的拖拽开始指示器
const existingIndicator = container.querySelector('.long-press-drag-start')
if (existingIndicator) {
container.removeChild(existingIndicator)
}
// 创建拖拽路径线(粗一些,橙色渐变)
const line = document.createElement('div')
const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2))
const angle = Math.atan2(endY - startY, endX - startX) * 180 / Math.PI
line.style.position = 'absolute'
line.style.left = `${startX}px`
line.style.top = `${startY}px`
line.style.width = `${length}px`
line.style.height = '4px'
line.style.background = 'linear-gradient(to right, #ff8c00, #ff6347)'
line.style.transformOrigin = '0 50%'
line.style.transform = `rotate(${angle}deg)`
line.style.pointerEvents = 'none'
line.style.zIndex = '999'
line.style.boxShadow = '0 0 6px rgba(255, 140, 0, 0.6)'
// 创建起始点指示器
const startIndicator = document.createElement('div')
startIndicator.style.position = 'absolute'
startIndicator.style.left = `${startX - 8}px`
startIndicator.style.top = `${startY - 8}px`
startIndicator.style.width = '16px'
startIndicator.style.height = '16px'
startIndicator.style.borderRadius = '50%'
startIndicator.style.backgroundColor = '#ff8c00'
startIndicator.style.border = '2px solid #fff'
startIndicator.style.pointerEvents = 'none'
startIndicator.style.zIndex = '1001'
// 创建结束点指示器(带箭头)
const endIndicator = document.createElement('div')
endIndicator.style.position = 'absolute'
endIndicator.style.left = `${endX - 12}px`
endIndicator.style.top = `${endY - 12}px`
endIndicator.style.width = '24px'
endIndicator.style.height = '24px'
endIndicator.style.borderRadius = '50%'
endIndicator.style.backgroundColor = '#ff6347'
endIndicator.style.border = '3px solid #fff'
endIndicator.style.pointerEvents = 'none'
endIndicator.style.zIndex = '1001'
endIndicator.style.boxShadow = '0 0 8px rgba(255, 99, 71, 0.8)'
// 添加箭头图标
const arrow = document.createElement('div')
arrow.style.position = 'absolute'
arrow.style.left = '50%'
arrow.style.top = '50%'
arrow.style.transform = 'translate(-50%, -50%)'
arrow.style.fontSize = '12px'
arrow.style.color = '#fff'
arrow.style.fontWeight = 'bold'
arrow.textContent = '→'
endIndicator.appendChild(arrow)
container.style.position = 'relative'
container.appendChild(line)
container.appendChild(startIndicator)
container.appendChild(endIndicator)
// 1.5秒后清除指示器
setTimeout(() => {
if (container.contains(line)) container.removeChild(line)
if (container.contains(startIndicator)) container.removeChild(startIndicator)
if (container.contains(endIndicator)) container.removeChild(endIndicator)
}, 1500)
}
// 添加滑动处理
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState<{x: number, y: number} | null>(null)
// 🆕 添加长按处理
const [isLongPressTriggered, setIsLongPressTriggered] = useState(false)
const longPressTimerRef = useRef<number | null>(null)
// 🆕 添加长按拖拽处理状态
const [isLongPressDragging, setIsLongPressDragging] = useState(false)
const [longPressDragStartPos, setLongPressDragStartPos] = useState<{x: number, y: number} | null>(null)
// 🆕 添加拖拽路径收集状态
const [dragPath, setDragPath] = useState<Array<{x: number, y: number}>>([])
const lastMoveTimeRef = useRef<number>(0)
// 处理真正的点击从mouseUp中调用
const performClick = useCallback((canvasX: number, canvasY: number) => {
if (!webSocket || !device) return
// 检查操作是否被允许
if (!operationEnabled) {
console.warn('屏幕点击操作已被阻止')
return
}
const canvas = canvasRef.current
if (!canvas) return
const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device)
if (!deviceCoords) {
console.warn('无法转换坐标,可能点击在图像区域外')
return
}
webSocket.emit('control_message', {
type: 'CLICK',
deviceId,
data: { x: deviceCoords.x, y: deviceCoords.y },
timestamp: Date.now()
})
// 显示触摸指示器
if (screenDisplay.showTouchIndicator) {
showTouchIndicator(canvasX, canvasY)
}
}, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, operationEnabled])
// 🆕 处理长按操作
const performLongPress = useCallback((canvasX: number, canvasY: number) => {
if (!webSocket || !device) return
// 检查操作是否被允许
if (!operationEnabled) {
console.warn('屏幕长按操作已被阻止')
return
}
const canvas = canvasRef.current
if (!canvas) return
const deviceCoords = convertCanvasToDeviceCoords(canvasX, canvasY, canvas, device)
if (!deviceCoords) {
console.warn('无法转换坐标,可能长按在图像区域外')
return
}
console.log(`长按操作: Canvas(${canvasX.toFixed(1)}, ${canvasY.toFixed(1)}) -> Device(${deviceCoords.x.toFixed(1)}, ${deviceCoords.y.toFixed(1)})`)
webSocket.emit('control_message', {
type: 'LONG_PRESS',
deviceId,
data: { x: deviceCoords.x, y: deviceCoords.y },
timestamp: Date.now()
})
// 显示长按指示器(使用不同颜色区分)
if (screenDisplay.showTouchIndicator) {
showLongPressIndicator(canvasX, canvasY)
}
}, [webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, operationEnabled])
const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault()
setIsDragging(true)
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const startX = event.clientX - rect.left
const startY = event.clientY - rect.top
setDragStart({ x: startX, y: startY })
// 🆕 启动长按计时器
setIsLongPressTriggered(false)
setIsLongPressDragging(false)
setLongPressDragStartPos(null)
setDragPath([]) // 🔧 清理拖拽路径
longPressTimerRef.current = window.setTimeout(() => {
setIsLongPressTriggered(true)
// 🆕 长按触发后不立即执行操作,而是准备进入拖拽模式
console.log('长按触发,准备进入拖拽模式')
}, 500) // 500ms 后触发长按
}, [performLongPress])
const handleMouseMove = useCallback((event: React.MouseEvent) => {
if (!isDragging || !dragStart) return
event.preventDefault()
// 🆕 处理长按拖拽逻辑 - 优化为连续手势
if (isLongPressTriggered && !isLongPressDragging) {
// 长按已触发,用户开始拖拽,进入长按拖拽模式
if (!webSocket || !device || !operationEnabled) return
const canvas = canvasRef.current
if (!canvas) return
// 转换开始位置为设备坐标
const startCoords = convertCanvasToDeviceCoords(dragStart.x, dragStart.y, canvas, device)
if (!startCoords) return
setIsLongPressDragging(true)
setLongPressDragStartPos(startCoords)
// 🔧 优化:初始化拖拽路径,包含起始点
setDragPath([startCoords])
// 🆕 显示长按拖拽开始指示器
if (screenDisplay.showTouchIndicator) {
showLongPressDragStartIndicator(dragStart.x, dragStart.y)
}
console.log(`长按拖拽开始: Device(${startCoords.x.toFixed(1)}, ${startCoords.y.toFixed(1)})`)
} else if (isLongPressDragging) {
// 🔧 优化:收集拖拽路径点,而不是立即发送消息
const now = Date.now()
// 频率控制每50ms最多记录一个点避免路径过密
if (now - lastMoveTimeRef.current < 50) return
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const currentX = event.clientX - rect.left
const currentY = event.clientY - rect.top
const currentCoords = convertCanvasToDeviceCoords(currentX, currentY, canvas, device)
if (!currentCoords) return
// 添加当前点到路径中
setDragPath(prevPath => {
const newPath = [...prevPath, currentCoords]
// 限制路径点数量,避免内存占用过大
return newPath.length > 100 ? [newPath[0], ...newPath.slice(-99)] : newPath
})
lastMoveTimeRef.current = now
console.debug(`长按拖拽路径收集: Device(${currentCoords.x.toFixed(1)}, ${currentCoords.y.toFixed(1)})`)
}
}, [isDragging, dragStart, isLongPressTriggered, isLongPressDragging, webSocket, device, deviceId, operationEnabled, convertCanvasToDeviceCoords, screenDisplay.showTouchIndicator, dragPath])
const handleMouseUp = useCallback((event: React.MouseEvent) => {
if (!isDragging || !dragStart) return
event.preventDefault()
setIsDragging(false)
// 🆕 清理长按计时器
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
// 🆕 处理长按相关操作
if (isLongPressTriggered) {
if (isLongPressDragging) {
// 🔧 优化:执行完整的长按拖拽手势
if (webSocket && device && operationEnabled && longPressDragStartPos && dragPath.length > 0) {
const canvas = canvasRef.current
if (canvas) {
const rect = canvas.getBoundingClientRect()
const endX = event.clientX - rect.left
const endY = event.clientY - rect.top
const endCoords = convertCanvasToDeviceCoords(endX, endY, canvas, device)
if (endCoords) {
// 将结束点添加到路径中
const completePath = [...dragPath, endCoords]
// 发送包含完整路径的长按拖拽消息
webSocket.emit('control_message', {
type: 'LONG_PRESS_DRAG',
deviceId,
data: {
path: completePath,
startX: completePath[0].x,
startY: completePath[0].y,
endX: endCoords.x,
endY: endCoords.y,
duration: Math.max(2000, 1500 + completePath.length * 20) // 优化:为微移动长按预留更多时间
},
timestamp: Date.now()
})
console.log(`长按拖拽完成: Device(${completePath[0].x.toFixed(1)}, ${completePath[0].y.toFixed(1)}) -> Device(${endCoords.x.toFixed(1)}, ${endCoords.y.toFixed(1)}), 路径点数: ${completePath.length}`)
// 🆕 显示长按拖拽路径指示器
if (screenDisplay.showTouchIndicator) {
showLongPressDragPath(dragStart.x, dragStart.y, endX, endY)
}
}
}
}
} else {
// 长按已触发但没有拖拽,执行普通长按操作
const canvas = canvasRef.current
const rect = canvas?.getBoundingClientRect()
if (rect) {
const endX = event.clientX - rect.left
const endY = event.clientY - rect.top
performLongPress(endX, endY)
}
}
// 清理长按拖拽相关状态
setDragStart(null)
setIsLongPressTriggered(false)
setIsLongPressDragging(false)
setLongPressDragStartPos(null)
setDragPath([]) // 🔧 清理拖拽路径
return
}
const canvas = canvasRef.current
if (!canvas || !webSocket || !device) return
const rect = canvas.getBoundingClientRect()
const endX = event.clientX - rect.left
const endY = event.clientY - rect.top
// 计算移动距离
const deltaX = Math.abs(endX - dragStart.x)
const deltaY = Math.abs(endY - dragStart.y)
const threshold = 10 // 增加阈值到10像素避免误触
if (deltaX < threshold && deltaY < threshold) {
// 小距离移动,当作点击处理
performClick(endX, endY)
} else {
// 大距离移动,当作滑动处理
// 检查操作是否被允许
if (!operationEnabled) {
console.warn('屏幕滑动操作已被阻止')
return
}
// 使用正确的坐标转换函数
const startCoords = convertCanvasToDeviceCoords(dragStart.x, dragStart.y, canvas, device)
const endCoords = convertCanvasToDeviceCoords(endX, endY, canvas, device)
if (!startCoords || !endCoords) {
console.warn('滑动坐标转换失败,滑动可能在图像区域外')
return
}
webSocket.emit('control_message', {
type: 'SWIPE',
deviceId,
data: {
startX: startCoords.x,
startY: startCoords.y,
endX: endCoords.x,
endY: endCoords.y,
duration: 300
},
timestamp: Date.now()
})
// 显示滑动指示器
if (screenDisplay.showTouchIndicator) {
showSwipeIndicator(dragStart.x, dragStart.y, endX, endY)
}
}
setDragStart(null)
}, [isDragging, dragStart, webSocket, device, deviceId, screenDisplay.showTouchIndicator, convertCanvasToDeviceCoords, performClick, operationEnabled, isLongPressTriggered, isLongPressDragging, longPressDragStartPos, performLongPress, dragPath])
// 处理鼠标离开画布
const handleMouseLeave = useCallback(() => {
setIsDragging(false)
setDragStart(null)
// 🆕 清理长按计时器和状态
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
setIsLongPressTriggered(false)
// 🆕 清理长按拖拽相关状态
setIsLongPressDragging(false)
setLongPressDragStartPos(null)
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) {
return (
<Card title="设备屏幕">
<div style={{ textAlign: 'center', padding: '50px' }}>
</div>
</Card>
)
}
return (
<>
{/* 添加CSS动画 */}
<style>
{`
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
`}
</style>
<div
ref={fullscreenContainerRef}
style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: isFullscreen ? '#000' : undefined,
}}
>
{/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */}
{/* 操作状态指示器 */}
{!operationEnabled && (
<div style={{
position: 'absolute',
top: '20px',
right: '20px',
zIndex: 20,
color: '#fff',
backgroundColor: 'rgba(255, 77, 79, 0.9)',
padding: '8px 16px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 'bold',
border: '2px solid #ff4d4f',
animation: 'pulse 2s infinite'
}}>
🔒
</div>
)}
{isLoading && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10
}}>
<Spin size="large" />
<div style={{ color: '#fff', marginTop: '16px' }}>
...
</div>
</div>
)}
{/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */}
<div style={{ position: 'relative' }}>
<canvas
ref={canvasRef}
style={{
...getCanvasStyle(),
cursor: 'pointer',
display: 'block',
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
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>
</>
)
}
export default DeviceScreen