feat: suspend input during port switch with overlay and countdown
All checks were successful
CI / fmt (push) Successful in 34s
Publish / frontend (push) Successful in 55s
CI / clippy (push) Successful in 1m21s
CI / check (push) Successful in 1m37s
Publish / backend (push) Successful in 2m51s

When switching ports via the console dropdown:
- Input (keyboard, mouse, wheel) is suspended immediately to prevent
  stray events from interfering with the OSCAR hotkey sequence
- A semi-transparent overlay with spinner and countdown timer appears
  over the console canvas
- Duration is calculated from the actual key_pause_duration setting
  multiplied by the number of * pause tokens in the port's hotkey,
  plus a 500ms buffer
- Input resumes and overlay disappears when the timer expires

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 13:11:47 +03:00
parent 7406b4ac02
commit 2e6f80f9ac
2 changed files with 106 additions and 9 deletions

View File

@@ -155,12 +155,15 @@ function handleMessage(ev: MessageEvent) {
// ---------------------------------------------------------------------------
let portHotkeys: Record<number, string> = {}
let keyPauseDuration = 2000
let inputSuspended = false
async function loadPortList() {
try {
const resp = await fetch('/api/kvm/ports')
if (!resp.ok) return
const data = await resp.json()
keyPauseDuration = data.key_pause_duration || 2000
const select = document.getElementById('port-select') as HTMLSelectElement
if (!select) return
select.innerHTML = ''
@@ -182,19 +185,69 @@ async function loadPortList() {
function switchToPort(port: number) {
const hotkey = portHotkeys[port]
if (hotkey && ws?.readyState === WebSocket.OPEN) {
// Send the hotkey sequence as key events over WS to the KVM
if (!hotkey || ws?.readyState !== WebSocket.OPEN) return
// Count pause tokens to calculate total switch duration
const pauseCount = (hotkey.match(/\*/g) || []).length
const totalDelay = pauseCount * keyPauseDuration + 500 // +500ms buffer
// Suspend input and show overlay
inputSuspended = true
showSwitchOverlay(totalDelay)
// Send the hotkey sequence
const messages = parseHotkey(hotkey)
for (const msg of messages) {
ws.send(msg)
}
}
// Also update the Belkin's active port tracking
// Update Belkin's active port tracking
fetch('/api/kvm/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
}).catch(() => {})
// Resume input after the switch completes
setTimeout(() => {
inputSuspended = false
hideSwitchOverlay()
document.getElementById('canvas')?.focus()
}, totalDelay)
}
function showSwitchOverlay(duration: number) {
let overlay = document.getElementById('switch-overlay')
if (!overlay) {
overlay = document.createElement('div')
overlay.id = 'switch-overlay'
overlay.className = 'switch-overlay'
document.querySelector('.console-wrap')?.appendChild(overlay)
}
overlay.innerHTML = `
<div class="switch-overlay-content">
<div class="switch-spinner"></div>
<div>Switching port...</div>
<div class="switch-timer" id="switch-timer"></div>
</div>
`
overlay.hidden = false
// Countdown timer
const start = Date.now()
const timerEl = document.getElementById('switch-timer')
const tick = () => {
if (!timerEl || overlay!.hidden) return
const remaining = Math.max(0, duration - (Date.now() - start))
timerEl.textContent = `${(remaining / 1000).toFixed(1)}s`
if (remaining > 0) requestAnimationFrame(tick)
}
tick()
}
function hideSwitchOverlay() {
const overlay = document.getElementById('switch-overlay')
if (overlay) overlay.hidden = true
}
// ---------------------------------------------------------------------------
@@ -206,18 +259,20 @@ function wireInputHandlers() {
canvas.addEventListener('keydown', (e) => {
e.preventDefault()
if (inputSuspended) return
const sc = codeToScancode(e.code)
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) ws.send(makeKeyPress(sc))
})
canvas.addEventListener('keyup', (e) => {
e.preventDefault()
if (inputSuspended) return
const sc = codeToScancode(e.code)
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) ws.send(makeKeyRelease(sc))
})
function sendPointer(e: MouseEvent) {
if (ws?.readyState !== WebSocket.OPEN) return
if (inputSuspended || ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) * canvas.width / rect.width)
const y = Math.round((e.clientY - rect.top) * canvas.height / rect.height)
@@ -247,7 +302,7 @@ function wireInputHandlers() {
canvas.addEventListener('contextmenu', (e) => e.preventDefault())
canvas.addEventListener('wheel', (e) => {
e.preventDefault()
if (ws?.readyState !== WebSocket.OPEN) return
if (inputSuspended || ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) * canvas.width / rect.width)
const y = Math.round((e.clientY - rect.top) * canvas.height / rect.height)

View File

@@ -179,6 +179,48 @@ body {
cursor: none;
}
/* Port switch overlay */
.switch-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.switch-overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
color: #ccc;
font-size: 0.9rem;
}
.switch-spinner {
width: 32px;
height: 32px;
border: 3px solid #444;
border-top-color: #4a7c59;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.switch-timer {
font-variant-numeric: tabular-nums;
color: #888;
font-size: 0.8rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Console wrap needs position for overlay */
.console-wrap {
position: relative;
}
/* Ports config page */
.ports-page {
padding: 1.5rem;