407 lines
14 KiB
Vue
407 lines
14 KiB
Vue
<template>
|
|
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6">
|
|
<div class="kestrel-card-modal w-full max-w-md p-6">
|
|
<h2 class="kestrel-section-heading mb-2">
|
|
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 40000-49999 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="kestrel-video-frame mb-4"
|
|
>
|
|
<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 }
|
|
const locationWatchId = ref(null)
|
|
const locationIntervalId = ref(null)
|
|
const device = ref(null)
|
|
const sendTransport = ref(null)
|
|
const producer = ref(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.value = await createMediasoupDevice(rtpCapabilities)
|
|
sendTransport.value = await createSendTransport(device.value, 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.value = await sendTransport.value.produce({ track: videoTrack })
|
|
// Monitor producer events
|
|
producer.value.on('transportclose', () => {
|
|
logWarn('share-live: Producer transport closed', {
|
|
producerId: producer.value.id,
|
|
producerPaused: producer.value.paused,
|
|
producerClosed: producer.value.closed,
|
|
})
|
|
})
|
|
producer.value.on('trackended', () => {
|
|
logWarn('share-live: Producer track ended', {
|
|
producerId: producer.value.id,
|
|
producerPaused: producer.value.paused,
|
|
producerClosed: producer.value.closed,
|
|
})
|
|
})
|
|
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
|
|
sendTransport.value.on('connectionstatechange', () => {
|
|
const state = sendTransport.value.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.value.id,
|
|
producerId: producer.value.id,
|
|
})
|
|
if (state === 'failed') {
|
|
webrtcState.value = 'failed'
|
|
runFailureReasonCheck()
|
|
}
|
|
}
|
|
})
|
|
// Monitor track state
|
|
if (producer.value.track) {
|
|
producer.value.track.addEventListener('ended', () => {
|
|
logWarn('share-live: Producer track ended', {
|
|
producerId: producer.value.id,
|
|
trackId: producer.value.track.id,
|
|
trackReadyState: producer.value.track.readyState,
|
|
trackEnabled: producer.value.track.enabled,
|
|
trackMuted: producer.value.track.muted,
|
|
})
|
|
})
|
|
producer.value.track.addEventListener('mute', () => {
|
|
logWarn('share-live: Producer track muted', {
|
|
producerId: producer.value.id,
|
|
trackId: producer.value.track.id,
|
|
trackEnabled: producer.value.track.enabled,
|
|
trackMuted: producer.value.track.muted,
|
|
})
|
|
})
|
|
producer.value.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.value = 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.value != null) {
|
|
clearInterval(locationIntervalId.value)
|
|
locationIntervalId.value = 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.value = 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.value != null && navigator.geolocation?.clearWatch) {
|
|
navigator.geolocation.clearWatch(locationWatchId.value)
|
|
}
|
|
locationWatchId.value = null
|
|
if (locationIntervalId.value != null) {
|
|
clearInterval(locationIntervalId.value)
|
|
}
|
|
locationIntervalId.value = null
|
|
if (producer.value) {
|
|
producer.value.close()
|
|
producer.value = null
|
|
}
|
|
if (sendTransport.value) {
|
|
sendTransport.value.close()
|
|
sendTransport.value = null
|
|
}
|
|
device.value = 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>
|