Files
crossdesk-web-client/web_client.js
yexuejc f641547d32 feat(web-client): 添加连接按钮状态检查和Docker部署支持
- 实现checkConnectButtonEnabled函数检查远程设备ID和密码是否有效
- 在服务器配置可见时验证信令服务器和STUN/TURN服务器输入
- 添加WebSocket连接初始化功能,延迟页面加载时的自动连接
- 为服务器配置输入框和传输ID/密码添加输入事件监听器
- 在断开连接时正确关闭WebSocket连接
- 添加Dockerfile支持容器化部署
- 更新连接逻辑以在WebSocket打开后才执行连接流程
2026-03-04 21:20:51 +08:00

1513 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const elements = {
iceState: document.getElementById("ice-connection-state"),
signalingState: document.getElementById("signaling-state"),
dataChannelState: document.getElementById("datachannel-state"),
displaySelect: document.getElementById("display-id"),
connectBtn: document.getElementById("connect"),
disconnectBtn: document.getElementById("disconnect"),
media: document.getElementById("media"),
video: document.getElementById("video"),
audio: document.getElementById("audio"),
connectionOverlay: document.getElementById("connection-overlay"),
connectedOverlay: document.getElementById("connected-overlay"),
connectedPanel: document.getElementById("connected-panel"),
panelCollapsedBar: document.getElementById("panel-collapsed-bar"),
connectingOverlay: document.getElementById("connecting-overlay"),
connectingMessageText: document.getElementById("connecting-message-text"),
connectionStatusLed: document.getElementById("connection-status-led"),
connectionStatusIndicator: document.getElementById("connection-status-indicator"),
connectedStatusLed: document.getElementById("connected-status-led"),
disconnectConnected: document.getElementById("disconnect-connected"),
// Server configuration elements
serverConfigBtn: document.getElementById("server-config-btn"),
serverConfigContainer: document.getElementById("server-config-container"),
signalingServerInput: document.getElementById("signaling-server"),
stunTurnServerInput: document.getElementById("stun-turn-server"),
};
// Config section (can be overridden by setting window.CROSSDESK_CONFIG before this script runs)
const DEFAULT_CONFIG = {
signalingUrl: "wss://192.168.0.107:33333",
iceServers: [
{ urls: ["stun:192.168.0.107:33334"] },
{ urls: ["turn:192.168.0.107:33334"], username: "crossdesk", credential: "crossdeskpw" },
],
heartbeatIntervalMs: 3000,
heartbeatTimeoutMs: 10000,
reconnectDelayMs: 2000,
clientTag: "web",
};
const CONFIG = Object.assign({}, DEFAULT_CONFIG, window.CROSSDESK_CONFIG || {});
// Function to extract host:port from signaling URL
function extractSignalingHostPort() {
try {
const url = new URL(CONFIG.signalingUrl);
return url.host; // Returns "host:port"
} catch (e) {
return "192.168.0.107:33333"; // Default fallback
}
}
// Function to extract STUN/TURN server host:port
function extractStunTurnHostPort() {
if (CONFIG.iceServers && CONFIG.iceServers.length > 0) {
const firstServer = CONFIG.iceServers[0];
if (firstServer.urls) {
const urls = Array.isArray(firstServer.urls) ? firstServer.urls : [firstServer.urls];
if (urls.length > 0) {
// Extract host:port from URL like "stun:192.168.0.107:33334"
const urlStr = urls[0];
const match = urlStr.match(/stun:(.+):([0-9]+)/) || urlStr.match(/turn:(.+):([0-9]+)/);
if (match) {
return `${match[1]}:${match[2]}`;
}
}
}
}
return "192.168.0.107:33334"; // Default fallback
}
// Initialize server config inputs with current values
function initServerConfigInputs() {
if (elements.signalingServerInput) {
elements.signalingServerInput.value = extractSignalingHostPort();
}
if (elements.stunTurnServerInput) {
elements.stunTurnServerInput.value = extractStunTurnHostPort();
}
}
// Check if connect button should be enabled
function checkConnectButtonEnabled() {
if (!elements.connectBtn) return;
const transmissionId = getTransmissionId();
const transmissionPwd = getTransmissionPwd();
// Remote device ID and password must have values
if (!transmissionId || !transmissionPwd) {
elements.connectBtn.disabled = true;
return;
}
// If server config is visible, check if both server inputs have values
const isServerConfigVisible = elements.serverConfigContainer &&
elements.serverConfigContainer.style.display !== "none";
if (isServerConfigVisible) {
const signalingHostPort = elements.signalingServerInput?.value.trim();
const stunTurnHostPort = elements.stunTurnServerInput?.value.trim();
// When server config is visible, both inputs must have values
if (signalingHostPort && stunTurnHostPort) {
elements.connectBtn.disabled = false;
} else {
elements.connectBtn.disabled = true;
}
} else {
// When server config is hidden, use default config, just need remote ID and password
elements.connectBtn.disabled = false;
}
}
// Toggle server configuration visibility
function toggleServerConfig() {
if (!elements.serverConfigContainer) return;
const isHidden = elements.serverConfigContainer.style.display === "none";
if (isHidden) {
// Show server config
initServerConfigInputs();
elements.serverConfigContainer.style.display = "block";
} else {
// Hide server config
elements.serverConfigContainer.style.display = "none";
}
// Re-check connect button enabled state
checkConnectButtonEnabled();
}
// Function to update server configuration
function updateServerConfig(signalingHostPort, stunTurnHostPort) {
if (!signalingHostPort || !stunTurnHostPort) {
return false;
}
// Update signaling URL
CONFIG.signalingUrl = `wss://${signalingHostPort}`;
// Update ICE servers
CONFIG.iceServers = [
{ urls: [`stun:${stunTurnHostPort}`] },
{ urls: [`turn:${stunTurnHostPort}`], username: "crossdesk", credential: "crossdeskpw" },
];
// Update global config
window.CROSSDESK_CONFIG = CONFIG;
return true;
}
// Save server configuration
function saveServerConfig() {
if (!elements.signalingServerInput || !elements.stunTurnServerInput) return;
const signalingHostPort = elements.signalingServerInput.value.trim();
const stunTurnHostPort = elements.stunTurnServerInput.value.trim();
if (!signalingHostPort || !stunTurnHostPort) {
alert("请填写完整的服务器地址");
return;
}
const success = updateServerConfig(signalingHostPort, stunTurnHostPort);
if (success) {
// Hide server config container
if (elements.serverConfigContainer) {
elements.serverConfigContainer.style.display = "none";
}
// Reload the page to apply new configuration
setTimeout(() => {
window.location.reload();
}, 500);
}
}
const control = window.CrossDeskControl;
let pc = null;
let clientId = "000000";
let heartbeatTimer = null;
let lastPongAt = Date.now();
let trackIndex = 0; // Track index for display_id (0, 1, 2, ...)
const trackMap = new Map(); // Map<index, track> - stores tracks by their display_id index
// Don't auto-connect on page load - wait for user to click connect button
// const websocket = new WebSocket(CONFIG.signalingUrl);
let websocket = null;
// Initialize WebSocket connection
function initWebSocket() {
if (websocket) return; // Already initialized
websocket = new WebSocket(CONFIG.signalingUrl);
websocket.addEventListener("message", (event) => {
if (typeof event.data !== "string") return;
const message = JSON.parse(event.data);
if (message.type === "pong") {
lastPongAt = Date.now();
return;
}
handleSignalingMessage(message);
});
websocket.addEventListener("open", () => {
enableConnectButton(true);
sendLogin();
startHeartbeat();
});
websocket.addEventListener("close", () => {
stopHeartbeat();
enableConnectButton(false);
});
websocket.addEventListener("error", () => {
stopHeartbeat();
scheduleReconnect();
});
}
function handleSignalingMessage(message) {
switch (message.type) {
case "login":
clientId = message.user_id.split("@")[0];
break;
case "user_join_transmission":
// Handle join transmission response
if (message.status === "failed") {
let errorMessage = "";
if (message.reason === "No such transmission id") {
errorMessage = "没有该设备";
} else if (message.reason === "Incorrect password") {
errorMessage = "密码错误";
}
if (errorMessage && elements.connectingOverlay && elements.connectingMessageText) {
// Show error message
elements.connectingMessageText.textContent = errorMessage;
elements.connectingOverlay.style.display = "flex";
// Reset connection state after showing error for 3 seconds
setTimeout(() => {
// Hide connecting overlay first
if (elements.connectingOverlay) {
elements.connectingOverlay.style.display = "none";
}
// Then disconnect to reset UI
disconnect();
}, 3000);
}
}
break;
case "offer":
handleOffer(message);
break;
case "new_candidate_mid":
if (!pc) return;
pc.addIceCandidate(
new RTCIceCandidate({
sdpMid: message.mid,
candidate: message.candidate,
})
).catch((err) => console.error("Error adding ICE candidate", err));
break;
default:
break;
}
}
function startHeartbeat() {
stopHeartbeat();
lastPongAt = Date.now();
heartbeatTimer = setInterval(() => {
if (websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
}
if (Date.now() - lastPongAt > CONFIG.heartbeatTimeoutMs) {
scheduleReconnect();
}
}, CONFIG.heartbeatIntervalMs);
}
function stopHeartbeat() {
if (!heartbeatTimer) return;
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
function scheduleReconnect() {
try {
if (websocket) {
websocket.close();
}
} catch (err) {}
setTimeout(() => window.location.reload(), CONFIG.reconnectDelayMs);
}
function sendLogin() {
websocket.send(JSON.stringify({ type: "login", user_id: CONFIG.clientTag }));
}
function handleOffer(offer) {
pc = createPeerConnection();
pc.setRemoteDescription(offer)
.then(() => sendAnswer(pc))
.catch((err) => console.error("Failed to handle offer", err));
}
function createPeerConnection() {
const config = {
iceServers: CONFIG.iceServers,
iceTransportPolicy: "all",
};
const peer = new RTCPeerConnection(config);
peer.addEventListener("iceconnectionstatechange", () => {
const state = peer.iceConnectionState;
updateStatus(elements.iceState, state);
// Update status LED: connected when ICE state is "connected"
const isConnected = state === "connected";
updateStatusLed(elements.connectionStatusLed, isConnected, true);
updateStatusLed(elements.connectedStatusLed, isConnected, false);
// Show connection status overlay for disconnected or failed states
if (state === "disconnected" || state === "failed") {
if (elements.connectingOverlay && elements.connectingMessageText) {
// Update message text based on state
if (state === "disconnected") {
elements.connectingMessageText.textContent = "连接已断开...";
} else if (state === "failed") {
elements.connectingMessageText.textContent = "连接失败...";
}
elements.connectingOverlay.style.display = "flex";
}
} else if (state === "connected" || state === "checking" || state === "completed") {
// Hide overlay when connected or checking
if (elements.connectingOverlay) {
// Only hide if we're not in the initial connecting phase
// (initial connecting is handled by hideConnectingOverlayOnFirstFrame)
if (state === "connected" || state === "completed") {
elements.connectingOverlay.style.display = "none";
}
}
}
});
updateStatus(elements.iceState, peer.iceConnectionState);
const isConnected = peer.iceConnectionState === "connected";
updateStatusLed(elements.connectionStatusLed, isConnected, true);
updateStatusLed(elements.connectedStatusLed, isConnected, false);
peer.addEventListener("signalingstatechange", () => {
updateStatus(elements.signalingState, peer.signalingState);
});
updateStatus(elements.signalingState, peer.signalingState);
peer.onicecandidate = ({ candidate }) => {
if (!candidate) return;
websocket.send(
JSON.stringify({
type: "new_candidate_mid",
transmission_id: getTransmissionId(),
user_id: clientId,
remote_user_id: getTransmissionId(),
candidate: candidate.candidate,
mid: candidate.sdpMid,
})
);
};
peer.ontrack = ({ track, streams }) => {
// Handle audio tracks
if (track.kind === "audio" && elements.audio) {
if (!elements.audio.srcObject) {
// First audio track: create new stream
const audioStream = streams && streams[0] ? streams[0] : new MediaStream([track]);
elements.audio.srcObject = audioStream;
elements.audio.autoplay = true;
// Try to play audio (may require user interaction)
elements.audio.play().catch(err => {
console.log("Audio autoplay prevented:", err);
});
} else {
// Additional audio track: add to existing stream
elements.audio.srcObject.addTrack(track);
}
return;
}
// Handle video tracks
if (track.kind !== "video" || !elements.video) return;
// Use track index as display_id (0, 1, 2, ...)
const currentIndex = trackIndex;
trackIndex++;
// Store track in map
trackMap.set(currentIndex, track);
if (!elements.video.srcObject) {
// First track: create new stream
const stream = streams && streams[0] ? streams[0] : new MediaStream([track]);
elements.video.srcObject = stream;
elements.video.muted = true;
elements.video.setAttribute("playsinline", "true");
elements.video.setAttribute("webkit-playsinline", "true");
elements.video.setAttribute("x5-video-player-type", "h5");
elements.video.setAttribute("x5-video-player-fullscreen", "true");
elements.video.autoplay = true;
// Wait for first frame to be decoded before hiding connecting overlay
hideConnectingOverlayOnFirstFrame();
} else {
// Additional track: add to existing stream
elements.video.srcObject.addTrack(track);
}
if (!elements.displaySelect) return;
// Remove placeholder option "候选画面 ID..." when first track arrives
if (currentIndex === 0) {
const placeholderOption = Array.from(elements.displaySelect.options).find(
(opt) => opt.value === ""
);
if (placeholderOption) {
placeholderOption.remove();
}
}
// Check if option with this index already exists
const existingOption = Array.from(elements.displaySelect.options).find(
(opt) => opt.value === String(currentIndex)
);
if (!existingOption) {
const option = document.createElement("option");
option.value = String(currentIndex);
option.textContent = track.id || `Display ${currentIndex}`;
elements.displaySelect.appendChild(option);
}
// Only set default value for the first track (index 0)
// Don't auto-switch when additional tracks arrive
if (currentIndex === 0 && !elements.displaySelect.value) {
elements.displaySelect.value = String(currentIndex);
}
};
peer.ondatachannel = (event) => {
const channel = event.channel;
control.setDataChannel(channel);
bindDataChannel(channel);
};
return peer;
}
function bindDataChannel(channel) {
channel.addEventListener("open", () => {
updateStatus(elements.dataChannelState, "open");
enableDataChannelUi(true);
});
channel.addEventListener("close", () => {
updateStatus(elements.dataChannelState, "closed");
enableDataChannelUi(false);
control.setDataChannel(null);
});
channel.addEventListener("message", (event) => {
// Message received (no logging in production)
});
}
async function sendAnswer(peer) {
await peer.setLocalDescription(await peer.createAnswer());
await waitIceGathering(peer);
websocket.send(
JSON.stringify({
type: "answer",
transmission_id: getTransmissionId(),
user_id: clientId,
remote_user_id: getTransmissionId(),
sdp: peer.localDescription.sdp,
})
);
}
function waitIceGathering(peer) {
if (peer.iceGatheringState === "complete") {
return Promise.resolve();
}
return new Promise((resolve) => {
peer.addEventListener("icegatheringstatechange", () => {
if (peer.iceGatheringState === "complete") resolve();
});
});
}
function getTransmissionId() {
return document.getElementById("transmission-id").value.trim();
}
function getTransmissionPwd() {
return document.getElementById("transmission-pwd").value.trim();
}
function sendJoinRequest() {
websocket.send(
JSON.stringify({
type: "join_transmission",
user_id: clientId,
transmission_id: `${getTransmissionId()}@${getTransmissionPwd()}`,
})
);
}
function sendLeaveRequest() {
websocket.send(
JSON.stringify({
type: "user_leave_transmission",
user_id: clientId,
transmission_id: getTransmissionId(),
})
);
}
function connect() {
if (!elements.connectBtn || !elements.disconnectBtn || !elements.media) return;
// Initialize WebSocket connection if not already initialized
if (!websocket) {
// Update server config before connecting
const signalingHostPort = elements.signalingServerInput?.value.trim();
const stunTurnHostPort = elements.stunTurnServerInput?.value.trim();
// Only update config if server config is visible and has values
const isServerConfigVisible = elements.serverConfigContainer &&
elements.serverConfigContainer.style.display !== "none";
if (isServerConfigVisible && signalingHostPort && stunTurnHostPort) {
const success = updateServerConfig(signalingHostPort, stunTurnHostPort);
if (!success) {
alert("服务器配置无效");
return;
}
}
// Initialize WebSocket
initWebSocket();
return; // Wait for WebSocket to open
}
// If WebSocket is already open, proceed with connection
if (websocket.readyState === WebSocket.OPEN) {
saveServerConfig();
elements.connectBtn.style.display = "none";
elements.disconnectBtn.style.display = "inline-block";
elements.media.style.display = "flex";
// Hide connection overlay, show connected overlay
if (elements.connectionOverlay) {
elements.connectionOverlay.style.display = "none";
}
if (elements.connectedOverlay) {
elements.connectedOverlay.style.display = "block";
// Show panel initially when connecting
if (elements.connectedPanel) {
isPanelMinimized = false;
panelAlignment = "left"; // Reset to left alignment
elements.connectedPanel.classList.remove("minimized");
elements.connectedPanel.style.left = "0";
elements.connectedPanel.style.right = "auto";
hideConnectedPanel(); // Start auto-hide timer
}
}
// Show connecting overlay
if (elements.connectingOverlay) {
elements.connectingOverlay.style.display = "flex";
}
// Reset connecting message text
if (elements.connectingMessageText) {
elements.connectingMessageText.textContent = "连接中...";
}
sendJoinRequest();
}
}
function disconnect() {
if (!elements.connectBtn || !elements.disconnectBtn || !elements.media) return;
elements.disconnectBtn.style.display = "none";
elements.connectBtn.style.display = "inline-block";
elements.media.style.display = "none";
// Show connection overlay, hide connected overlay
if (elements.connectionOverlay) {
elements.connectionOverlay.style.display = "flex";
}
if (elements.connectedOverlay) {
elements.connectedOverlay.style.display = "none";
}
// Hide connecting overlay
if (elements.connectingOverlay) {
elements.connectingOverlay.style.display = "none";
}
// Clear panel hide timer and reset panel state
if (panelHideTimer) {
clearTimeout(panelHideTimer);
panelHideTimer = null;
}
isPanelMinimized = false;
isDragging = false;
panelAlignment = "left"; // Reset to left alignment
if (elements.connectedPanel) {
elements.connectedPanel.classList.remove("minimized");
elements.connectedPanel.style.left = "0";
elements.connectedPanel.style.right = "auto";
}
sendLeaveRequest();
teardownPeerConnection();
enableDataChannelUi(false);
updateStatus(elements.iceState, "");
updateStatus(elements.signalingState, "");
updateStatus(elements.dataChannelState, "closed");
// Reset track index and clear display select options
trackIndex = 0;
trackMap.clear();
if (elements.displaySelect) {
elements.displaySelect.innerHTML = '<option value="" selected>候选画面 ID...</option>';
}
// Reset status LEDs and hide indicator
updateStatusLed(elements.connectionStatusLed, false, true);
updateStatusLed(elements.connectedStatusLed, false, false);
// Close WebSocket connection
if (websocket) {
websocket.close();
websocket = null;
}
}
function hideConnectingOverlayOnFirstFrame() {
if (!elements.video || !elements.connectingOverlay) return;
// Use requestVideoFrameCallback if available (most accurate)
if (elements.video.requestVideoFrameCallback) {
let frameCallbackId = null;
const callback = () => {
if (elements.connectingOverlay) {
elements.connectingOverlay.style.display = "none";
}
if (frameCallbackId !== null) {
elements.video.cancelVideoFrameCallback(frameCallbackId);
}
};
frameCallbackId = elements.video.requestVideoFrameCallback(callback);
return;
}
// Fallback: use loadeddata event (first frame decoded)
const onFirstFrame = () => {
if (elements.connectingOverlay) {
elements.connectingOverlay.style.display = "none";
}
elements.video.removeEventListener("loadeddata", onFirstFrame);
elements.video.removeEventListener("canplay", onFirstFrame);
};
// Try loadeddata first (more accurate - first frame decoded)
if (elements.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
// Already has data, hide immediately
onFirstFrame();
} else {
elements.video.addEventListener("loadeddata", onFirstFrame, { once: true });
// Fallback to canplay if loadeddata doesn't fire
elements.video.addEventListener("canplay", onFirstFrame, { once: true });
}
}
function teardownPeerConnection() {
if (!pc) return;
try {
pc.getSenders().forEach((sender) => sender.track?.stop?.());
} catch (err) {}
pc.close();
pc = null;
if (elements.video?.srcObject) {
elements.video.srcObject.getTracks().forEach((track) => track.stop());
elements.video.srcObject = null;
}
if (elements.audio?.srcObject) {
elements.audio.srcObject.getTracks().forEach((track) => track.stop());
elements.audio.srcObject = null;
}
}
function updateStatus(element, value) {
if (!element) return;
element.textContent = value || "";
}
// Update status LED indicator
function updateStatusLed(ledElement, isConnected, showIndicator = true) {
if (!ledElement) return;
if (isConnected) {
ledElement.classList.remove("status-led-off");
ledElement.classList.add("status-led-on");
// 显示指示灯容器
if (showIndicator && elements.connectionStatusIndicator) {
elements.connectionStatusIndicator.style.display = "flex";
}
} else {
ledElement.classList.remove("status-led-on");
ledElement.classList.add("status-led-off");
// 隐藏指示灯容器(未连接时)
if (showIndicator && elements.connectionStatusIndicator) {
elements.connectionStatusIndicator.style.display = "none";
}
}
}
function enableConnectButton(enabled) {
if (!elements.connectBtn) return;
// Only enable button if WebSocket is open and form inputs are valid
if (enabled) {
// Check if form inputs allow connection
checkConnectButtonEnabled();
} else {
elements.connectBtn.disabled = true;
}
}
function enableDataChannelUi(enabled) {
if (elements.displaySelect) {
elements.displaySelect.disabled = !enabled;
}
}
function setDisplayId() {
if (!elements.displaySelect) return;
const raw = elements.displaySelect.value.trim();
if (!raw) {
// 如果值为空,不发送(保持原有行为)
return;
}
const parsed = parseInt(raw, 10);
// 检查解析结果如果解析失败NaN或者不是有效数字不发送
if (isNaN(parsed) || !Number.isFinite(parsed)) {
console.warn("setDisplayId: Invalid display_id value:", raw);
return;
}
// Switch video track to the selected display_id
const selectedTrack = trackMap.get(parsed);
if (selectedTrack && elements.video) {
// Don't stop tracks - just replace the stream
// Stopping tracks makes them unusable
const newStream = new MediaStream([selectedTrack]);
elements.video.srcObject = newStream;
elements.video.muted = true;
elements.video.setAttribute("playsinline", "true");
elements.video.setAttribute("webkit-playsinline", "true");
elements.video.setAttribute("x5-video-player-type", "h5");
elements.video.setAttribute("x5-video-player-fullscreen", "true");
elements.video.autoplay = true;
}
control.sendDisplayId(parsed);
}
if (elements.connectBtn) {
elements.connectBtn.addEventListener("click", connect);
}
if (elements.disconnectBtn) {
elements.disconnectBtn.addEventListener("click", disconnect);
}
if (elements.disconnectConnected) {
elements.disconnectConnected.addEventListener("click", disconnect);
}
if (elements.displaySelect) {
elements.displaySelect.addEventListener("change", setDisplayId);
}
// Server configuration event listeners
if (elements.serverConfigBtn) {
elements.serverConfigBtn.addEventListener("click", toggleServerConfig);
}
// Add input event listeners to check connect button state
if (elements.signalingServerInput) {
elements.signalingServerInput.addEventListener("input", checkConnectButtonEnabled);
}
if (elements.stunTurnServerInput) {
elements.stunTurnServerInput.addEventListener("input", checkConnectButtonEnabled);
}
if (document.getElementById("transmission-id")) {
document.getElementById("transmission-id").addEventListener("input", checkConnectButtonEnabled);
}
if (document.getElementById("transmission-pwd")) {
document.getElementById("transmission-pwd").addEventListener("input", checkConnectButtonEnabled);
}
// Initialize connect button state on page load
checkConnectButtonEnabled();
// Panel minimize/maximize and drag functionality
let panelHideTimer = null;
const PANEL_HIDE_DELAY = 3000; // 3 seconds
let isPanelMinimized = false;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let panelStartLeft = 0;
let panelStartTop = 0;
let panelAlignment = "left"; // "left" or "right" - tracks which edge the minimized panel is closer to
let panelCorner = "top-left"; // "top-left", "top-right", "bottom-left", "bottom-right" - tracks which corner the button is at when expanded
const SNAP_THRESHOLD = 20; // Distance in pixels to trigger edge snapping
function calculateExpandPosition(buttonLeft, buttonTop, buttonWidth, buttonHeight) {
// Estimated panel dimensions (will be updated after layout)
const estimatedPanelWidth = 400;
const estimatedPanelHeight = 100;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Try top-left first (button as top-left corner)
let expandLeft = buttonLeft;
let expandTop = buttonTop;
let expandRight = "auto";
let expandBottom = "auto";
let horizontalAlign = "left";
let verticalAlign = "top";
// Check if panel would overflow right
if (buttonLeft + estimatedPanelWidth > viewportWidth) {
// Try top-right (button as top-right corner)
if (buttonLeft - estimatedPanelWidth >= 0) {
expandLeft = buttonLeft - estimatedPanelWidth + buttonWidth;
expandRight = "auto";
horizontalAlign = "right";
} else {
// Panel too wide, align to viewport edge
expandLeft = "0";
expandRight = "auto";
horizontalAlign = "left";
}
}
// Check if panel would overflow bottom
if (buttonTop + estimatedPanelHeight > viewportHeight) {
// Try bottom-left or bottom-right
if (buttonTop - estimatedPanelHeight >= 0) {
expandTop = buttonTop - estimatedPanelHeight + buttonHeight;
expandBottom = "auto";
verticalAlign = "bottom";
} else {
// Panel too tall, align to viewport edge
expandTop = "auto";
expandBottom = "0";
verticalAlign = "bottom";
}
}
return {
left: expandLeft,
top: expandTop,
right: expandRight,
bottom: expandBottom,
horizontalAlign,
verticalAlign
};
}
function togglePanelMinimize() {
if (!elements.connectedPanel) return;
isPanelMinimized = !isPanelMinimized;
if (isPanelMinimized) {
// Minimizing: keep icon at its current position
// Get the current icon position BEFORE clearing right/bottom
// This is critical: getBoundingClientRect() returns the actual rendered position
// regardless of how the panel is positioned (left/right, top/bottom)
const iconRect = elements.panelCollapsedBar.getBoundingClientRect();
let iconLeft = iconRect.left;
let iconTop = iconRect.top;
// Ensure position is within viewport bounds (handle edge cases like 0, 0)
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const buttonSize = 48; // Size of minimized button
// Clamp to viewport bounds
iconLeft = Math.max(0, Math.min(iconLeft, viewportWidth - buttonSize));
iconTop = Math.max(0, Math.min(iconTop, viewportHeight - buttonSize));
elements.connectedPanel.classList.add("minimized");
// Set left/top and clear right/bottom in a single operation to prevent position jump
// Place panel at icon's current position (icon is at top-left of panel, so panel position = icon position)
elements.connectedPanel.style.left = `${iconLeft}px`;
elements.connectedPanel.style.top = `${iconTop}px`;
elements.connectedPanel.style.right = "auto";
elements.connectedPanel.style.bottom = "auto";
// Force a reflow to ensure the position is applied
elements.connectedPanel.offsetHeight;
} else {
// Expanding: calculate position based on button location
const rect = elements.connectedPanel.getBoundingClientRect();
const buttonLeft = rect.left;
const buttonTop = rect.top;
const buttonWidth = rect.width;
const buttonHeight = rect.height;
elements.connectedPanel.classList.remove("minimized");
// Calculate optimal expand position
const pos = calculateExpandPosition(buttonLeft, buttonTop, buttonWidth, buttonHeight);
// Apply position after layout update
requestAnimationFrame(() => {
const actualPanelWidth = elements.connectedPanel.offsetWidth;
const actualPanelHeight = elements.connectedPanel.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Always expand with button as top-left corner
let finalLeft = buttonLeft;
let finalTop = buttonTop;
let finalRight = "auto";
let finalBottom = "auto";
let corner = "top-left"; // Always use top-left corner
// Check horizontal overflow - ensure panel is fully visible
if (buttonLeft + actualPanelWidth > viewportWidth) {
// Panel too wide, align to right edge (right: 0) to ensure it's fully visible
// Button remains at top-left, but panel right edge touches viewport right edge
finalLeft = "auto";
finalRight = 0;
}
// Check vertical overflow - ensure panel is fully visible
if (buttonTop + actualPanelHeight > viewportHeight) {
// Panel too tall, align to bottom edge (bottom: 0) to ensure it's fully visible
// Button remains at top-left, but panel bottom edge touches viewport bottom edge
finalTop = "auto";
finalBottom = 0;
}
// Final constraint check - ensure panel is completely within viewport
// Only apply constraints if using left/top positioning
if (finalLeft !== "auto") {
finalLeft = Math.max(0, Math.min(finalLeft, viewportWidth - actualPanelWidth));
}
if (finalTop !== "auto") {
finalTop = Math.max(0, Math.min(finalTop, viewportHeight - actualPanelHeight));
}
// Record the corner position (always top-left)
panelCorner = corner;
elements.connectedPanel.style.left = typeof finalLeft === "number" ? `${finalLeft}px` : finalLeft;
elements.connectedPanel.style.top = typeof finalTop === "number" ? `${finalTop}px` : finalTop;
elements.connectedPanel.style.right = finalRight;
elements.connectedPanel.style.bottom = finalBottom;
// Update alignment for future reference
updatePanelAlignment();
});
}
// Clear hide timer when toggling
if (panelHideTimer) {
clearTimeout(panelHideTimer);
panelHideTimer = null;
}
}
function minimizePanel() {
if (!elements.connectedPanel || isPanelMinimized) return;
// Get the current icon position BEFORE clearing right/bottom
// This is critical: getBoundingClientRect() returns the actual rendered position
// regardless of how the panel is positioned (left/right, top/bottom)
const iconRect = elements.panelCollapsedBar.getBoundingClientRect();
let iconLeft = iconRect.left;
let iconTop = iconRect.top;
// Ensure position is within viewport bounds (handle edge cases like 0, 0)
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const buttonSize = 48; // Size of minimized button
// Clamp to viewport bounds
iconLeft = Math.max(0, Math.min(iconLeft, viewportWidth - buttonSize));
iconTop = Math.max(0, Math.min(iconTop, viewportHeight - buttonSize));
isPanelMinimized = true;
elements.connectedPanel.classList.add("minimized");
// Set left/top and clear right/bottom in a single operation to prevent position jump
// Place panel at icon's current position (icon is at top-left of panel, so panel position = icon position)
elements.connectedPanel.style.left = `${iconLeft}px`;
elements.connectedPanel.style.top = `${iconTop}px`;
elements.connectedPanel.style.right = "auto";
elements.connectedPanel.style.bottom = "auto";
// Force a reflow to ensure the position is applied before any other operations
elements.connectedPanel.offsetHeight;
// Update alignment based on final button position
updatePanelAlignment();
}
function updatePanelAlignment() {
if (!elements.connectedPanel) return;
const rect = elements.connectedPanel.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const distanceFromLeft = rect.left;
const distanceFromRight = viewportWidth - rect.right;
// Determine which edge is closer
if (distanceFromRight < distanceFromLeft) {
panelAlignment = "right";
} else {
panelAlignment = "left";
}
}
function applyPanelAlignment() {
if (!elements.connectedPanel) return;
// This function is no longer used for expanding from minimized state
// The expansion logic is now handled in togglePanelMinimize and maximizePanel
// Keep this for backward compatibility but it shouldn't reset position
const rect = elements.connectedPanel.getBoundingClientRect();
if (panelAlignment === "right") {
elements.connectedPanel.style.right = "0";
elements.connectedPanel.style.left = "auto";
} else {
elements.connectedPanel.style.left = "0";
elements.connectedPanel.style.right = "auto";
}
// Don't reset top/bottom - keep current position
}
function maximizePanel() {
if (!elements.connectedPanel || !isPanelMinimized) return;
// Save current button position before maximizing
const rect = elements.connectedPanel.getBoundingClientRect();
const buttonLeft = rect.left;
const buttonTop = rect.top;
const buttonWidth = rect.width;
const buttonHeight = rect.height;
isPanelMinimized = false;
elements.connectedPanel.classList.remove("minimized");
// Use requestAnimationFrame to ensure layout is updated before setting position
requestAnimationFrame(() => {
const actualPanelWidth = elements.connectedPanel.offsetWidth;
const actualPanelHeight = elements.connectedPanel.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Always expand with button as top-left corner
let finalLeft = buttonLeft;
let finalTop = buttonTop;
let finalRight = "auto";
let finalBottom = "auto";
let corner = "top-left"; // Always use top-left corner
// Check horizontal overflow - ensure panel is fully visible
if (buttonLeft + actualPanelWidth > viewportWidth) {
// Panel too wide, align to right edge (right: 0) to ensure it's fully visible
// Button remains at top-left, but panel right edge touches viewport right edge
finalLeft = "auto";
finalRight = 0;
}
// Check vertical overflow - ensure panel is fully visible
if (buttonTop + actualPanelHeight > viewportHeight) {
// Panel too tall, align to bottom edge (bottom: 0) to ensure it's fully visible
// Button remains at top-left, but panel bottom edge touches viewport bottom edge
finalTop = "auto";
finalBottom = 0;
}
// Final constraint check - ensure panel is completely within viewport
// Only apply constraints if using left/top positioning
if (finalLeft !== "auto") {
finalLeft = Math.max(0, Math.min(finalLeft, viewportWidth - actualPanelWidth));
}
if (finalTop !== "auto") {
finalTop = Math.max(0, Math.min(finalTop, viewportHeight - actualPanelHeight));
}
// Record the corner position (always top-left)
panelCorner = corner;
elements.connectedPanel.style.left = typeof finalLeft === "number" ? `${finalLeft}px` : finalLeft;
elements.connectedPanel.style.top = typeof finalTop === "number" ? `${finalTop}px` : finalTop;
elements.connectedPanel.style.right = finalRight;
elements.connectedPanel.style.bottom = finalBottom;
// Update alignment for future reference
updatePanelAlignment();
});
}
function showConnectedPanel() {
if (!elements.connectedPanel) return;
maximizePanel();
// Clear existing hide timer
if (panelHideTimer) {
clearTimeout(panelHideTimer);
panelHideTimer = null;
}
}
function hideConnectedPanel() {
if (!elements.connectedPanel) return;
panelHideTimer = setTimeout(() => {
if (elements.connectedPanel && !isPanelMinimized) {
minimizePanel();
}
}, PANEL_HIDE_DELAY);
}
// Drag functionality for collapsed bar
function startDrag(e) {
if (!elements.connectedPanel) return;
isDragging = true;
// Notify control manager to block mouse events during drag
if (control && control.setDraggingPanel) {
control.setDraggingPanel(true);
}
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
dragStartX = clientX;
dragStartY = clientY;
const rect = elements.connectedPanel.getBoundingClientRect();
panelStartLeft = rect.left;
panelStartTop = rect.top;
e.preventDefault();
document.addEventListener("mousemove", onDrag);
document.addEventListener("mouseup", stopDrag);
document.addEventListener("touchmove", onDrag);
document.addEventListener("touchend", stopDrag);
}
function onDrag(e) {
if (!isDragging || !elements.connectedPanel) return;
// Prevent event from propagating to other handlers
e.preventDefault();
e.stopPropagation();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const deltaX = clientX - dragStartX;
const deltaY = clientY - dragStartY;
const newLeft = panelStartLeft + deltaX;
const newTop = panelStartTop + deltaY;
// Constrain to viewport
const panelWidth = elements.connectedPanel.offsetWidth;
const panelHeight = elements.connectedPanel.offsetHeight;
const maxLeft = window.innerWidth - panelWidth;
const maxTop = window.innerHeight - panelHeight;
const constrainedLeft = Math.max(0, Math.min(newLeft, maxLeft));
const constrainedTop = Math.max(0, Math.min(newTop, maxTop));
elements.connectedPanel.style.left = `${constrainedLeft}px`;
elements.connectedPanel.style.top = `${constrainedTop}px`;
elements.connectedPanel.style.right = "auto";
elements.connectedPanel.style.bottom = "auto";
// Update alignment based on position
const viewportWidth = window.innerWidth;
const distanceFromLeft = constrainedLeft;
const distanceFromRight = viewportWidth - constrainedLeft - panelWidth;
// Determine which edge is closer (with a small threshold to avoid flickering)
if (distanceFromRight < distanceFromLeft) {
panelAlignment = "right";
} else {
panelAlignment = "left";
}
}
function stopDrag() {
isDragging = false;
// Notify control manager to resume mouse events after drag
if (control && control.setDraggingPanel) {
control.setDraggingPanel(false);
}
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("mouseup", stopDrag);
document.removeEventListener("touchmove", onDrag);
document.removeEventListener("touchend", stopDrag);
// Snap to nearest edge if close enough
if (elements.connectedPanel && isPanelMinimized) {
snapToEdge();
updatePanelAlignment();
}
}
function snapToEdge() {
if (!elements.connectedPanel || !isPanelMinimized) return;
const rect = elements.connectedPanel.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const panelWidth = rect.width;
const panelHeight = rect.height;
const distanceFromLeft = rect.left;
const distanceFromRight = viewportWidth - rect.right;
const distanceFromTop = rect.top;
const distanceFromBottom = viewportHeight - rect.bottom;
// Find the nearest edge
const minHorizontal = Math.min(distanceFromLeft, distanceFromRight);
const minVertical = Math.min(distanceFromTop, distanceFromBottom);
// Snap to horizontal edge if close enough
if (minHorizontal <= SNAP_THRESHOLD) {
if (distanceFromLeft < distanceFromRight) {
elements.connectedPanel.style.left = "0";
elements.connectedPanel.style.right = "auto";
panelAlignment = "left";
} else {
elements.connectedPanel.style.right = "0";
elements.connectedPanel.style.left = "auto";
panelAlignment = "right";
}
}
// Snap to vertical edge if close enough
if (minVertical <= SNAP_THRESHOLD) {
if (distanceFromTop < distanceFromBottom) {
elements.connectedPanel.style.top = "0";
elements.connectedPanel.style.bottom = "auto";
} else {
elements.connectedPanel.style.bottom = "0";
elements.connectedPanel.style.top = "auto";
}
}
}
// Show panel when mouse moves to top area or when interacting with panel
if (elements.connectedOverlay) {
const topTriggerHeight = 80; // Height of top area that triggers panel show
elements.connectedOverlay.addEventListener("mousemove", (e) => {
if (e.clientY <= topTriggerHeight) {
showConnectedPanel();
} else if (!elements.connectedPanel?.matches(":hover") && !isPanelMinimized) {
hideConnectedPanel();
}
});
elements.connectedOverlay.addEventListener("mouseleave", () => {
if (!isPanelMinimized) {
hideConnectedPanel();
}
});
// Keep panel visible when hovering over it
if (elements.connectedPanel) {
elements.connectedPanel.addEventListener("mouseenter", () => {
if (!isPanelMinimized) {
showConnectedPanel();
}
});
elements.connectedPanel.addEventListener("mouseleave", () => {
if (!isPanelMinimized) {
hideConnectedPanel();
}
});
}
// Minimize on collapsed bar click (only when expanded)
if (elements.panelCollapsedBar) {
// Use a shared variable to track drag state across event handlers
let panelDragStarted = false;
let panelDragStartTime = 0;
let panelDragStartPos = { x: 0, y: 0 };
// Start drag on collapsed bar (prevent click when dragging)
elements.panelCollapsedBar.addEventListener("mousedown", (e) => {
// Immediately prevent event from being handled by control.js
e.stopPropagation();
e.preventDefault();
// Immediately set dragging state to prevent mouse movement
if (control && control.setDraggingPanel) {
control.setDraggingPanel(true);
}
panelDragStarted = false;
panelDragStartTime = Date.now();
panelDragStartPos.x = e.clientX;
panelDragStartPos.y = e.clientY;
const onMouseMove = (moveEvent) => {
moveEvent.stopPropagation();
const deltaX = Math.abs(moveEvent.clientX - panelDragStartPos.x);
const deltaY = Math.abs(moveEvent.clientY - panelDragStartPos.y);
if (deltaX > 5 || deltaY > 5) {
panelDragStarted = true;
startDrag(moveEvent);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
}
};
const onMouseUp = (upEvent) => {
upEvent.stopPropagation();
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
// If it was a quick click (not a drag), handle it immediately
const clickDuration = Date.now() - panelDragStartTime;
const deltaX = Math.abs(upEvent.clientX - panelDragStartPos.x);
const deltaY = Math.abs(upEvent.clientY - panelDragStartPos.y);
if (!panelDragStarted && clickDuration < 300 && deltaX <= 5 && deltaY <= 5) {
// It was a click, not a drag
if (control && control.setDraggingPanel) {
control.setDraggingPanel(false);
}
// Handle click immediately
if (!isPanelMinimized) {
minimizePanel();
} else {
togglePanelMinimize();
}
} else if (panelDragStarted) {
// It was a drag, reset dragging state
if (control && control.setDraggingPanel) {
control.setDraggingPanel(false);
}
} else {
// Reset dragging state if it wasn't a click or drag
if (control && control.setDraggingPanel) {
control.setDraggingPanel(false);
}
}
// Reset drag flag
panelDragStarted = false;
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
elements.panelCollapsedBar.addEventListener("touchstart", (e) => {
// Immediately prevent event from being handled by control.js
e.stopPropagation();
e.preventDefault();
// Immediately set dragging state to prevent mouse movement
if (control && control.setDraggingPanel) {
control.setDraggingPanel(true);
}
panelDragStarted = false;
panelDragStartTime = Date.now();
panelDragStartPos.x = e.touches[0].clientX;
panelDragStartPos.y = e.touches[0].clientY;
const onTouchMove = (moveEvent) => {
moveEvent.stopPropagation();
const deltaX = Math.abs(moveEvent.touches[0].clientX - panelDragStartPos.x);
const deltaY = Math.abs(moveEvent.touches[0].clientY - panelDragStartPos.y);
if (deltaX > 5 || deltaY > 5) {
panelDragStarted = true;
startDrag(moveEvent);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", onTouchEnd);
}
};
const onTouchEnd = (endEvent) => {
endEvent.stopPropagation();
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", onTouchEnd);
// If it was a quick tap (not a drag), handle it immediately
const tapDuration = Date.now() - panelDragStartTime;
const endX = endEvent.changedTouches && endEvent.changedTouches[0] ? endEvent.changedTouches[0].clientX : panelDragStartPos.x;
const endY = endEvent.changedTouches && endEvent.changedTouches[0] ? endEvent.changedTouches[0].clientY : panelDragStartPos.y;
const deltaX = Math.abs(endX - panelDragStartPos.x);
const deltaY = Math.abs(endY - panelDragStartPos.y);
if (!panelDragStarted && tapDuration < 300 && deltaX <= 5 && deltaY <= 5) {
// It was a tap, not a drag
if (control && control.setDraggingPanel) {
control.setDraggingPanel(false);
}
// Handle tap immediately
if (!isPanelMinimized) {
minimizePanel();
} else {
togglePanelMinimize();
}
} else if (panelDragStarted) {
// It was a drag, reset dragging state
if (control && control.setDraggingPanel) {
control.setDraggingPanel(false);
}
} else {
// Reset dragging state if it wasn't a tap or drag
if (control && control.setDraggingPanel) {
control.setDraggingPanel(false);
}
}
// Reset drag flag
panelDragStarted = false;
};
document.addEventListener("touchmove", onTouchMove);
document.addEventListener("touchend", onTouchEnd);
}, { passive: false });
}
// Show panel when clicking on video (for touch devices)
if (elements.video) {
elements.video.addEventListener("click", (e) => {
if (e.clientY <= topTriggerHeight || e.target === elements.video) {
if (isPanelMinimized) {
togglePanelMinimize();
} else {
showConnectedPanel();
hideConnectedPanel();
}
}
});
}
}
window.connect = connect;
window.disconnect = disconnect;
window.setDisplayId = setDisplayId;
// 禁止复制、剪切、粘贴等操作
document.addEventListener("copy", (event) => {
event.preventDefault();
event.clipboardData.setData("text/plain", "");
return false;
});
document.addEventListener("cut", (event) => {
event.preventDefault();
event.clipboardData.setData("text/plain", "");
return false;
});
document.addEventListener("paste", (event) => {
// 允许在输入框中粘贴
const target = event.target;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) {
return; // 允许输入框粘贴
}
event.preventDefault();
return false;
});
// 阻止右键菜单(可选,但保留以增强保护)
document.addEventListener("contextmenu", (event) => {
// 允许在输入框上显示右键菜单
const target = event.target;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) {
return; // 允许输入框右键菜单
}
event.preventDefault();
return false;
});
// 阻止选择文本(通过鼠标拖拽)
document.addEventListener("selectstart", (event) => {
const target = event.target;
// 允许输入框和文本区域选择
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT")) {
return;
}
event.preventDefault();
return false;
});
// 阻止拖拽
document.addEventListener("dragstart", (event) => {
event.preventDefault();
return false;
});