1165 lines
38 KiB
JavaScript
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;
|
|
});
|
|
|