minor: heavily simplify server and app content. unify styling (#4)
All checks were successful
ci/woodpecker/push/push Pipeline was successful

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-02-14 04:52:18 +00:00
parent 1a143d2f8e
commit 17f28401ba
40 changed files with 595 additions and 933 deletions

View File

@@ -0,0 +1,12 @@
/** Auto-closes selectedCamera when the selected live session disappears from liveSessions. */
export function useAutoCloseLiveSession(selectedCamera, liveSessions) {
watch(
[() => selectedCamera.value, () => liveSessions.value],
([sel, sessions]) => {
if (!sel || typeof sel.hasStream === 'undefined') return
const stillActive = (sessions ?? []).some(s => s.id === sel.id)
if (!stillActive) selectedCamera.value = null
},
{ deep: true },
)
}

View File

@@ -1,16 +1,19 @@
/**
* Fetches devices + live sessions (unified cameras). Optionally polls when tab is visible.
*/
/** Fetches devices + live sessions; polls when tab visible. */
const POLL_MS = 1500
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
export function useCameras(options = {}) {
const { poll: enablePoll = true } = options
const { data, refresh } = useAsyncData(
'cameras',
() => $fetch('/api/cameras').catch(() => ({ devices: [], liveSessions: [] })),
{ default: () => ({ devices: [], liveSessions: [] }) },
() => $fetch('/api/cameras').catch(() => EMPTY_RESPONSE),
{ default: () => EMPTY_RESPONSE },
)
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
const pollInterval = ref(null)
function startPolling() {
if (!enablePoll || pollInterval.value) return
@@ -27,22 +30,11 @@ export function useCameras(options = {}) {
onMounted(() => {
if (typeof document === 'undefined') return
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
startPolling()
refresh()
}
else {
stopPolling()
}
document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
})
if (document.visibilityState === 'visible') startPolling()
})
onBeforeUnmount(stopPolling)
const devices = computed(() => data.value?.devices ?? [])
const liveSessions = computed(() => data.value?.liveSessions ?? [])
/** All cameras: devices first, then live sessions */
const cameras = computed(() => [...devices.value, ...liveSessions.value])
return { data, devices, liveSessions, cameras, refresh, startPolling, stopPolling }
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
}

View File

