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()