Files
kestrelos/app/pages/share-live.vue
Madison Grubb b7046dc0e6 initial commit
2026-02-10 23:32:26 -05:00

407 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6">
<div class="w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_24px_-6px_rgba(34,201,201,0.2)]">
<h2 class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
Share live (camera + location)
</h2>
<p class="mb-4 text-sm text-kestrel-muted">
Use this page in Safari on your iPhone to stream your camera and location to the map. You'll appear as a live POI.
</p>
<div
v-if="!isSecureContext"
class="mb-4 rounded border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-amber-200"
>
<strong>HTTPS required.</strong> From your phone, camera and location only work over a secure connection. Open this app using an HTTPS URL (e.g. a tunnel like ngrok, or a server with an SSL certificate).
</div>
<!-- Status -->
<div class="mb-4 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text">
<p
v-if="status"
class="font-medium"
>
{{ status }}
</p>
<p
v-if="webrtcState === 'connecting'"
class="mt-1 text-kestrel-muted"
>
WebRTC: connecting…
</p>
<template v-if="webrtcState === 'failed'">
<p class="mt-1 font-medium text-red-400">
WebRTC: failed
</p>
<p
v-if="webrtcFailureReason?.wrongHost"
class="mt-1 text-amber-400"
>
Wrong host: server sees <strong>{{ webrtcFailureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ webrtcFailureReason.wrongHost.clientHostname }}</strong>. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP.
</p>
<ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP ports 4000049999 on the server.</li>
<li><strong>Wrong host:</strong> Server must see the same address you use (see above or open /api/live/debug-request-host).</li>
<li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li>
</ul>
</template>
<p
v-if="error"
class="mt-1 text-red-400"
>
{{ error }}
</p>
</div>
<!-- Local preview -->
<div
v-if="stream && videoRef"
class="relative mb-4 aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black"
>
<video
ref="videoRef"
autoplay
playsinline
muted
class="h-full w-full object-cover"
/>
<div
v-if="sharing"
class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400"
>
● Live — you appear on the map
</div>
</div>
<!-- Controls -->
<div class="flex flex-col gap-2">
<button
v-if="!sharing"
type="button"
class="w-full rounded bg-kestrel-accent px-4 py-3 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
:disabled="starting"
@click="startSharing"
>
{{ starting ? 'Starting' : 'Start sharing' }}
</button>
<button
v-else
type="button"
class="w-full rounded border border-red-400/60 bg-red-400/10 px-4 py-3 text-sm font-medium text-red-400 transition-opacity hover:bg-red-400/20"
@click="stopSharing"
>
Stop sharing
</button>
<NuxtLink
to="/"
class="block text-center text-sm text-kestrel-muted underline hover:text-kestrel-accent"
>
Back to map
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup>
import { createMediasoupDevice, createSendTransport } from '~/composables/useWebRTC.js'
import { getWebRTCFailureReason } from '~/composables/useWebRTCFailureReason.js'
import { initLogger, logError, logWarn } from '~/utils/logger.js'
import { useUser } from '~/composables/useUser.js'
definePageMeta({ layout: 'default' })
const { user } = useUser()
const videoRef = ref(null)
const stream = ref(null)
const sessionId = ref(null)
const status = ref('')
const error = ref('')
const sharing = ref(false)
const starting = ref(false)
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
let locationWatchId = null
let locationIntervalId = null
let device = null
let sendTransport = null
let producer = null
async function runFailureReasonCheck() {
webrtcFailureReason.value = await getWebRTCFailureReason()
}
function setStatus(msg) {
status.value = msg
error.value = ''
}
function setError(msg) {
error.value = msg
}
async function startSharing() {
starting.value = true
setStatus('Requesting camera and location…')
setError('')
try {
// 1. Start live session on server
const session = await $fetch('/api/live/start', {
method: 'POST',
body: {},
})
sessionId.value = session.id
// Initialize logger with session and user context
initLogger(session.id, user.value?.id)
setStatus('Session started. Requesting camera…')
// 2. Get camera if available (requires HTTPS on mobile Safari)
const hasMediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices != null
if (!hasMediaDevices) {
setError('Media devices not available. HTTPS required.')
cleanup()
return
}
let mediaStream = null
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
})
stream.value = mediaStream
if (videoRef.value) {
videoRef.value.srcObject = mediaStream
}
setStatus('Camera on. Setting up WebRTC…')
}
catch {
setError('Camera denied or unavailable. Allow camera access in browser settings.')
cleanup()
return
}
// 3. Initialize Mediasoup device and create WebRTC transport
try {
webrtcState.value = 'connecting'
webrtcFailureReason.value = null
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
credentials: 'include',
})
device = await createMediasoupDevice(rtpCapabilities)
sendTransport = await createSendTransport(device, sessionId.value, {
onConnectSuccess: () => { webrtcState.value = 'connected' },
onConnectFailure: () => {
webrtcState.value = 'failed'
runFailureReasonCheck()
},
})
// 4. Produce video track
const videoTrack = mediaStream.getVideoTracks()[0]
if (!videoTrack) {
throw new Error('No video track available')
}
producer = await sendTransport.produce({ track: videoTrack })
// Monitor producer events
producer.on('transportclose', () => {
logWarn('share-live: Producer transport closed', {
producerId: producer.id,
producerPaused: producer.paused,
producerClosed: producer.closed,
})
})
producer.on('trackended', () => {
logWarn('share-live: Producer track ended', {
producerId: producer.id,
producerPaused: producer.paused,
producerClosed: producer.closed,
})
})
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
sendTransport.on('connectionstatechange', () => {
const state = sendTransport.connectionState
if (state === 'connected') webrtcState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('share-live: Send transport connection state changed', {
state,
transportId: sendTransport.id,
producerId: producer.id,
})
if (state === 'failed') {
webrtcState.value = 'failed'
runFailureReasonCheck()
}
}
})
// Monitor track state
if (producer.track) {
producer.track.addEventListener('ended', () => {
logWarn('share-live: Producer track ended', {
producerId: producer.id,
trackId: producer.track.id,
trackReadyState: producer.track.readyState,
trackEnabled: producer.track.enabled,
trackMuted: producer.track.muted,
})
})
producer.track.addEventListener('mute', () => {
logWarn('share-live: Producer track muted', {
producerId: producer.id,
trackId: producer.track.id,
trackEnabled: producer.track.enabled,
trackMuted: producer.track.muted,
})
})
producer.track.addEventListener('unmute', () => {})
}
webrtcState.value = 'connected'
setStatus('WebRTC connected. Requesting location…')
}
catch (webrtcErr) {
logError('share-live: WebRTC setup error', { err: webrtcErr.message || String(webrtcErr), stack: webrtcErr.stack })
webrtcState.value = 'failed'
runFailureReasonCheck()
setError('Failed to set up video stream: ' + (webrtcErr.message || String(webrtcErr)))
cleanup()
return
}
// 5. Get location (continuous) — also requires HTTPS on mobile Safari
if (!navigator.geolocation) {
setError('Geolocation not supported in this browser.')
cleanup()
return
}
try {
await new Promise((resolve, reject) => {
locationWatchId = navigator.geolocation.watchPosition(
(pos) => {
resolve(pos)
},
(err) => {
reject(err)
},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 },
)
})
}
catch (locErr) {
const msg = locErr?.code === 1 || (locErr?.message && locErr.message.toLowerCase().includes('permission'))
? 'Camera and location require a secure connection (HTTPS) when using this page from your phone. Open this app via an HTTPS URL (e.g. use a tunnel or a server with SSL).'
: (locErr?.message || 'Location was denied or unavailable.')
setError(msg)
cleanup()
return
}
setStatus('Location enabled. Streaming live…')
sharing.value = true
starting.value = false
// 6. Send location updates periodically (video is handled by WebRTC)
let locationUpdate404Logged = false
const sendLocationUpdate = async () => {
if (!sessionId.value || !sharing.value) return
const id = sessionId.value
const pos = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
maximumAge: 2000,
timeout: 5000,
})
}).catch(() => null)
const lat = pos?.coords?.latitude
const lng = pos?.coords?.longitude
if (Number.isFinite(lat) && Number.isFinite(lng)) {
try {
await $fetch(`/api/live/${id}`, {
method: 'PATCH',
body: {
lat,
lng,
},
credentials: 'include',
})
}
catch (e) {
if (e?.statusCode === 404) {
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
locationIntervalId = null
}
sharing.value = false
if (!locationUpdate404Logged) {
locationUpdate404Logged = true
logWarn('share-live: Session ended (404), stopping location updates', { sessionId: id })
}
}
else {
logWarn('share-live: Live location update failed', { err: e.message || String(e) })
}
}
}
}
await sendLocationUpdate()
locationIntervalId = setInterval(sendLocationUpdate, 2000)
}
catch (e) {
starting.value = false
if (e?.message) setError(e.message)
else if (e?.name === 'NotAllowedError') setError('Camera or location was denied. Allow in Safari settings.')
else if (e?.name === 'NotFoundError') setError('No camera found.')
else setError('Failed to start: ' + String(e))
cleanup()
}
}
function cleanup() {
if (locationWatchId != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId)
}
locationWatchId = null
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
}
locationIntervalId = null
if (producer) {
producer.close()
producer = null
}
if (sendTransport) {
sendTransport.close()
sendTransport = null
}
device = null
if (stream.value) {
stream.value.getTracks().forEach(t => t.stop())
stream.value = null
}
if (sessionId.value) {
$fetch(`/api/live/${sessionId.value}`, { method: 'DELETE' }).catch(() => {})
sessionId.value = null
}
sharing.value = false
webrtcState.value = ''
webrtcFailureReason.value = null
}
async function stopSharing() {
setStatus('Stopping…')
cleanup()
setStatus('')
setError('')
}
onBeforeUnmount(() => {
cleanup()
})
</script>