mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 09:16:26 +08:00
hotkey support
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
46
server/api/bridge/ptt.post.ts
Normal file
46
server/api/bridge/ptt.post.ts
Normal 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
77
server/api/bridge/ws.ts
Normal 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
31
server/utils/pttBus.ts
Normal 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()
|
||||
Reference in New Issue
Block a user