Files
crossdesk-web-client/web_client.js

1165 lines
38 KiB
JavaScript

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"),
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"),
connectionStatusLed: document.getElementById("connection-status-led"),
connectionStatusIndicator: document.getElementById("connection-status-indicator"),
connectedStatusLed: document.getElementById("connected-status-led"),
disconnectConnected: document.getElementById("disconnect-connected"),
};
// Config section (can be overridden by setting window.CROSSDESK_CONFIG before this script runs)
const DEFAULT_CONFIG = {
signalingUrl: "wss://api.crossdesk.cn:9090",
iceServers: [
{ urls: ["stun:api.crossdesk.cn:3478"] },
{ urls: ["turn:api.crossdesk.cn:3478"], username: "crossdesk", credential: "crossdeskpw" },
],
heartbeatIntervalMs: 3000,
heartbeatTimeoutMs: 10000,
reconnectDelayMs: 2000,
clientTag: "web",
};
const CONFIG = Object.assign({}, DEFAULT_CONFIG, window.CROSSDESK_CONFIG || {});
const control = window.CrossDeskControl;
let pc = null;
let clientId = "000000";
let heartbeatTimer = null;
let lastPongAt = Date.now();
const 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 "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 {
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);
});
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 }) => {
if (track.kind !== "video" || !elements.video) return;
if (!elements.video.srcObject) {
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 {
elements.video.srcObject.addTrack(track);
}
if (!elements.displaySelect) return;
const trackId = track.id || "";
if (!trackId) return;
const existingOption = Array.from(elements.displaySelect.options).find(
(opt) => opt.value === trackId
);
if (!existingOption) {
const option = document.createElement("option");
option.value = trackId;
option.textContent = trackId;
elements.displaySelect.appendChild(option);
}
elements.displaySelect.value = trackId;
};
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: "leave_transmission",
user_id: clientId,
transmission_id: getTransmissionId(),
})
);
}
function connect() {
if (!elements.connectBtn || !elements.disconnectBtn || !elements.media) return;
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";
}
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 status LEDs and hide indicator
updateStatusLed(elements.connectionStatusLed, false, true);
updateStatusLed(elements.connectedStatusLed, false, false);
}
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;
}
}
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;
elements.connectBtn.disabled = !enabled;
}
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);
const numericValue = Number.isFinite(parsed) ? parsed : 0;
control.sendDisplayId(numericValue);
}
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);
}
// 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);
});
}
// 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;
});