hotkey support

This commit is contained in:
itsrubberduck
2026-06-20 03:17:53 +02:00
parent 6603677214
commit d9e9be2b54
4 changed files with 241 additions and 0 deletions

View File

@@ -622,6 +622,9 @@
>
{{ isRecording ? 'Transmitting' : 'Hold to transmit' }}
</p>
<p v-if="bridgePttConnected" class="text-[10px] uppercase tracking-[0.25em] text-cyan-300/70">
Hotkey armed
</p>
<p class="pt-2 text-4xl font-bold font-mono tracking-tight">{{ frequencies.active || '---' }}</p>
<p class="text-xs text-white/45">Active frequency</p>
</div>
@@ -4899,8 +4902,90 @@ function stopBridgeSync() {
}
}
// --- Remote push-to-talk over the bridge link --------------------------------
// The OpenSquawk Bridge captures a global hotkey on the PC and POSTs each edge
// to /api/bridge/ptt; the backend relays it here over WebSocket so PTT works
// while the sim (not this tab) is focused. We reuse the on-screen pad's
// startRecording/stopRecording, so behaviour is identical to holding the pad.
let pttSocket: WebSocket | null = null
let pttReconnectTimer: ReturnType<typeof setTimeout> | null = null
let pttClosedByUs = false
const bridgePttConnected = ref(false)
async function handleRemotePtt(state: 'down' | 'up') {
if (state === 'down') {
// A backgrounded tab can suspend the prerec AudioContext; resume it so the
// ring buffer + live capture are running when the edge arrives from the sim.
if (prerecCtx && prerecCtx.state === 'suspended') {
try { await prerecCtx.resume() } catch {}
}
void startRecording(false)
} else {
stopRecording()
}
}
function schedulePttReconnect() {
if (pttReconnectTimer || pttClosedByUs) return
pttReconnectTimer = setTimeout(() => {
pttReconnectTimer = null
connectPttSocket()
}, 3_000)
}
function connectPttSocket() {
disconnectPttSocket()
const token = bridgeToken.value
if (!token || typeof window === 'undefined') return
pttClosedByUs = false
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${window.location.host}/api/bridge/ws`
let socket: WebSocket
try {
socket = new WebSocket(url)
} catch {
schedulePttReconnect()
return
}
pttSocket = socket
socket.onopen = () => {
bridgePttConnected.value = true
try { socket.send(JSON.stringify({ type: 'subscribe', token })) } catch {}
}
socket.onmessage = (ev) => {
let data: any
try { data = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) } catch { return }
if (data?.type === 'ptt' && (data.state === 'down' || data.state === 'up')) {
void handleRemotePtt(data.state)
}
}
socket.onclose = () => {
bridgePttConnected.value = false
if (pttSocket === socket) pttSocket = null
if (!pttClosedByUs) schedulePttReconnect()
}
socket.onerror = () => {
try { socket.close() } catch {}
}
}
function disconnectPttSocket() {
pttClosedByUs = true
if (pttReconnectTimer) {
clearTimeout(pttReconnectTimer)
pttReconnectTimer = null
}
if (pttSocket) {
try { pttSocket.close() } catch {}
pttSocket = null
}
bridgePttConnected.value = false
}
onMounted(() => {
startBridgeSync()
connectPttSocket()
})
watch(bridgeToken, () => {
@@ -4908,6 +4993,7 @@ watch(bridgeToken, () => {
bridgeSimActiveFreq.value = null
lastSyncedSimActive = null
startBridgeSync()
connectPttSocket()
})
onUnmounted(() => {
@@ -4915,6 +5001,7 @@ onUnmounted(() => {
stopPrerecCapture()
cancelAirportDataRefresh()
stopBridgeSync()
disconnectPttSocket()
})
</script>

View File

@@ -0,0 +1,46 @@
import { createError, readBody } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import { logBridgeEvent } from '../../utils/bridgeLog'
import { pttBus, type PttState } from '../../utils/pttBus'
interface PttBody {
state?: PttState
}
export default defineEventHandler(async (event) => {
const token = getBridgeTokenFromHeader(event)
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
const body = await readBody<PttBody>(event)
const state = body?.state
if (state !== 'down' && state !== 'up') {
throw createError({ statusCode: 400, statusMessage: "state muss 'down' oder 'up' sein." })
}
// Only relay for a linked token; an unknown/unlinked token is a no-op so the
// bus is never driven by an unauthenticated caller.
const exists = await BridgeToken.exists({ token })
if (!exists) {
throw createError({ statusCode: 404, statusMessage: 'Bridge-Token ist nicht verknüpft.' })
}
console.info(
`\x1b[33m[bridge:ptt]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m state=\x1b[92m${state}\x1b[0m`,
)
pttBus.publish(token, state)
logBridgeEvent(token, {
endpoint: '/api/bridge/ptt',
method: 'POST',
statusCode: 200,
color: '#eab308',
summary: `ptt=${state}`,
data: { state },
})
return { ok: true, state }
})

77
server/api/bridge/ws.ts Normal file
View File

@@ -0,0 +1,77 @@
// server/api/bridge/ws.ts
//
// Low-latency push channel for push-to-talk. A /pm tab opens this socket and
// sends { type: 'subscribe', token } using its `?token=` bridge link. The
// Bridge POSTs key edges to /api/bridge/ptt, which drives pttBus; we relay each
// edge to every peer subscribed to that token.
import { defineWebSocketHandler } from 'h3'
import { normalizeBridgeToken } from '../../utils/bridge'
import { pttBus } from '../../utils/pttBus'
// token → set of connected /pm peers
const subscribers = new Map<string, Set<any>>()
// peerId → token, so close() can clean up without scanning every set
const peerTokens = new Map<string, string>()
function peerId(peer: any): string {
return peer?.id ?? String(peer)
}
// Relay every PTT edge to the peers listening on that token.
pttBus.subscribe((token, state) => {
const peers = subscribers.get(token)
if (!peers) return
const payload = JSON.stringify({ type: 'ptt', state })
for (const peer of peers) {
try { peer.send(payload) } catch {}
}
})
function unsubscribe(peer: any) {
const id = peerId(peer)
const token = peerTokens.get(id)
if (!token) return
peerTokens.delete(id)
const peers = subscribers.get(token)
if (!peers) return
peers.delete(peer)
if (peers.size === 0) subscribers.delete(token)
}
export default defineWebSocketHandler({
message(peer, msg) {
let data: any
try {
data = JSON.parse(typeof msg === 'string' ? msg : msg.toString())
} catch {
peer.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }))
return
}
if (data.type === 'subscribe') {
const token = normalizeBridgeToken(data.token)
if (!token) {
peer.send(JSON.stringify({ type: 'error', message: 'Invalid token' }))
return
}
// A peer only ever listens to one token; drop any previous binding.
unsubscribe(peer)
let peers = subscribers.get(token)
if (!peers) {
peers = new Set()
subscribers.set(token, peers)
}
peers.add(peer)
peerTokens.set(peerId(peer), token)
peer.send(JSON.stringify({ type: 'subscribed' }))
}
},
close(peer) {
unsubscribe(peer)
},
error(peer) {
unsubscribe(peer)
},
})

31
server/utils/pttBus.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* In-memory push-to-talk event bus.
*
* Flow: Bridge → POST /api/bridge/ptt → pttBus → WS (/api/bridge/ws) → /pm
*
* Keyed by bridge token: the Bridge POSTs with its token, and the /pm tab
* subscribes over WebSocket with the same token (from its `?token=` link), so
* an edge is delivered only to the matching browser.
*/
export type PttState = 'down' | 'up'
type PttListener = (token: string, state: PttState) => void
class PttBus {
private listeners = new Set<PttListener>()
publish(token: string, state: PttState) {
for (const listener of this.listeners) {
try { listener(token, state) } catch {}
}
}
subscribe(listener: PttListener) {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
}
// Singleton — shared across all server handlers
export const pttBus = new PttBus()