2026-02-09 16:33:52 +08:00
|
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
|
|
|
|
|
import { Card, Spin } from 'antd'
|
|
|
|
|
|
import { useSelector } from 'react-redux'
|
|
|
|
|
|
import type { RootState } from '../../store/store'
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
/** 画质档位(参考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' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
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)
|
2026-02-11 22:15:16 +08:00
|
|
|
|
const fullscreenContainerRef = useRef<HTMLDivElement>(null)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
|
|
|
|
const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null)
|
2026-02-11 22:15:16 +08:00
|
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
|
|
|
|
|
|
// ✅ FPS 计算:使用滑动窗口统计真实帧率
|
|
|
|
|
|
const fpsFrameTimesRef = useRef<number[]>([])
|
|
|
|
|
|
const [displayFps, setDisplayFps] = useState(0)
|
2026-02-11 22:15:16 +08:00
|
|
|
|
const lastFpsUpdateRef = useRef(0)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
|
|
|
|
|
|
// ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染
|
|
|
|
|
|
const latestFrameRef = useRef<any>(null)
|
|
|
|
|
|
const rafIdRef = useRef<number>(0)
|
|
|
|
|
|
const isRenderingRef = useRef(false)
|
2026-02-11 22:15:16 +08:00
|
|
|
|
const imageSizeRef = useRef<{ width: number, height: number } | null>(null)
|
|
|
|
|
|
|
2026-02-15 18:41:47 +08:00
|
|
|
|
// canvas锁定尺寸:首次有效帧确定canvas尺寸后不再变化,
|
|
|
|
|
|
// 避免不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致闪烁
|
|
|
|
|
|
const lockedCanvasSizeRef = useRef<{ width: number, height: number } | null>(null)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
|
|
|
|
|
|
// ✅ 添加控制权状态跟踪,避免重复申请
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
// 📊 画质控制状态
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
// ✅ 监听屏幕数据的独立useEffect,避免与控制权逻辑混合
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!webSocket) return
|
|
|
|
|
|
|
|
|
|
|
|
const handleScreenData = (data: any) => {
|
|
|
|
|
|
if (data.deviceId === deviceId) {
|
2026-02-11 22:15:16 +08:00
|
|
|
|
// 📊 帧计数用于质量反馈
|
|
|
|
|
|
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}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
// ✅ 只保存最新帧引用,不立即解码
|
|
|
|
|
|
latestFrameRef.current = data
|
2026-02-11 22:15:16 +08:00
|
|
|
|
|
|
|
|
|
|
// 只在首帧时更新loading状态,避免每帧触发重渲染
|
|
|
|
|
|
if (isLoading) setIsLoading(false)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
|
|
|
|
|
|
// ✅ 如果没有正在进行的渲染循环,启动一个
|
|
|
|
|
|
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
|
2026-02-15 18:41:47 +08:00
|
|
|
|
// 设备切换或断开时重置锁定尺寸,下次连接重新锁定
|
|
|
|
|
|
lockedCanvasSizeRef.current = null
|
2026-02-09 16:33:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, [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避免重复执行
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
// ✅ 高性能渲染:createImageBitmap 离屏解码 + 持续 rAF 循环
|
|
|
|
|
|
const decodingRef = useRef(false)
|
|
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
const renderLatestFrame = useCallback(() => {
|
|
|
|
|
|
const doRender = () => {
|
|
|
|
|
|
const frameData = latestFrameRef.current
|
|
|
|
|
|
if (!frameData) {
|
2026-02-11 22:15:16 +08:00
|
|
|
|
// 没有新帧,停止循环,等 handleScreenData 重新启动
|
2026-02-09 16:33:52 +08:00
|
|
|
|
isRenderingRef.current = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-11 22:15:16 +08:00
|
|
|
|
|
|
|
|
|
|
// 取走帧数据
|
2026-02-09 16:33:52 +08:00
|
|
|
|
latestFrameRef.current = null
|
2026-02-11 22:15:16 +08:00
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
const canvas = canvasRef.current
|
|
|
|
|
|
if (!canvas) {
|
|
|
|
|
|
isRenderingRef.current = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!frameData?.data || !frameData?.format) {
|
2026-02-11 22:15:16 +08:00
|
|
|
|
rafIdRef.current = requestAnimationFrame(doRender)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
// 上一帧还在解码,把数据放回去,下个 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
|
|
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
|
|
if (!ctx) {
|
2026-02-11 22:15:16 +08:00
|
|
|
|
bitmap.close()
|
|
|
|
|
|
rafIdRef.current = requestAnimationFrame(doRender)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-11 22:15:16 +08:00
|
|
|
|
|
2026-02-15 18:41:47 +08:00
|
|
|
|
// canvas尺寸锁定策略:
|
|
|
|
|
|
// 首次有效帧确定canvas尺寸后锁定,后续帧直接绘制到固定尺寸canvas上
|
|
|
|
|
|
// 避免不同采集模式帧尺寸不一致导致canvas反复resize闪烁
|
|
|
|
|
|
const locked = lockedCanvasSizeRef.current
|
|
|
|
|
|
if (!locked) {
|
|
|
|
|
|
// 首次帧:锁定canvas尺寸
|
|
|
|
|
|
lockedCanvasSizeRef.current = { width: bitmap.width, height: bitmap.height }
|
2026-02-15 14:57:51 +08:00
|
|
|
|
canvas.width = bitmap.width
|
|
|
|
|
|
canvas.height = bitmap.height
|
2026-02-15 18:41:47 +08:00
|
|
|
|
} else if (canvas.width !== locked.width || canvas.height !== locked.height) {
|
|
|
|
|
|
// canvas被外部重置了,恢复锁定尺寸
|
|
|
|
|
|
canvas.width = locked.width
|
|
|
|
|
|
canvas.height = locked.height
|
2026-02-09 16:33:52 +08:00
|
|
|
|
}
|
2026-02-11 22:15:16 +08:00
|
|
|
|
|
2026-02-15 18:41:47 +08:00
|
|
|
|
// 始终将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 })
|
2026-02-11 22:15:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
fpsFrameTimesRef.current.push(now)
|
|
|
|
|
|
const cutoff = now - 2000
|
|
|
|
|
|
fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff)
|
2026-02-11 22:15:16 +08:00
|
|
|
|
if (now - lastFpsUpdateRef.current > 500) {
|
|
|
|
|
|
lastFpsUpdateRef.current = now
|
|
|
|
|
|
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bitmap.close()
|
|
|
|
|
|
|
|
|
|
|
|
// 解码完成后始终调度下一帧,保持循环活跃
|
2026-02-09 16:33:52 +08:00
|
|
|
|
rafIdRef.current = requestAnimationFrame(doRender)
|
2026-02-11 22:15:16 +08:00
|
|
|
|
})
|
|
|
|
|
|
.catch(err => {
|
|
|
|
|
|
decodingRef.current = false
|
|
|
|
|
|
console.error('图像解码失败:', err)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
rafIdRef.current = requestAnimationFrame(doRender)
|
2026-02-11 22:15:16 +08:00
|
|
|
|
})
|
2026-02-09 16:33:52 +08:00
|
|
|
|
}
|
2026-02-11 22:15:16 +08:00
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
doRender()
|
2026-02-15 18:41:47 +08:00
|
|
|
|
}, [])
|
2026-02-09 16:33:52 +08:00
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
|
2026-02-09 16:33:52 +08:00
|
|
|
|
const scale = Math.min(canvas.width / img.width, canvas.height / img.height)
|
2026-02-15 14:57:51 +08:00
|
|
|
|
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)
|
2026-02-09 16:33:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
|
2026-02-09 16:33:52 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
|
2026-02-09 16:33:52 +08:00
|
|
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
|
2026-02-09 16:33:52 +08:00
|
|
|
|
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([]) // 🔧 清理拖拽路径
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
// 🔍 全屏切换
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
2026-02-09 16:33:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-02-11 22:15:16 +08:00
|
|
|
|
ref={fullscreenContainerRef}
|
2026-02-09 16:33:52 +08:00
|
|
|
|
style={{
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
alignItems: 'center',
|
2026-02-11 22:15:16 +08:00
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
backgroundColor: isFullscreen ? '#000' : undefined,
|
2026-02-09 16:33:52 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 操作状态指示器 */}
|
|
|
|
|
|
{!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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-02-11 22:15:16 +08:00
|
|
|
|
{/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */}
|
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
2026-02-09 16:33:52 +08:00
|
|
|
|
<canvas
|
|
|
|
|
|
ref={canvasRef}
|
|
|
|
|
|
style={{
|
2026-02-11 22:15:16 +08:00
|
|
|
|
...getCanvasStyle(),
|
2026-02-09 16:33:52 +08:00
|
|
|
|
cursor: 'pointer',
|
2026-02-11 22:15:16 +08:00
|
|
|
|
display: 'block',
|
2026-02-09 16:33:52 +08:00
|
|
|
|
}}
|
|
|
|
|
|
onMouseDown={handleMouseDown}
|
|
|
|
|
|
onMouseUp={handleMouseUp}
|
|
|
|
|
|
onMouseMove={handleMouseMove}
|
|
|
|
|
|
onMouseLeave={handleMouseLeave}
|
|
|
|
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
|
|
|
|
/>
|
2026-02-11 22:15:16 +08:00
|
|
|
|
</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>
|
2026-02-09 16:33:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default DeviceScreen
|