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 = ({ deviceId, onScreenSizeChange }) => { // const dispatch = useDispatch() const canvasRef = useRef(null) const fullscreenContainerRef = useRef(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([]) const [displayFps, setDisplayFps] = useState(0) const lastFpsUpdateRef = useRef(0) // ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染 const latestFrameRef = useRef(null) const rafIdRef = useRef(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(null) const [lastControlRequestTime, setLastControlRequestTime] = useState(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(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 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(null) // 🆕 添加长按拖拽处理状态 const [isLongPressDragging, setIsLongPressDragging] = useState(false) const [longPressDragStartPos, setLongPressDragStartPos] = useState<{x: number, y: number} | null>(null) // 🆕 添加拖拽路径收集状态 const [dragPath, setDragPath] = useState>([]) const lastMoveTimeRef = useRef(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 (
设备未找到
) } return ( <> {/* 添加CSS动画 */}
{/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */} {/* 操作状态指示器 */} {!operationEnabled && (
🔒 操作已禁用
)} {isLoading && (
正在连接设备屏幕...
)} {/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */}
e.preventDefault()} />
{/* 📊 画质控制面板 + 🔍 全屏按钮 */}
{displayFps} FPS{networkStats.dropRate > 0.05 ? ` ⚠${(networkStats.dropRate * 100).toFixed(0)}%丢帧` : ''}
{showQualityPanel && (
{QUALITY_PROFILES.map(p => (
{ 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})
))}
)}
{/* 🔍 全屏/退出全屏按钮 */}
) } export default DeviceScreen