minor: heavily simplify server and app content. unify styling (#4)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
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:
12
app/composables/useAutoCloseLiveSession.js
Normal file
12
app/composables/useAutoCloseLiveSession.js
Normal 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 },
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user