diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 8623856..951beef 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -622,6 +622,9 @@ > {{ isRecording ? 'Transmitting' : 'Hold to transmit' }}

+

+ Hotkey armed +

{{ frequencies.active || '---' }}

Active frequency

@@ -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 | 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() }) diff --git a/server/api/bridge/ptt.post.ts b/server/api/bridge/ptt.post.ts new file mode 100644 index 0000000..e956a35 --- /dev/null +++ b/server/api/bridge/ptt.post.ts @@ -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(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 } +}) diff --git a/server/api/bridge/ws.ts b/server/api/bridge/ws.ts new file mode 100644 index 0000000..4cbb362 --- /dev/null +++ b/server/api/bridge/ws.ts @@ -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>() +// peerId → token, so close() can clean up without scanning every set +const peerTokens = new Map() + +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) + }, +}) diff --git a/server/utils/pttBus.ts b/server/utils/pttBus.ts new file mode 100644 index 0000000..08f7e11 --- /dev/null +++ b/server/utils/pttBus.ts @@ -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() + + 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()