AppStateService), the lightweight cross-component pub/sub bus (DroneSubscriptionService), the 3-tier 3D-model cache (ModelCacheService), and the video subsystem — live WebRTC over Janus (JanusService) plus recorded HLS playback via hls.js.
These are the glue services that let ~30 injectable services and a 3,500-line HomeComponent share state without prop-drilling, and that keep the map’s 3D model and both video paths off the critical bundle/render path.
Live telemetry ingress (
WebsocketService), the redispad command channel and gamepad input (VehicleCommandService / GamepadService), and the Janus room lifecycle on the backend are covered elsewhere. See Real-time Telemetry Client, Vehicle Commands & Gamepad, and the gateway’s Janus Video Rooms. This page is the frontend state + video consumer side.AppStateService — the reactive state hub
src/app/services/app-state.service.ts is a root-provided singleton and the central RxJS store. It holds domain state (selected drone, current flight mode, current mission/geofence), UI edit/control flags (edit modes, gamepad/guided-control/follow/docking/fullscreen), the gamepad input pipeline, and every confirmation-dialog visibility flag in the app. It is intentionally a god-object (~620 lines) so that any component can read or drive shared UI state from one injection.
Shape of the store
Almost every field follows the same pattern: aprivate readonly ...Subject = new BehaviorSubject<T>(...), a public ...$ observable, and imperative set…() / get…() accessors. The main groups:
| Group | Key observables / accessors | Notes |
|---|---|---|
| Selected drone | selectedDrone$, getSelectedDrone(), setSelectedDrone(), clearSelectedDrone() | Persists drone.id to localStorage['selected_drone_id'] |
| Flight state | currentMode$ (default 'AUTO'), currentDronePosition$, goToPosition$ | currentDronePosition$ is { droneId, position } |
| Edit modes | editMissionMode$, editGeofenceMode$ | Mutually exclusive — see below |
| Mission / geofence | currentMission$, geofences$, currentGeofence$ | Fed by MissionService / GeofenceService |
| Control flags | gamepadActive$, manualControlType$ ('real' | 'virtual' | null), guidedControlEnabled$, controlVehicleMode$, dockingMode$, followVehicleMode$, videoFullscreen$ | followVehicleMode persisted to localStorage |
| Input pipeline | controllerData$, setControllerData(), getControllerData() | Outbound command bus (see note) |
| Angular signals | systemStatus = signal(''), altitude = signal(0) | The only two fields that are signals, not subjects |
| Dialog visibility | dozens of …ConfirmationDialogVisible$ + pending…Drone holders | See “dialog flag pattern” |
controllerData$ is also the outbound command bus
controllerData$ is not only fed by GamepadService at 60 Hz. VideoWindowComponent pushes gimbal button frames and camera_command payloads through the same setControllerData() — e.g. sendGimbalUp() emits { buttons: { '12': 1 }, axes: [], timestamp, front_ts } and sendCameraCommand() emits { type: 'camera_command', command: 'zoom_set', … } (src/app/home/video-window/video-window.component.ts). ControllerDataSenderService subscribes to controllerData$ and forwards frames over the redispad WebSocket. setControllerData() deliberately always forwards (system commands and gamepad frames alike), so any component that needs to reach the vehicle’s low-latency channel can emit through this one subject. Full command routing is documented in Vehicle Commands & Gamepad.
Mutually-exclusive edit modes
Opening one map-edit mode closes the other, enforced in the setters:src/app/services/app-state.service.ts
HomeComponent’s map-click dispatch (onMapClickAddMarker) branches on these flags, and both being true would route a click to two handlers.
The confirmation-dialog flag pattern (gotcha)
Each confirmation dialog (arm, land, takeoff, mode, motor-test, dock, dock-stop, delete-drone, restart-FC, toggle-lights, mission, manual-control, disconnect) is stored as both a public plain boolean and aBehaviorSubject, kept in sync only through the setter:
src/app/services/app-state.service.ts
localStorage persistence
Only two pieces of state survive reloads, written directly by the service:| Key | Written by | Cleared by |
|---|---|---|
selected_drone_id | setSelectedDrone(drone) (when drone.id present) | clearSelectedDrone() |
follow_vehicle_mode | setFollowVehicleMode(enabled) | clearFollowVehicleMode() |
follow_vehicle_mode is re-read into followVehicleMode$ in the constructor via initializeFollowVehicleMode(). Token keys (accessToken, refreshToken) and the billing cache live in other services — see Authentication.
DroneSubscriptionService — generic pub/sub bus + fleet cache
src/app/services/drone-subscription.service.ts is a tiny keyed event bus used to share values that don’t warrant a dedicated AppStateService field. It lazily creates a plain Subject per string key:
src/app/services/drone-subscription.service.ts
setCurrentDroneList(drones) stores the array and publishes it on the 'currentDroneList' key, getCurrentDroneList() returns that stream, and getCurrentDroneName(id) / getCurrentDroneType(id) resolve display metadata by id without another HTTP round-trip. updateDroneInList(updatedDrone) patches a single entry and re-emits a new array reference so OnPush consumers detect the change.
Unlike
AppStateService’s BehaviorSubjects, these are plain Subjects — a late subscriber gets no replay of the last value. Only currentDroneList is also cached in a field (currentDroneList) for synchronous name/type lookups.ModelCacheService — 3-tier GLB caching
src/app/services/model-cache.service.ts loads the shared observer.glb drone model once and reuses it everywhere the 3D viewer appears (e.g. ThreeModelViewerComponent, and indirectly the map markers). It is a cost-avoidance layer: the GLB download is large, GLTFLoader parsing is expensive, and browsers cap live WebGL contexts, so the model is cached at three levels and cloned per use.
- Level 1 — memory:
cachedModel: THREE.Group. A hit returnscloneModel(cachedModel)(.clone(true)) instantly. Concurrent callers share one in-flightloadPromise. - Level 2 — IndexedDB: database
SkyHubModelCache, storemodels, keyobserver_drone_model. The rawArrayBufferis persisted so a page reload skips the network entirely. - Level 3 — network:
downloadModelAsArrayBuffer()does a rawXMLHttpRequestto the hardcoded S3 URLhttps://skyhub-3d-assets.s3.eu-central-1.amazonaws.com/observer.glb, reportingevent.loaded / event.totalintoprogress$. The buffer is then written to IndexedDB and parsed into memory.
progress$ (a BehaviorSubject<{ isLoading, progress }>) drives the app-wide progress bar — AppComponent subscribes to it (src/app/app.component.ts).
Disposal & lifecycle. clearCache() disposes the in-memory model and deletes the IndexedDB entry; it is called on logout in auth.service.ts (this.modelCacheService.clearCache()). disposeMaterial() manually disposes each texture because THREE.Material.dispose() does not release texture GPU memory. Callers that clone via getModel() own their clone and must dispose it themselves (see ThreeModelViewerComponent, which also calls forceContextLoss() on destroy).
JanusService — live WebRTC video
src/app/services/janus.service.ts subscribes the Dashboard to the drone’s live camera feed through a Janus VideoRoom SFU. The on-drone GStreamer pipeline publishes H264 to the WHIP server, which registers it as a publisher in the room; the Dashboard is a pure subscriber.
Configuration comes from src/environments/environment.ts:
| Field | Value (default env) | Purpose |
|---|---|---|
janusGatewayUrl | wss://prod.skyhub.ai:8188 | Janus signaling WebSocket (server) |
janusIceServers | ['stun:stun.l.google.com:19302'] | ICE/STUN for WebRTC |
roomId defaults to 1234 but is overwritten per drone: VideoWindowComponent sets janusService.roomId = drone.video_room_id and janusService.pin = drone.video_room_password before connecting (Drone.video_room_id / video_room_password, src/app/models/drone.model.ts).
Key behaviours:
- Publisher-then-subscriber bootstrap.
attachVideoRoomPlugin()first joins the room asptype: 'publisher'— this is only to enumerate existing publishers. On thejoinedmessage it callsextractStreams()thensubscribeTo(), which attaches a second handle joined asptype: 'subscriber'(createNewSubscription). This two-handle dance is the standard Janus VideoRoom subscribe flow; do not “simplify” it away. - Track accumulation.
handleRemoteTrack(track, mid, on)builds a singleMediaStream,addTracks each incoming track, and callsJanus.attachMediaStream(videoElement, stream)on the<video>element the component assigned tojanusService.videoElement. It then pushes{ available: true, error: null }tovideoStreamStatus$. - Status stream.
videoStreamStatus$({ available, error }) is howVideoWindowComponentclears its connecting spinner / shows the “Start Video” button. There is a 15 s fallback timeout in the component because the first track can lag the plugin attach. - Teardown.
disconnect()marks all subscriptionsdisabledand sends anupdateunsubscribe, stops everyMediaStreamtrack, and nulls the<video>srcObject(clearVideoElement) so the last frame is not retained.Janus.init({ debug: false })is deliberate —'all'logging leaks memory on long sessions.
The Janus session object (
new Janus({...})) is created and owned by VideoWindowComponent, not by JanusService. The component’s proactive teardown calls janus.destroy({ notifyDestroyed: false }) so the destroyed callback fires only on unexpected drops — and when it does, the component resets local video state instead of reloading the whole app (which would tear down the map, telemetry and re-download every asset). Janus itself is a global script (declare var Janus: any), not an ES import.DroneService.startVideoStream(id) issues GET /api/v1/video_room/{id}/start?update=true (also /stop and /restart). The ?update=true query param tells the gateway to reuse the existing room. Room creation and the WHIP→Janus wiring live in Janus Video Rooms and Janus WebRTC SFU. Gimbal, zoom and recording controls that share the video window are covered in Video, Gimbal & Manual Flight Control.
Recorded video — HLS playback
Recorded flight clips are served as HLS (m3u8) from S3, not over WebRTC. The flow is entirely REST +hls.js:
Fetch the playlist as text
AssetService.getVideoAssetByVehicleIdAndAssetId(vehicleId, assetId) calls GET /api/v1/assets/{vehicleId}/video/{assetId} with responseType: 'text', returning the raw m3u8 playlist string (src/app/services/asset.service.ts).Wrap it in a blob URL
A caller (
VideoListDialogComponent, ReportsDialogComponent) opens app-video-player-dialog with { filename, content: m3u8 }. The dialog builds new Blob([content], { type: 'application/x-mpegURL' }) and URL.createObjectURL(blob).Play via hls.js (lazy-loaded)
VideoPlayerDialogComponent dynamically import('hls.js'), then hls.loadSource(blobUrl) + hls.attachMedia(video) and plays on MANIFEST_PARSED. On Safari it falls back to native canPlayType('application/x-mpegURL'). The blob URL is revoked and hls.destroy() is called on destroy (src/app/home/dialogs/video-player-dialog/video-player-dialog.component.ts).Where to go next
Telemetry client
Socket.IO ingress (
WebsocketService) that feeds map + HUD state.Vehicle commands
How
controllerData$ frames reach the vehicle over redispad.Backend integration
The four transport channels and which env var configures each.
Video & flight control
The video window UI, gimbal, zoom and manual flight.

