Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3aae981e |
340
src/App.css
340
src/App.css
@@ -1,45 +1,335 @@
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 主布局自适应 */
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
/* Header 响应式 */
|
||||
.app-header {
|
||||
padding: 0 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
padding: 0 12px;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
/* 侧边栏 */
|
||||
.app-sider {
|
||||
background: #fff !important;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.app-sider .ant-menu {
|
||||
border-right: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.app-content {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 设备列表页 */
|
||||
.device-list-page {
|
||||
padding: 20px;
|
||||
background: #f0f2f5;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.device-list-page {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
.device-list-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
/* 筛选栏响应式 */
|
||||
.device-filter-bar {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.device-filter-bar .ant-form-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.device-filter-bar .ant-form-inline .ant-form-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.device-empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 独立控制页面 - 全屏自适应 */
|
||||
.standalone-control-page {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #f0f2f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 控制页左侧屏幕区域 */
|
||||
.control-screen-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.control-toolbar {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.control-toolbar .ant-btn {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.control-toolbar {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.control-toolbar .ant-btn {
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 屏幕+阅读器水平布局 */
|
||||
.screen-reader-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen-reader-panel {
|
||||
width: 50%;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-screen-panel {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 文本输入区域 */
|
||||
.text-input-bar {
|
||||
height: 44px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text-input-bar input {
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.text-input-bar input:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24,144,255,0.1);
|
||||
}
|
||||
|
||||
.text-input-bar button {
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.text-input-bar button:hover:not(:disabled) {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.text-input-bar button:disabled {
|
||||
background: #f5f5f5;
|
||||
color: #bfbfbf;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 系统按键区域 */
|
||||
.system-keys-bar {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.system-keys-bar .ant-btn {
|
||||
min-width: 72px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 右侧控制面板 */
|
||||
.control-panel-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-panel-header {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.control-panel-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 底部状态栏 */
|
||||
.screen-reader-status-bar {
|
||||
padding: 3px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 移动端遮罩 */
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0,0,0,0.45);
|
||||
z-index: 999;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
/* 移动端侧边栏 */
|
||||
@media (max-width: 768px) {
|
||||
.ant-layout-sider {
|
||||
position: fixed !important;
|
||||
height: 100vh !important;
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.ant-layout-header {
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格自适应 */
|
||||
.ant-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
25
src/App.tsx
25
src/App.tsx
@@ -17,8 +17,29 @@ function App() {
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1890ff',
|
||||
borderRadius: 6,
|
||||
colorPrimary: '#667eea',
|
||||
borderRadius: 8,
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgLayout: '#f0f2f5',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: '#fafafa',
|
||||
headerColor: '#595959',
|
||||
rowHoverBg: '#f0f5ff',
|
||||
borderRadius: 8,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
Button: {
|
||||
borderRadius: 6,
|
||||
},
|
||||
Menu: {
|
||||
itemBorderRadius: 8,
|
||||
itemMarginInline: 8,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1257,6 +1257,23 @@ const ControlPanel: React.FC<ControlPanelProps> = ({ deviceId }) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 🆕 手动授权投屏权限(不自动点击确认)
|
||||
const handleRefreshPermissionManual = () => {
|
||||
if (!webSocket) {
|
||||
message.error('WebSocket未连接')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📺 手动授权投屏权限(不自动点击)')
|
||||
|
||||
webSocket.emit('client_event', {
|
||||
type: 'REFRESH_MEDIA_PROJECTION_MANUAL',
|
||||
data: { deviceId }
|
||||
})
|
||||
|
||||
message.info('已发送手动授权请求,请在设备上手动确认权限弹窗')
|
||||
}
|
||||
|
||||
// 🆕 暂停屏幕捕获 - 已隐藏(功能已移至RemoteControlApp)
|
||||
// const handlePauseScreenCapture = () => {
|
||||
// if (!webSocket) {
|
||||
@@ -2849,7 +2866,7 @@ ${savedConfirmCoords ?
|
||||
|
||||
{/* 🆕 重新获取投屏权限 */}
|
||||
<Row gutter={[8, 8]} style={{ marginTop: '8px' }}>
|
||||
<Col span={24}>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
@@ -2865,6 +2882,22 @@ ${savedConfirmCoords ?
|
||||
重新获取投屏权限
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
|
||||
onClick={handleRefreshPermissionManual}
|
||||
disabled={!operationEnabled}
|
||||
style={{
|
||||
background: operationEnabled ? 'linear-gradient(135deg, #597ef7 0%, #1d39c4 100%)' : undefined,
|
||||
borderColor: operationEnabled ? '#597ef7' : undefined,
|
||||
color: operationEnabled ? 'white' : undefined
|
||||
}}
|
||||
>
|
||||
手动授权投屏
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 🆕 屏幕捕获控制 - 已隐藏(功能已移至RemoteControlApp) */}
|
||||
|
||||
@@ -96,14 +96,7 @@ const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, st
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
...style
|
||||
}}>
|
||||
<div className="device-filter-bar" style={style}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -112,7 +105,7 @@ const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, st
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,14 @@ import { Card, Spin } from 'antd'
|
||||
import { useSelector } from 'react-redux'
|
||||
import type { RootState } from '../../store/store'
|
||||
|
||||
/** 画质档位(参考billd-desk的参数化控制) */
|
||||
const QUALITY_PROFILES = [
|
||||
{ key: 'low', label: '低画质', fps: 5, quality: 30, resolution: '360P' },
|
||||
{ key: 'medium', label: '中画质', fps: 10, quality: 45, resolution: '480P' },
|
||||
{ key: 'high', label: '高画质', fps: 15, quality: 60, resolution: '720P' },
|
||||
{ key: 'ultra', label: '超高画质', fps: 20, quality: 75, resolution: '1080P' },
|
||||
]
|
||||
|
||||
interface DeviceScreenProps {
|
||||
deviceId: string
|
||||
onScreenSizeChange?: (size: { width: number, height: number }) => void
|
||||
@@ -14,17 +22,26 @@ interface DeviceScreenProps {
|
||||
const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChange }) => {
|
||||
// const dispatch = useDispatch<AppDispatch>()
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const fullscreenContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// ✅ FPS 计算:使用滑动窗口统计真实帧率
|
||||
const fpsFrameTimesRef = useRef<number[]>([])
|
||||
const [displayFps, setDisplayFps] = useState(0)
|
||||
const lastFpsUpdateRef = useRef(0)
|
||||
|
||||
// ✅ 帧渲染控制:只保留最新帧,用 rAF 驱动渲染
|
||||
const latestFrameRef = useRef<any>(null)
|
||||
const rafIdRef = useRef<number>(0)
|
||||
const isRenderingRef = useRef(false)
|
||||
const imageSizeRef = useRef<{ width: number, height: number } | null>(null)
|
||||
|
||||
// ✅ 尺寸稳定性:防止偶发异常帧导致canvas闪烁
|
||||
const pendingSizeRef = useRef<{ width: number, height: number } | null>(null)
|
||||
const pendingSizeCountRef = useRef(0)
|
||||
const SIZE_STABLE_THRESHOLD = 3 // 连续3帧相同尺寸才更新
|
||||
|
||||
// ✅ 添加控制权状态跟踪,避免重复申请
|
||||
const [isControlRequested, setIsControlRequested] = useState(false)
|
||||
@@ -37,15 +54,105 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
|
||||
const device = connectedDevices.find(d => d.id === deviceId)
|
||||
|
||||
// 📊 画质控制状态
|
||||
const [currentProfile, setCurrentProfile] = useState('medium')
|
||||
const [showQualityPanel, setShowQualityPanel] = useState(false)
|
||||
const [networkStats, setNetworkStats] = useState({ fps: 0, dropRate: 0, avgFrameSize: 0 })
|
||||
const frameCountRef = useRef(0)
|
||||
const droppedFrameCountRef = useRef(0)
|
||||
const feedbackTimerRef = useRef<number>(0)
|
||||
const lastFeedbackTimeRef = useRef(0)
|
||||
|
||||
// ✅ 安全地通知父组件屏幕尺寸变化(在 useEffect 中而非渲染期间)
|
||||
useEffect(() => {
|
||||
if (imageSize && onScreenSizeChange) {
|
||||
onScreenSizeChange(imageSize)
|
||||
}
|
||||
}, [imageSize, onScreenSizeChange])
|
||||
|
||||
// 📊 画质反馈:定期向服务端报告网络质量
|
||||
useEffect(() => {
|
||||
if (!webSocket || !deviceId) return
|
||||
|
||||
const sendFeedback = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastFeedbackTimeRef.current < 3000) return // 3秒发一次
|
||||
lastFeedbackTimeRef.current = now
|
||||
|
||||
const totalFrames = frameCountRef.current
|
||||
const droppedFrames = droppedFrameCountRef.current
|
||||
const dropRate = totalFrames > 0 ? droppedFrames / totalFrames : 0
|
||||
const fps = displayFps
|
||||
|
||||
webSocket.emit('quality_feedback', {
|
||||
deviceId,
|
||||
fps,
|
||||
dropRate,
|
||||
})
|
||||
|
||||
setNetworkStats({ fps, dropRate, avgFrameSize: 0 })
|
||||
|
||||
// 重置计数
|
||||
frameCountRef.current = 0
|
||||
droppedFrameCountRef.current = 0
|
||||
}
|
||||
|
||||
feedbackTimerRef.current = window.setInterval(sendFeedback, 3000)
|
||||
|
||||
// 监听服务端画质变更通知
|
||||
const handleQualityChanged = (data: any) => {
|
||||
if (data.deviceId === deviceId) {
|
||||
if (data.profile) setCurrentProfile(data.profile)
|
||||
}
|
||||
}
|
||||
webSocket.on('quality_changed', handleQualityChanged)
|
||||
|
||||
return () => {
|
||||
if (feedbackTimerRef.current) clearInterval(feedbackTimerRef.current)
|
||||
webSocket.off('quality_changed', handleQualityChanged)
|
||||
}
|
||||
}, [webSocket, deviceId, displayFps])
|
||||
|
||||
// 📊 切换画质档位
|
||||
const handleSetProfile = useCallback((profileKey: string) => {
|
||||
if (!webSocket) return
|
||||
webSocket.emit('set_quality_profile', { deviceId, profile: profileKey })
|
||||
setCurrentProfile(profileKey)
|
||||
}, [webSocket, deviceId])
|
||||
|
||||
// ✅ 监听屏幕数据的独立useEffect,避免与控制权逻辑混合
|
||||
useEffect(() => {
|
||||
if (!webSocket) return
|
||||
|
||||
const handleScreenData = (data: any) => {
|
||||
if (data.deviceId === deviceId) {
|
||||
// 📊 帧计数用于质量反馈
|
||||
frameCountRef.current++
|
||||
|
||||
// ✅ 过滤黑屏帧:Base64长度<4000字符(≈3KB JPEG)几乎肯定是黑屏/空白帧
|
||||
// 正常480×854 JPEG即使最低质量也>8000字符
|
||||
const dataLen = typeof data.data === 'string' ? data.data.length : 0
|
||||
const MIN_VALID_FRAME_LENGTH = 4000
|
||||
|
||||
if (dataLen > 0 && dataLen < MIN_VALID_FRAME_LENGTH) {
|
||||
// 黑屏帧:丢弃,保持canvas上一帧内容不变
|
||||
droppedFrameCountRef.current++
|
||||
if (frameCountRef.current % 30 === 0) {
|
||||
console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 丢弃黑屏帧(${dataLen}字符 < ${MIN_VALID_FRAME_LENGTH}), 已丢弃: ${droppedFrameCountRef.current}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 🔍 诊断:记录数据到达频率
|
||||
if (frameCountRef.current % 30 === 0) {
|
||||
console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 数据大小: ${dataLen}, 渲染中: ${isRenderingRef.current}, 解码中: ${decodingRef.current}`)
|
||||
}
|
||||
|
||||
// ✅ 只保存最新帧引用,不立即解码
|
||||
latestFrameRef.current = data
|
||||
setIsLoading(false)
|
||||
|
||||
// 只在首帧时更新loading状态,避免每帧触发重渲染
|
||||
if (isLoading) setIsLoading(false)
|
||||
|
||||
// ✅ 如果没有正在进行的渲染循环,启动一个
|
||||
if (!isRenderingRef.current) {
|
||||
@@ -136,16 +243,19 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
}
|
||||
}, [webSocket, deviceId, isControlRequested, currentWebSocket]) // 🔧 不包含lastControlRequestTime避免重复执行
|
||||
|
||||
// ✅ rAF 驱动的渲染函数:取最新帧解码并绘制,一次只解码一帧
|
||||
// ✅ 高性能渲染:createImageBitmap 离屏解码 + 持续 rAF 循环
|
||||
const decodingRef = useRef(false)
|
||||
|
||||
const renderLatestFrame = useCallback(() => {
|
||||
const doRender = () => {
|
||||
const frameData = latestFrameRef.current
|
||||
if (!frameData) {
|
||||
// 没有新帧,停止循环,等 handleScreenData 重新启动
|
||||
isRenderingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
// 取走帧数据,清空引用
|
||||
// 取走帧数据
|
||||
latestFrameRef.current = null
|
||||
|
||||
const canvas = canvasRef.current
|
||||
@@ -155,120 +265,135 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
}
|
||||
|
||||
if (!frameData?.data || !frameData?.format) {
|
||||
// 无效帧,检查是否有新帧等待
|
||||
if (latestFrameRef.current) {
|
||||
rafIdRef.current = requestAnimationFrame(doRender)
|
||||
} else {
|
||||
isRenderingRef.current = false
|
||||
}
|
||||
rafIdRef.current = requestAnimationFrame(doRender)
|
||||
return
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
// 上一帧还在解码,把数据放回去,下个 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
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
if (latestFrameRef.current) rafIdRef.current = requestAnimationFrame(doRender)
|
||||
else isRenderingRef.current = false
|
||||
bitmap.close()
|
||||
rafIdRef.current = requestAnimationFrame(doRender)
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ 只在尺寸变化时才重设 canvas 尺寸(避免清空画布导致黑屏)
|
||||
if (canvas.width !== img.width || canvas.height !== img.height) {
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
// 只在canvas内部分辨率需要增大时才更新,避免偶发小帧清空画布导致闪烁
|
||||
if (canvas.width < bitmap.width || canvas.height < bitmap.height) {
|
||||
canvas.width = Math.max(canvas.width, bitmap.width)
|
||||
canvas.height = Math.max(canvas.height, bitmap.height)
|
||||
}
|
||||
// 每帧绘制前清除(canvas可能比bitmap大)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 直接绘制,覆盖上一帧
|
||||
switch (screenDisplay.fitMode) {
|
||||
case 'fit':
|
||||
drawFitMode(ctx, img, canvas)
|
||||
drawFitMode(ctx, bitmap, canvas)
|
||||
break
|
||||
case 'fill':
|
||||
drawFillMode(ctx, img, canvas)
|
||||
drawFillMode(ctx, bitmap, canvas)
|
||||
break
|
||||
case 'stretch':
|
||||
drawStretchMode(ctx, img, canvas)
|
||||
drawStretchMode(ctx, bitmap, canvas)
|
||||
break
|
||||
case 'original':
|
||||
drawOriginalMode(ctx, img, canvas)
|
||||
drawOriginalMode(ctx, bitmap, canvas)
|
||||
break
|
||||
}
|
||||
|
||||
// 更新图片尺寸
|
||||
setImageSize(prev => {
|
||||
if (prev && prev.width === img.width && prev.height === img.height) return prev
|
||||
if (onScreenSizeChange) onScreenSizeChange({ width: img.width, height: img.height })
|
||||
return { width: img.width, height: img.height }
|
||||
})
|
||||
const bw = bitmap.width
|
||||
const bh = bitmap.height
|
||||
const prevSize = imageSizeRef.current
|
||||
if (!prevSize || prevSize.width !== bw || prevSize.height !== bh) {
|
||||
// 尺寸稳定性检查:只有连续多帧相同尺寸才更新,防止偶发异常帧闪烁
|
||||
const pending = pendingSizeRef.current
|
||||
if (pending && pending.width === bw && pending.height === bh) {
|
||||
pendingSizeCountRef.current++
|
||||
} else {
|
||||
pendingSizeRef.current = { width: bw, height: bh }
|
||||
pendingSizeCountRef.current = 1
|
||||
}
|
||||
|
||||
if (pendingSizeCountRef.current >= SIZE_STABLE_THRESHOLD || !prevSize) {
|
||||
imageSizeRef.current = { width: bw, height: bh }
|
||||
setImageSize({ width: bw, height: bh })
|
||||
pendingSizeRef.current = null
|
||||
pendingSizeCountRef.current = 0
|
||||
}
|
||||
} else {
|
||||
// 尺寸未变,重置pending
|
||||
pendingSizeRef.current = null
|
||||
pendingSizeCountRef.current = 0
|
||||
}
|
||||
|
||||
// ✅ 更新FPS统计
|
||||
const now = Date.now()
|
||||
fpsFrameTimesRef.current.push(now)
|
||||
const cutoff = now - 2000
|
||||
fpsFrameTimesRef.current = fpsFrameTimesRef.current.filter(t => t > cutoff)
|
||||
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
|
||||
if (now - lastFpsUpdateRef.current > 500) {
|
||||
lastFpsUpdateRef.current = now
|
||||
setDisplayFps(Math.round(fpsFrameTimesRef.current.length / 2))
|
||||
}
|
||||
|
||||
} catch (drawError) {
|
||||
console.error('绘制图像失败:', drawError)
|
||||
}
|
||||
bitmap.close()
|
||||
|
||||
// 这帧画完了,检查是否有新帧等待
|
||||
if (latestFrameRef.current) {
|
||||
// 解码完成后始终调度下一帧,保持循环活跃
|
||||
rafIdRef.current = requestAnimationFrame(doRender)
|
||||
} else {
|
||||
isRenderingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('图像加载失败')
|
||||
if (latestFrameRef.current) {
|
||||
})
|
||||
.catch(err => {
|
||||
decodingRef.current = false
|
||||
console.error('图像解码失败:', err)
|
||||
rafIdRef.current = requestAnimationFrame(doRender)
|
||||
} else {
|
||||
isRenderingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置图像数据源
|
||||
if (typeof frameData.data === 'string') {
|
||||
img.src = `data:image/${frameData.format.toLowerCase()};base64,${frameData.data}`
|
||||
} else {
|
||||
const blob = new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const origOnload = img.onload as (() => void)
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
origOnload()
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
doRender()
|
||||
}, [screenDisplay.fitMode])
|
||||
|
||||
const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||
const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
|
||||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height)
|
||||
const x = (canvas.width - img.width * scale) / 2
|
||||
const y = (canvas.height - img.height * scale) / 2
|
||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
|
||||
}
|
||||
|
||||
const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||
const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
|
||||
const scale = Math.max(canvas.width / img.width, canvas.height / img.height)
|
||||
const x = (canvas.width - img.width * scale) / 2
|
||||
const y = (canvas.height - img.height * scale) / 2
|
||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
|
||||
}
|
||||
|
||||
const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||
const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||
const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement | ImageBitmap, canvas: HTMLCanvasElement) => {
|
||||
const x = (canvas.width - img.width) / 2
|
||||
const y = (canvas.height - img.height) / 2
|
||||
ctx.drawImage(img, x, y)
|
||||
@@ -890,6 +1015,65 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
setDragPath([]) // 🔧 清理拖拽路径
|
||||
}, [])
|
||||
|
||||
// 🔍 全屏切换
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const container = fullscreenContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
container.requestFullscreen().catch(err => {
|
||||
console.warn('进入全屏失败:', err)
|
||||
})
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 监听 fullscreenchange 事件同步状态
|
||||
useEffect(() => {
|
||||
const onFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement)
|
||||
}
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 全屏模式下计算 canvas 的 CSS 尺寸(保持宽高比,适配屏幕)
|
||||
// 使用 state 存储全屏容器尺寸,确保全屏切换和窗口resize时触发重渲染
|
||||
const [containerSize, setContainerSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
const updateSize = () => {
|
||||
setContainerSize({ w: window.innerWidth, h: window.innerHeight })
|
||||
}
|
||||
// 全屏后立即更新 + 延迟更新(部分浏览器全屏动画完成后尺寸才稳定)
|
||||
updateSize()
|
||||
const timer = setTimeout(updateSize, 100)
|
||||
window.addEventListener('resize', updateSize)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
window.removeEventListener('resize', updateSize)
|
||||
}
|
||||
}, [isFullscreen])
|
||||
|
||||
const getCanvasStyle = useCallback((): React.CSSProperties => {
|
||||
if (!isFullscreen || !imageSize || containerSize.w === 0) {
|
||||
return {
|
||||
width: imageSize ? `${imageSize.width}px` : '100%',
|
||||
height: imageSize ? `${imageSize.height}px` : 'auto',
|
||||
}
|
||||
}
|
||||
// 全屏时:canvas 按宽高比缩放填满屏幕
|
||||
const scale = Math.min(containerSize.w / imageSize.width, containerSize.h / imageSize.height)
|
||||
return {
|
||||
width: `${Math.round(imageSize.width * scale)}px`,
|
||||
height: `${Math.round(imageSize.height * scale)}px`,
|
||||
}
|
||||
}, [isFullscreen, imageSize, containerSize])
|
||||
|
||||
|
||||
|
||||
if (!device) {
|
||||
@@ -916,6 +1100,7 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
</style>
|
||||
|
||||
<div
|
||||
ref={fullscreenContainerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@@ -923,29 +1108,12 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
overflow: 'hidden'
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: isFullscreen ? '#000' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* 顶部信息栏 - 屏幕阅读器模式下隐藏 */}
|
||||
{!device?.screenReader?.enabled && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-10px',
|
||||
left: '20px',
|
||||
zIndex: 20,
|
||||
color: '#fff',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
|
||||
<div style={{ fontSize: '12px', opacity: 0.8 }}>
|
||||
FPS: {displayFps}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作状态指示器 */}
|
||||
{!operationEnabled && (
|
||||
@@ -984,16 +1152,14 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas wrapper - position:relative so touch/swipe indicators are positioned relative to canvas */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={imageSize?.width || device?.screenWidth || 360}
|
||||
height={imageSize?.height || device?.screenHeight || 640}
|
||||
style={{
|
||||
width: imageSize ? `${imageSize.width}px` : '100%',
|
||||
height: imageSize ? `${imageSize.height}px` : 'auto',
|
||||
objectFit: 'none',
|
||||
...getCanvasStyle(),
|
||||
cursor: 'pointer',
|
||||
display: 'block'
|
||||
display: 'block',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
@@ -1001,6 +1167,91 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 📊 画质控制面板 + 🔍 全屏按钮 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
right: '8px',
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<span style={{
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{displayFps} FPS{networkStats.dropRate > 0.05 ? ` ⚠${(networkStats.dropRate * 100).toFixed(0)}%丢帧` : ''}
|
||||
</span>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowQualityPanel(!showQualityPanel)}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{QUALITY_PROFILES.find(p => p.key === currentProfile)?.label || '自定义'}
|
||||
</button>
|
||||
{showQualityPanel && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
right: 0,
|
||||
marginBottom: '4px',
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
minWidth: '120px',
|
||||
}}>
|
||||
{QUALITY_PROFILES.map(p => (
|
||||
<div
|
||||
key={p.key}
|
||||
onClick={() => { handleSetProfile(p.key); setShowQualityPanel(false) }}
|
||||
style={{
|
||||
color: currentProfile === p.key ? '#1890ff' : '#fff',
|
||||
fontSize: '12px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '3px',
|
||||
background: currentProfile === p.key ? 'rgba(24,144,255,0.15)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{p.label} ({p.fps}fps / {p.resolution})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 🔍 全屏/退出全屏按钮 */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? '退出全屏 (ESC)' : '全屏显示'}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{isFullscreen ? '⮌' : '⛶'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
102
src/index.css
102
src/index.css
@@ -2,77 +2,77 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
color: #1f1f1f;
|
||||
background-color: #f0f2f5;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
a {
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
a:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
|
||||
/* Ant Design 表格行悬停效果增强 */
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: #e6f7ff !important;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user