@@ -1,24 +1,12 @@
/**
* Fetches active live sessions (camera + location sharing) and refreshes on an interval.
* Only runs when the app is focused so we don't poll in the background.
*/
/** Fetches live sessions; polls when tab visible. */
const POLL_MS = 1500
export function useLiveSessions() {
const { data: sessions, refresh } = useAsyncData(
const { data: _sessions, refresh } = useAsyncData(
'live-sessions',
async () => {
try {
const result = await $fetch('/api/live')
if (process.env.NODE_ENV === 'development') {
console.log('[useLiveSessions] Fetched sessions:', result.map(s => ({
id: s.id,
label: s.label,
hasStream: s.hasStream,
})))
}
return result
return await $fetch('/api/live')
}
catch (err) {
const msg = err?.message ?? String(err)
@@ -30,14 +18,13 @@ export function useLiveSessions() {
{ default: () => [] },
)
const sessions = computed(() => Object.freeze([...(_sessions.value ?? [])]))
const pollInterval = ref(null)
function startPolling() {
if (pollInterval.value) return
refresh() // Fetch immediately so new sessions show without waiting for first interval
pollInterval.value = setInterval(() => {
refresh()
}, POLL_MS)
refresh()
pollInterval.value = setInterval(refresh, POLL_MS)
}
function stopPolling() {
@@ -49,21 +36,12 @@ export function useLiveSessions() {
onMounted(() => {
if (typeof document === 'undefined') return
const onFocus = () => startPolling()
const onBlur = () => stopPolling()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
onFocus()
refresh() // Fresh data when returning to tab
}
else onBlur()
document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
})
if (document.visibilityState === 'visible') startPolling()
})
onBeforeUnmount(stopPolling)
onBeforeUnmount(() => {
stopPolling()
})
return { sessions, refresh, startPolling, stopPolling }
return Object.freeze({ sessions, refresh, startPolling, stopPolling })
}

View File

@@ -1,3 +1,5 @@
const EDIT_ROLES = Object.freeze(['admin', 'leader'])
export function useUser() {
const requestFetch = useRequestFetch()
const { data: user, refresh } = useAsyncData(
@@ -5,7 +7,7 @@ export function useUser() {
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
{ default: () => null },
)
const canEditPois = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
const isAdmin = computed(() => user.value?.role === 'admin')
return { user, canEditPois, isAdmin, refresh }
return Object.freeze({ user, canEditPois, isAdmin, refresh })
}

View File

@@ -1,61 +1,26 @@
/**
* WebRTC composable for Mediasoup client operations.
* Handles device initialization, transport creation, and WebSocket signaling.
*/
/** WebRTC/Mediasoup client utilities. */
import { logError, logWarn } from '../utils/logger.js'
/**
* Initialize Mediasoup device from router RTP capabilities.
* @param {object} rtpCapabilities
* @returns {Promise<object>} Mediasoup device
*/
export async function createMediasoupDevice(rtpCapabilities) {
// Dynamically import mediasoup-client only in browser
if (typeof window === 'undefined') {
throw new TypeError('Mediasoup device can only be created in browser')
}
const FETCH_OPTS = { credentials: 'include' }
// Use dynamic import for mediasoup-client
export async function createMediasoupDevice(rtpCapabilities) {
if (typeof window === 'undefined') throw new TypeError('Mediasoup device can only be created in browser')
const { Device } = await import('mediasoup-client')
const device = new Device()
await device.load({ routerRtpCapabilities: rtpCapabilities })
return device
}
/**
* Create WebSocket connection for signaling.
* @param {string} url - WebSocket URL (e.g., 'ws://localhost:3000/ws')
* @returns {Promise<WebSocket>} WebSocket connection
*/
export function createWebSocketConnection(url) {
return new Promise((resolve, reject) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws`
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
resolve(ws)
}
ws.onerror = () => {
reject(new Error('WebSocket connection failed'))
}
ws.onclose = () => {
// Connection closed
}
ws.onopen = () => resolve(ws)
ws.onerror = () => reject(new Error('WebSocket connection failed'))
})
}
/**
* Send WebSocket message and wait for response.
* @param {WebSocket} ws
* @param {string} sessionId
* @param {string} type
* @param {object} data
* @returns {Promise<object>} Response message
*/
export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
return new Promise((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
@@ -95,41 +60,20 @@ export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
})
}
/**
* Create send transport (for publisher).
* @param {object} device
* @param {string} sessionId
* @param {{ onConnectSuccess?: () => void, onConnectFailure?: (err: Error) => void }} [options] - Optional callbacks when transport connect succeeds or fails.
* @returns {Promise<object>} Transport with send method
*/
export async function createSendTransport(device, sessionId, options = {}) {
const { onConnectSuccess, onConnectFailure } = options
// Create transport via HTTP API
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: true },
credentials: 'include',
})
const transport = device.createSendTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
function attachTransportHandlers(transport, transportParams, sessionId, label, { onConnectSuccess, onConnectFailure } = {}) {
transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
try {
await $fetch('/api/live/webrtc/connect-transport', {
method: 'POST',
body: { sessionId, transportId: transportParams.id, dtlsParameters },
credentials: 'include',
...FETCH_OPTS,
})
onConnectSuccess?.()
callback()
}
catch (err) {
logError('useWebRTC: Send transport connect failed', {
err: err.message || String(err),
logError(`useWebRTC: ${label} transport connect failed`, {
err: err?.message ?? String(err),
transportId: transportParams.id,
connectionState: transport.connectionState,
sessionId,
@@ -138,48 +82,50 @@ export async function createSendTransport(device, sessionId, options = {}) {
errback(err)
}
})
transport.on('connectionstatechange', () => {
const state = transport.connectionState
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('useWebRTC: Send transport connection state changed', {
state,
transportId: transportParams.id,
sessionId,
})
if (['failed', 'disconnected', 'closed'].includes(state)) {
logWarn(`useWebRTC: ${label} transport connection state changed`, { state, transportId: transportParams.id, sessionId })
}
})
}
export async function createSendTransport(device, sessionId, options = {}) {
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: true },
...FETCH_OPTS,
})
const transport = device.createSendTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
attachTransportHandlers(transport, transportParams, sessionId, 'Send', options)
transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
try {
const { id } = await $fetch('/api/live/webrtc/create-producer', {
method: 'POST',
body: { sessionId, transportId: transportParams.id, kind, rtpParameters },
credentials: 'include',
...FETCH_OPTS,
})
callback({ id })
}
catch (err) {
logError('useWebRTC: Producer creation failed', { err: err.message || String(err) })
logError('useWebRTC: Producer creation failed', { err: err?.message ?? String(err) })
errback(err)
}
})
return transport
}
/**
* Create receive transport (for viewer).
* @param {object} device
* @param {string} sessionId
* @returns {Promise<object>} Transport with consume method
*/
export async function createRecvTransport(device, sessionId) {
// Create transport via HTTP API
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: false },
credentials: 'include',
...FETCH_OPTS,
})
const transport = device.createRecvTransport({
id: transportParams.id,
@@ -187,55 +133,15 @@ export async function createRecvTransport(device, sessionId) {
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
// Set up connect handler (will be called by mediasoup-client when needed)
transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
try {
await $fetch('/api/live/webrtc/connect-transport', {
method: 'POST',
body: { sessionId, transportId: transportParams.id, dtlsParameters },
credentials: 'include',
})
callback()
}
catch (err) {
logError('useWebRTC: Recv transport connect failed', {
err: err.message || String(err),
transportId: transportParams.id,
connectionState: transport.connectionState,
sessionId,
})
errback(err)
}
})
transport.on('connectionstatechange', () => {
const state = transport.connectionState
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('useWebRTC: Recv transport connection state changed', {
state,
transportId: transportParams.id,
sessionId,
})
}
})
attachTransportHandlers(transport, transportParams, sessionId, 'Recv')
return transport
}
/**
* Consume producer's stream (for viewer).
* @param {object} transport
* @param {object} device
* @param {string} sessionId
* @returns {Promise<object>} Consumer with track
*/
export async function consumeProducer(transport, device, sessionId) {
const rtpCapabilities = device.rtpCapabilities
const consumerParams = await $fetch('/api/live/webrtc/create-consumer', {
method: 'POST',
body: { sessionId, transportId: transport.id, rtpCapabilities },
credentials: 'include',
body: { sessionId, transportId: transport.id, rtpCapabilities: device.rtpCapabilities },
...FETCH_OPTS,
})
const consumer = await transport.consume({
@@ -256,14 +162,6 @@ export async function consumeProducer(transport, device, sessionId) {
return consumer
}
/**
* Resolve when condition() returns truthy, or after timeoutMs (then resolve anyway).
* No mutable shared state; cleanup on first completion.
* @param {() => unknown} condition
* @param {number} timeoutMs
* @param {number} intervalMs
* @returns {Promise<void>}
*/
function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
@@ -285,12 +183,6 @@ function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
})
}
/**
* Wait for transport connection state to reach a terminal state or timeout.
* @param {object} transport - Mediasoup transport with connectionState and on/off
* @param {number} timeoutMs
* @returns {Promise<string>} Final connection state
*/
export function waitForConnectionState(transport, timeoutMs = 10000) {
const terminal = ['connected', 'failed', 'disconnected', 'closed']
return new Promise((resolve) => {

View File

@@ -1,18 +1,13 @@
/**
* Fetch WebRTC failure reason (e.g. wrong host). Pure: same inputs → same output.
* @returns {Promise<{ wrongHost: { serverHostname: string, clientHostname: string } | null }>} Failure reason or null.
*/
/** Pure: fetches WebRTC failure reason (e.g. wrong host). Returns frozen object. */
export async function getWebRTCFailureReason() {
try {
const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' })
const clientHostname = typeof window !== 'undefined' ? window.location.hostname : ''
const serverHostname = res?.hostname ?? ''
if (serverHostname && clientHostname && serverHostname !== clientHostname) {
return { wrongHost: { serverHostname, clientHostname } }
return Object.freeze({ wrongHost: Object.freeze({ serverHostname, clientHostname }) })
}
}
catch {
// ignore
}
return { wrongHost: null }
catch { /* ignore */ }
return Object.freeze({ wrongHost: null })
}