This page covers the Dashboard’s shared client-side plumbing: the reactive state hub every component reads and writes (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: a private readonly ...Subject = new BehaviorSubject<T>(...), a public ...$ observable, and imperative set…() / get…() accessors. The main groups:
GroupKey observables / accessorsNotes
Selected droneselectedDrone$, getSelectedDrone(), setSelectedDrone(), clearSelectedDrone()Persists drone.id to localStorage['selected_drone_id']
Flight statecurrentMode$ (default 'AUTO'), currentDronePosition$, goToPosition$currentDronePosition$ is { droneId, position }
Edit modeseditMissionMode$, editGeofenceMode$Mutually exclusive — see below
Mission / geofencecurrentMission$, geofences$, currentGeofence$Fed by MissionService / GeofenceService
Control flagsgamepadActive$, manualControlType$ ('real' | 'virtual' | null), guidedControlEnabled$, controlVehicleMode$, dockingMode$, followVehicleMode$, videoFullscreen$followVehicleMode persisted to localStorage
Input pipelinecontrollerData$, setControllerData(), getControllerData()Outbound command bus (see note)
Angular signalssystemStatus = signal(''), altitude = signal(0)The only two fields that are signals, not subjects
Dialog visibilitydozens of …ConfirmationDialogVisible$ + pending…Drone holdersSee “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
setEditMissionMode(enabled: boolean) {
  if (enabled) this.editGeofenceModeSubject.next(false); // close geofence edit
  this.editMissionModeSubject.next(enabled);
}
setEditGeofenceMode(enabled: boolean) {
  if (enabled) this.editMissionModeSubject.next(false); // close mission edit
  this.editGeofenceModeSubject.next(enabled);
}
Preserve this invariant when refactoring — 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 a BehaviorSubject, kept in sync only through the setter:
src/app/services/app-state.service.ts
setArmConfirmationDialogVisible(visible: boolean): void {
  this.armConfirmationDialogVisible = visible;              // plain mirror
  this.armConfirmationDialogVisibleSubject.next(visible);  // reactive source
}
Mutating appState.armConfirmationDialogVisible = true directly (e.g. from a template two-way binding) updates the boolean but not the subject, silently desyncing subscribers. Always go through the set…() accessor. The eight action dialogs that target a specific drone (mode, restart-FC, delete-drone, dock, dock-stop, takeoff, motor-test, toggle-lights) also carry a non-reactive pending…Drone holder (a plain private field) that stores which drone the pending action targets — clear it via clearPending…Drone() when the dialog resolves. The arm, land, disconnect, mission, and manual-control dialogs have no such holder.
AppStateService declares an index signature [x: string]: any on line 9. This makes any property access type-check, which is why typos on state fields compile cleanly. Treat the typed set…()/get…() methods as the real API surface.

localStorage persistence

Only two pieces of state survive reloads, written directly by the service:
KeyWritten byCleared by
selected_drone_idsetSelectedDrone(drone) (when drone.id present)clearSelectedDrone()
follow_vehicle_modesetFollowVehicleMode(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
getSubject(key: string): Subject<any> {
  if (!this.subjectMap[key]) this.subjectMap[key] = new Subject<any>();
  return this.subjectMap[key];
}
setValue(key: string, value: any) { this.getSubject(key).next(value); }
getValue(key: string): Observable<any> { return this.getSubject(key).asObservable(); }
It doubles as the fleet-list cache: 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 returns cloneModel(cachedModel) (.clone(true)) instantly. Concurrent callers share one in-flight loadPromise.
  • Level 2 — IndexedDB: database SkyHubModelCache, store models, key observer_drone_model. The raw ArrayBuffer is persisted so a page reload skips the network entirely.
  • Level 3 — network: downloadModelAsArrayBuffer() does a raw XMLHttpRequest to the hardcoded S3 URL https://skyhub-3d-assets.s3.eu-central-1.amazonaws.com/observer.glb, reporting event.loaded / event.total into progress$. 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).
The observer.glb URL and its bucket (skyhub-3d-assets) are hardcoded in the service, not read from environment.assetsUrl (which points at skyhub-prod-assets/drone for image/HLS media). They are two different S3 buckets. three and three/examples/jsm/loaders/GLTFLoader.js are dynamically imported (await import(...)) with a type-only static import so they stay out of the initial JS bundle — keep that pattern.
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:
FieldValue (default env)Purpose
janusGatewayUrlwss://prod.skyhub.ai:8188Janus signaling WebSocket (server)
janusIceServers['stun:stun.l.google.com:19302']ICE/STUN for WebRTC
The service’s 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 as ptype: 'publisher' — this is only to enumerate existing publishers. On the joined message it calls extractStreams() then subscribeTo(), which attaches a second handle joined as ptype: '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 single MediaStream, addTracks each incoming track, and calls Janus.attachMediaStream(videoElement, stream) on the <video> element the component assigned to janusService.videoElement. It then pushes { available: true, error: null } to videoStreamStatus$.
  • Status stream. videoStreamStatus$ ({ available, error }) is how VideoWindowComponent clears 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 subscriptions disabled and sends an update unsubscribe, stops every MediaStream track, 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.
Starting/stopping the on-drone stream is a separate REST call, not a Janus operation — 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:
1

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).
2

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).
3

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).
Live and recorded video use completely different transports and must not be conflated: live = WebRTC via JanusService (port 8188), recorded = HLS m3u8 text fetched over the REST API and played by hls.js. hls.js (like three) is dynamically imported to stay out of the initial bundle.
The asset REST surface and how the gateway segments MP4→HLS in S3 is documented in S3 Assets, HLS Video & Execution Archives and Executions, Assets & Reports API.

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.