View Inventory

Most views live at the top of frontend/ and are built once, on first visit or during prewarming, then retained by the page cache. The dashboard editor lives under dashboards/editor/ and is destroyed when popped (see the page cache). The table lists what each one declares and where it is opened from; "required" means required property, which cachedPage() satisfies by always passing stackView and mainWindow. Because instances are long-lived, per-visit work belongs in StackView.onActivated, not Component.onCompleted.

ViewobjectNameProperties it takesReached from
MainMenumainMenustackView, mainWindow, windowWidth, windowHeight (plain)StackView.initialItem; BottomBar Home normally pops back to it; an open wiki reader can require a cached Home above Settings
MediaRoommediaRoomstackView, mainWindow (plain)BottomBar Media, MainMenu media panel, MediaPlayer row tap
MediaPlayermediaPlayerstackView, mainWindow (required)BottomBar Media (toggles with MediaRoom), MainMenu media panel
OBDHomeobdHomestackView, mainWindow (required)BottomBar OBD
OBDMenuobdMenustackView, mainWindow (required)OBDHome "Dashboards" card, MainMenu OBD panel, BottomBar OBD via lastOBDPage
OBDDiagnosticsobdDiagnosticsstackView, mainWindow (required)OBDHome "Trouble Codes" card, BottomBar OBD via lastOBDPage
TripHistoryobdTripHistorystackView, mainWindow (required)OBDHome "Trip History" card, Black Box settings card, BottomBar OBD via lastOBDPage
SensorHomesensorHomestackView, mainWindow (required)BottomBar Sensor button
SensorMenusensorMenustackView, mainWindow (required)SensorHome "Sensors" card, BottomBar Sensor via lastSensorPage
CarMenucarMenustackView (required), mainWindow (plain)SensorHome "3D View" card, BottomBar Sensor via lastSensorPage
ClockMenu—stackView, mainWindow (required)BottomBar clock; navigateTo("ClockMenu.qml") (command server / screenshot sweeper)
PhoneMirrorViewphoneMirrorViewstackView, mainWindow (plain)BottomBar Phone Mirror (desktop only)
AndroidAutoView—stackView, mainWindow (plain)BottomBar Android Auto (desktop only)
AirPlayView—stackView, mainWindow (plain)Opens by itself when an iPhone starts mirroring; Phone Mirror's "Using an iPhone?" button; the phone button while an iPhone mirrors
SettingsMenusettingsMenustackView, mainWindow, initialSection (required)BottomBar Settings, navigateToSettings()
DashboardEditor (dashboards/editor/)—stackView, callerPage, optional editingId / draftJsonOBDMenu chooser (+ New, Edit, ▲ Draft); see Gauges & Dashboards

Every view uses the OBD or media colour tokens from Style and the scaled sizes from Spacing, and every one carries the local dp() / dpMin() shim described in GUI Scale.

frontend/MainMenu.qml is the home screen and the initialItem of the StackView: "Media on left (1/3), OBD on right (2/3)". It imports QtQuick.Controls.Basic, Qt5Compat.GraphicalEffects and QtQuick.Effects — the only file besides MediaRoom that uses both effect modules.

Layout

RowLayout {
    anchors.fill: parent; anchors.margins: dp(10); spacing: dp(10)

    // LEFT: media (Layout.preferredWidth: parent.width * 0.33, clip: true)
    Rectangle { id: mediaSection ... }

    // RIGHT: OBD (Layout.fillWidth: true, 2 px accent border)
    Rectangle { HomeOBDView { anchors.fill: parent; anchors.margins: dp(5) } }
}

First-run prompts

Each panel asks for its connection while it has nothing to show, so a fresh install is one tap from a car and music. While obdManager.connectionState is not Connected, the OBD panel hides HomeOBDView (dropping its poll demand) and shows "No vehicle connected" with OBDConnectButton (homeOBDConnectButton), which opens the adapter sheet; a tap elsewhere on the panel still opens OBDMenu. While the active playlist is empty and no phone has the media controls (musicEmpty: get_current_song_list() at load, then mediaListChanged), the media panel swaps its art and controls for "No music yet" with Browse Music (a FolderDialog into save_media_folder_url(), same rules and error wording as Media Room's Browse) and, when phone mirroring or AirPlay is on, Connect Phone (PhoneMirrorView, else AirPlayView). The panel's tap-to-open-player is off meanwhile. Object names for the dev tools: homeOBDPrompt, homeMusicPrompt, homeMusicBrowseButton, homeConnectPhoneButton.

Media panel

  • Blurred album art background — an Image of the current art with layer.effect: MultiEffect { blurEnabled: true; blurMax: 64; blur: min(1, settingsManager.backgroundBlurRadius / 64) } under a #C0000000 overlay; the panel border is drawn by a separate border-only rectangle at z: 10 so it stays crisp above the blur.
  • Foreground album art with the file's single DropShadow (offset 4/4, radius 12, 25 samples).
  • Scrolling title and "artist • album" — two copies of the Flickable + 3 s Timer + 5 s InOutQuad NumberAnimation ping-pong on contentX, running only while the text is wider than its container.
  • Seek slider — a custom Slider (accent fill, round handle, userSeeking flag) that follows nowPlaying.positionMs (a Binding paused while userSeeking) and on release calls nowPlaying.seek(), flanked by position/duration labels bound to the same values.
  • Either source — currentArt/Title/Artist/Album bind nowPlaying, which follows the phone while it has the media controls (see phone media) and local media otherwise; the page only picks the wording ("Phone" / "Nothing playing" for a phone with no media app, "Select a song" with nothing at all). Missing art falls back to assets/missing_art.png. No timers or Connections: the phone's position clock is in nowPlaying.
  • Tap to open the player — a TapHandler, not a MouseArea, pushes MediaPlayer.qml or MediaRoom.qml according to settingsManager.musicButtonDefaultPage. The comment explains: the layout is full of a Flickable title, a Flickable metadata row and a Slider, "all of which consume MouseArea clicks. TapHandler observes taps without interfering with those drag interactions".

OBD panel

Hosts HomeOBDView; a MouseArea over the whole panel calls stackView.openPage("OBDMenu.qml") — straight to the gauges, bypassing OBDHome. Component.onCompleted and onHomeOBDParametersChanged call obdManager.refresh_values() so the cards are populated immediately.

Backend managers used

nowPlaying (everything about the playing track), settingsManager, obdManager (through HomeOBDView only settingsManager). No clock is drawn on this page; the clock lives in the BottomBar.

MediaRoom.qml

frontend/MediaRoom.qml is the "now playing" view: large album art in an AlbumArtCarousel, transport controls, seek bar, an optional FFT visualizer and a blurred art background. Properties stackView / mainWindow are plain, so the BottomBar can push it with stackView alone.

Key features

  • Artwork and playback devices — Album art mode uses the carousel below. The Music Player Object picker also offers Record player, MP3 player, CD player, Cassette player and iPod through MusicDevicePreference; these use MusicDeviceScene when Quick3D is available. See Music Device Components.
  • Carousel — bound to the settings albumArtTransition (ten styles: Crossfade, Slide, Vinyl Lift, Dissolve, Flip, Coverflow, Conveyor, Stack, Depth, Swing), show3DAlbumPreview, roundedAlbumArt, albumArtCornerRadius, vinylRecordMode, sideCardAngle, sideCardOpacity, showAlbumArtShadow. onArtClicked opens albumArtPopup; onArtLoadFailed swaps in missing_art.png.
  • Blurred background — a Grid of repeated art tiles (backgroundGrid = "Normal" 1×1, "2x2", "4x4") with one MultiEffect blur applied to the whole grid ("16x cheaper on 4x4"), blurMax: 64, blur: backgroundBlurRadius / 64, layer.live: !_cardAnimBusy so the blur freezes during card animation, autoPaddingEnabled: false so the halo cannot bleed under the BottomBar (the StackView does not clip), and a black overlay at backgroundOverlayOpacity / 100.
  • Scrolling metadata — title and "artist • album" marquees whose scroll duration is settingsManager.textScrollSpeed (default 5000 ms).
  • Transport — Previous / Play-Pause / Next Controls with an X-axis "3D tilt" on press (show3DButtonTilt, buttonTiltDuration; 15° and scale 0.9 for prev/next, 25° and 0.85 for play), a Shuffle button bottom-left, a volume slider with mute icon at the top (routed through volumeController.applyVolume()) and a "Phone" source pill, shown only while the phone has the controls, whose tap calls phoneMirrorManager.releaseMedia(true) to hand them back to local files. Shuffle is dimmed while the phone has the controls (the phone's shuffle belongs to its app). There is no repeat button.
  • Seek bar — 75 % wide, follows nowPlaying.positionMs through a Binding paused while dragging (and while the page is hidden: it stays alive off-screen, and 10 Hz position updates cost ~9 % of a Pi core for nothing on screen), and commits once on release with nowPlaying.seek() (it used to seek twice, from the touch area and the slider).
  • Either source — title, artist, album, art, play state, duration and position come from nowPlaying; the controls call its slots. A track change made on the phone animates the carousel from nowPlaying.trackChanged (not after a source switch); local changes animate from mediaManager.currentMediaChanged, which also drives the carousel's bookkeeping. The "Phone" pill calls phoneMirrorManager.releaseMedia(true).
  • Waveform visualizer — visible when showWaveformVisualizer && !playlistEmpty; audioAnalyzer.numBars bars (fallback 96) as pill-shaped gradient rectangles in a Row with layer.enabled so 96 gradient draws collapse into one texture blit; fed by onFftDataChanged(levels), the analyzer follows what is playing by itself (file analysis at the playback position, or live PCM for the phone); the page only calls audioAnalyzer.setViewActive(visible && !_cardAnimBusy). Both feed the same bars through fftDataChanged. See Audio Analyzer.
  • Empty playlist — playlistEmpty = !usePhone && localSongCount === 0 hides controls, seek bar, shuffle and visualizer and shows "No songs in this playlist yet" with a Browse button: the same music library folder picker as Settings > Media > Library & Playback (a refused folder shows the reason under it; an accepted one rescans the library). When the list goes from empty to non-empty (onMediaListChanged) the first file auto-plays.
  • Album-art popup — modal, CloseOnEscape | CloseOnPressOutside; on open it calls mediaManager.extract_colors_from_album_art() and offers "Save as Theme", which stores Style.albumArtTheme through settingsManager.save_custom_theme(name, json, artPath).
  • previewTrackTransition(direction, style) — runs one transition without a track change, temporarily overriding transitionStyle; the Now Playing Studio's live preview calls it when a chip is tapped.

Side-card preloading

Two hidden 1×1 Images (prevArtPreloader / nextArtPreloader, sourceSize 2×artSize, asynchronous, cache: true) decode the neighbouring art so a 3D transition never waits on a decode. sideCardRefreshTimer (interval: 0) fires on the next event-loop tick after any track, shuffle, queue or source change and makes one batched call, mediaManager.get_neighbor_album_arts() → [prev, next], which halves the backend calls and keeps them off the animation frame. While the phone has the controls there are no side cards (the phone reports only what is playing now).

Track-change sequencing

function animateTrackChange() {
    vizFadeInTimer.stop()
    _cardAnimBusy = true
    waveformContainer.dropBars()      // snap bars to zero, no animation
    albumArtStack.triggerSlide()       // carousel animation
}
// _cardAnimBusy holds audioAnalyzer's view; it analyses the new local track once it settles
// then vizFadeInTimer (50 ms) fades the bars back in

Backend managers used

nowPlaying, mediaManager (playlists, side-card art, the carousel), phoneMirrorManager (the "Phone" pill, the save-theme popup), settingsManager, audioAnalyzer, volumeController.

MediaPlayer.qml

frontend/MediaPlayer.qml is the library browser: a playlist dropdown, sortable column headers and a ListView of songs. Both stackView and mainWindow are required, which is why the BottomBar and MainMenu add mainWindow to the props only when pushing this page. The comment block at the top documents the Android singleton-function bug behind the local dp() wrappers.

Key features

  • Playlists — playlistNames (from mediaManager.get_playlist_names()) feeds one dropdown popup; a 100 ms dropdownCooldown timer stops CloseOnPressOutside from immediately re-opening it. Long-pressing a playlist (not "All Music" / "Unsorted") opens a delete dialog. The "Create New Playlist" popup is the same width as the song action sheet (min(60 %, dp(640))) and grows to 82 % of the window height when its inline QWERTY keyboard is toggled from the button inside the left of the name field; Cancel / Create are SheetButtons; every key handler ends with newPlaylistInput.forceActiveFocus().
  • Forced rebinding — playlistRefreshCounter is incremented by a 300 ms albumArtRefreshTimer once the backend has cached album art; delegates read it in their bindings (var _ = mediaPlayer.playlistRefreshCounter) so art, title, artist, album and duration re-evaluate.
  • Song rows — ListView with reuseItems: true, cacheBuffer: height * 0.5, displayMarginBeginning/End: dp(40), set one overallMargin below the column header. Each card has a faint full-card art texture (opacity 0.10 when current, 0.03 otherwise) clipped to the card's rounded corners, and a square album-art Image the full height of the card, flush with its left edge, corners rounded with an OpacityMask (sourceSize 1.5× its size, asynchronous, cache: true). Title, artist and album share one text size (mediaPlayerTextSize * 1.2) on one vertically centered line; the duration sits under the title. Long values wrap to a second line before eliding, and each column keeps a gutter on its right. Column widths (title 40 %, artist 25 %, album 35 %) live in titleColumnRatio / artistColumnRatio / albumColumnRatio, shared by the header and the rows. The list uses boundsBehavior: Flickable.StopAtBounds.
  • Highlighting — the current song's card is filled with App.Style.accent at 35 % alpha with a 2 px accent border and a left accent gradient; the title turns accent-coloured. (The playingHighlightColor / pausedHighlightColor tokens in Style are not used here or anywhere else.)
  • Sorting — clickable TITLE / ARTIST / ALBUM headers with ↑/↓ arrows call mediaManager.sort_media_files(column, ascending).
  • Row actions — tap calls mediaManager.play_file() and pushes MediaRoom.qml unless returnToLibraryAfterSelection is set. A 600 ms long-press opens songActionSheet: one popup whose header shows the song's art, title, artist · album and the folder it lives in (get_song_playlist_name()), with three pages — actions (Move to playlist / Delete song), a scrollable list of move targets (get_movable_playlist_names() minus the song's own folder, so a song viewed under "All Music" can't be "moved" into the folder it is already in), and an in-place delete confirmation. A failed move or delete shows an error line instead of closing. Buttons are the file-local SheetButton inline component; delete uses the fixed dangerColor so it never matches the theme accent. No search field, no drag reorder.
  • Initial scroll — calculateScrollPosition() estimates the current row's offset from Spacing.mediaPlayerRowHeight, then scrollToCurrentTrack() sets contentY once the real list height is known (50 ms timer, onHeightChanged). While the page is showing, a track change (next / previous, track end; onCurrentMediaChanged) calls followCurrentTrack(), which animates contentY over 350 ms to center the new song, unless the list is being dragged or flicked.

Backend managers used

mediaManager, settingsManager.

OBDHome.qml

frontend/OBDHome.qml is the OBD hub the BottomBar's OBD button opens. Above a two-card row it shows a 48 dp status row — a dot that is green when connected, amber and pulsing while the status contains "Connecting", red otherwise — with the status text and an elided detail string, and beneath it a rolling connection log (obdManager.connectionLog, the manager's last 60 lines, the same history the OBD settings page shows, monospace ListView that positionViewAtEnd()s). The comment explains the log: "Most useful on Android where the BLE state machine has many steps and silent failures are easy unless we surface each transition."

While no adapter is connected (obdManager.connected false) the status row, log and cards are hidden and the page shows only "No vehicle connected" and OBDConnectButton ("Connect Bluetooth OBD2", which opens OBDAdapterSheet; obdHomeDisconnected). The rest comes back as soon as the link is up. The BottomBar's OBD button lands here rather than on lastOBDPage meanwhile.

CardSubtitleAction
DashboardsLive gauges & parameterspushSubpage("OBDMenu.qml")
Trouble CodesRead & clear DTCspushSubpage("OBDDiagnostics.qml")
Trip History"Recording this drive" / "n drives logged" / "Black box drive logs"pushSubpage("TripHistory.qml")

pushSubpage() saves the file name to the generic setting lastOBDPage and opens the page through stackView.openPage(), which logs a console.warn on failure. The card matching lastSubpage gets a 2 px accent border ("you were just here"); the BottomBar reads the same key to jump straight to the remembered subpage on the next tap.

Backend managers used

obdManager (is_connected(), connectionStatusChanged, connectionStatusDetailChanged, connectionLog), settingsManager.

OBDMenu.qml

frontend/OBDMenu.qml is the live-data page. Since the dashboards work it is a host for one active dashboard chosen from a registry, of which the original "Parameter Cards" grid is the built-in entry:

// Dashboard registry — built-in "grid" (parameter-cards view in this
// file) plus every JSON-defined dashboard enumerated by DashboardManager
// (presets + user-authored files).
readonly property var _gridSentinel: ({ id: "grid", label: "Parameter Cards", builtIn: true })
property var dashboardRegistry: _rebuildRegistry()     // [_gridSentinel].concat(dashboardManager.dashboards)
property string activeDashboardId: settingsManager.get_setting_with_default("activeDashboard", "grid")

The registry is rebuilt from dashboardManager.dashboardsChanged (every reference to dashboardManager is behind typeof so the page loads without the manager) and falls back to "grid" if the active id disappears. A Dashboards.DashboardRenderer fills the page with dashboardManager.loadDashboard(activeDashboardId) whenever the id is not "grid"; otherwise the Parameter Cards grid below is shown. Both are gated on obdConnected (obdManager.connected): with no adapter connected neither renders — the renderer's spec goes null, so its widgets are unloaded — and parameterCardsEmpty shows the "No vehicle connected" prompt with the connect button in place of whichever dashboard is active. Swipe and the page dots are off meanwhile; the active id is kept, so the same dashboard appears as soon as the adapter connects. The chooser button stays available, so dashboards can be created and edited offline. A horizontal swipe anywhere on the content (dashboardSwipeHandler, an xAxis-only DragHandler) cycles the registry with a slide animation — it commits past 20 % of the width or on a fast flick past 8 % — and page dots under the header title show the position; only the active dashboard is ever instantiated. The registry, chooser and editor hand-off are documented in depth in Gauges & Dashboards → OBDMenu Integration; this section covers the page itself.

The Parameter Cards grid

  • Repeater { model: obdPage.vehicleParameters } (= App.OBDParameterModel.vehicleParameters) — one card for every OBD PID the connected vehicle reported in this session's supported-PID scan; the per-PID enable toggles in OBD settings no longer filter it. Until a vehicle has answered, the grid is empty and parameterCardsEmpty explains why (it also covers every other dashboard while disconnected, see above): "No vehicle connected" (plus obdConnectButton, the shared OBDConnectButton, "Connect Bluetooth OBD2", which opens OBDAdapterSheet to scan for, pair and connect an adapter; see OBD Manager → Adapter Finder), "Reading your vehicle's PIDs…" (adapter connected, scan pending) or "No supported PIDs reported". See Gauges & Dashboards → Vehicle Gate.
  • The grid sits in a vertical Flickable (parameterCardsScroll) whose content height is max(viewport, rows × 120 dp), so a long vehicle list scrolls instead of shrinking cards to slivers.
  • Column count is imperative, in updateLayout() behind a 100 ms debounce timer that also runs on resize and when the vehicle list changes: 2 columns for ≤4 cards, 3 for ≤9, 4 for ≤16 (or on a narrow page), 5 above that.
  • Polling is demand-driven: while Parameter Cards is showing, the page registers every card's id with obdManager.setParameterDemand("cards", …); while a dashboard is showing it registers the renderer's paramIds as "dashboard"; both are withdrawn when the page stops being the active StackView page.
  • Behavior on displayValue { NumberAnimation { duration: 50; easing.type: Easing.Linear } } — "fast rolling effect".
  • Each card reads its value through OBDParameterModel.liveValue(id) (paramStore[id]), and only while _gridLive (the grid is the active dashboard and the page is active): on screen a card re-evaluates only when its own PID changes; hidden, it stops listening and shows "--".
  • Two card styles from the settingsManager.obdCardStyleCircular property (live): a square card with title and value centred above a progress bar pinned to the card's bottom edge (the card clips, so a short card can't push the bar out; 100 ms OutCubic width behaviour), or a circular card whose 270° background/value arcs are QtQuick.Shapes PathAngleArcs ("GPU-accelerated Shape (no layer overhead)").
  • Until a PID has reported (liveValue(id) === undefined), both styles show "--" in the label colour and hide the bar or arc, instead of a fake 0.0. Values use OBDParameterModel.decimalsFor(id): whole numbers for speed, RPM, run times, distances and counters, one decimal otherwise. Signed ranges (fuel trims, timing) fill the square card's bar outward from the zero point.
  • Colours come from the OBD tokens obdBoxBackground, obdBarColor, obdValueColor, obdLabelColor.
  • Only the RPM card is clickable: it opens the shift-light popup.

Header and chooser

A header strip shows the active dashboard's label. The square Dashboards button (objectName: "dashboardsButton", icon assets/dashboard_button.svg, hit area twice its size) is anchored to the outer content rectangle with the media pages' mediaRoomMargin top/right margins and the bottom-bar nav button size, so the header lines up with the media pages. It opens the modal chooser popup (up to 960 dp wide, CloseOnEscape | CloseOnPressOutside): a two-column grid of cards with live scaled miniatures (a second DashboardRenderer per card, pre-loaded from aboutToShow and faded in after a 180 ms settle so the gauges' start-up animation is hidden; the grid entry shows a 3×3 schematic). Scriptable names: newDashboardButton ("+ New" pushes dashboards/editor/DashboardEditor.qml with { stackView, callerPage }), resumeDraftButton ("▲ Draft", shown when the dashboardEditorDraft setting is non-empty), chooserScroll, importDashboardButton ("Import" → From file… / Paste from clipboard), dashCard_<id> and dashCard_<id>_edit / _duplicate / _share / _delete (Copy and Share on every JSON dashboard — Share opens Save to Downloads / Copy as text — Edit and Delete only when builtIn === false, Delete is a two-tap "Sure?" with a 3 s timer), and shareStatusBanner for the result. See Gauges & Dashboards → Sharing. openDashboardChooser() / closeDashboardChooser() exist for the command server's call action.

RPM shift-light popup

Clicking the RPM card opens a 92 % × 95 % modal editor for the shift-light flags: "Enable Shift Light" and "Show Indicator on Home" toggles, a Redline slider (rpm_max_rpm, clamping existing flags), an RPM bar with coloured range segments, "+ Add Flag", and per-flag controls — RPM low/high sliders, colour, flash + flash speed, full-screen overlay + opacity, aura overlay + opacity + spread. onOpened loads shiftLightManager.flags (already migrated to rpmLow/rpmHigh) and edits save through shiftLightManager.setFlags(). It writes rpm_flags, rpm_shift_light_enabled, rpm_show_on_home_card, rpm_max_rpm and activeDashboard; the appearance keys it reads (rpm_shift_light_size, rpm_glow_size, rpm_glow_intensity, rpm_color_transition_speed, rpm_pulse_enabled, rpm_fullscreen_flash_opacity) have no controls in this popup; they use defaults unless changed through generic settings. The six DropShadows in this file are all inside this popup (its background and five toggle handles). The rules belong to ShiftLightManager; HomeOBDView and Main.qml draw its state.

Backend managers used

settingsManager, dashboardManager, and obdManager only for setParameterDemand() and the connected flag (obdConnected) that gates every dashboard and the Parameter Cards grid behind the connect prompt (both behind typeof guards). Live values arrive through the OBDParameterModel singleton, and connection status is otherwise OBDHome's job.

OBDDiagnostics.qml

frontend/OBDDiagnostics.qml is the trouble-code reader. A 4×2 GridLayout holds four command buttons across the top — Read DTCs (read_dtc()), Current DTCs ("Active this drive cycle", read_current_dtc()), Read Status ("CEL & DTC count", read_status()), Freeze Frame (read_freeze_frame()) — a three-column terminal panel on the second row, and a red Clear DTCs button that opens a Dialog ("This will also reset the Check Engine Light and clear freeze frame data", "may affect emissions testing readiness") before calling clear_dtc(). There is no permanent-code (mode 0A) reader.

Output is the terminal

Every result is rendered as text in an inline terminal (QtObject { id: terminalOutput } with lines, maxLines: 50, appendLine(), clear(); #1a1a1a panel, "DIAGNOSTIC OUTPUT" header, a 3 % scanline canvas, a 530 ms blinking cursor, "> Awaiting command..." when empty, a BusyIndicator while isLoading). Lines are colour-coded by prefix ([ERROR], [DONE]/[SUCCESS], [INFO]/[SCAN], [WARN], [FOUND], [CLEAR], [DTC]). The page stores dtcCodes, dtcCount, milStatus and freezeFrameData, but none of them has a dedicated widget — freeze-frame data, for instance, is printed as one [DTC] line per entry. This page does not use the reusable TerminalFeedback component.

Connections {
    target: obdManager
    enabled: obdManager !== null
    function onDtcCodesChanged(codes)              // "[DTC] P0301 - Cylinder 1 misfire" per code
    function onDtcCountChanged(count)
    function onMilStatusChanged(status)
    function onDtcClearResult(success, message)
    function onFreezeFrameChanged(data)
}

Diagnostic mode lifecycle

Diagnostics need the polling loop paused. Component.onCompleted calls obdManager.enter_diagnostic_mode() and read_status() if already connected; onVisibleChanged re-enters or exits the mode as the page is covered and uncovered, because "StackView.push() doesn't destroy the page, so Component.onDestruction won't be called when navigating away"; Component.onDestruction is the fallback for real destruction. See OBD Manager → Diagnostics.

Backend managers used

obdManager only.

TripHistory.qml

frontend/TripHistory.qml is the black box viewer, reached from the OBD Home "Trip History" card (and the Black Box settings card). Two views, Drives and Trends. Drives has two panels: the drive list on the left (newest first; the trip being recorded on top with a LIVE badge, a trip closed after a power cut with RECOVERED; each row shows the start, duration, distance and max speed) under a recording pill (REC m:ss · samples, "Waiting for engine", or "Off"), with the drive count and storage used against the cap in the footer and a Folder button on desktop. The selected drive on the right: start → end, vehicle / VIN, eight stat tiles (duration, distance, max speed, max RPM, peak coolant, voltage range, fuel used, samples · PIDs), a chip row of every parameter recorded in that drive, a Canvas chart of the chosen parameter (min–max band per bucket and the bucket average, time axis in m:ss) with its min / avg / max / count line — pinch to zoom, one-finger drag to pan while zoomed, mouse wheel to zoom on desktop, double-tap (or the window badge) to reset; every window change re-queries the store for 360 buckets over just that span after a 120 ms debounce, so zooming reveals detail instead of stretching the overview —, and nothing below it (the events and the CSV path are in the trip's JSON, not on the page). The only button is End trip, on the trip being recorded; export and delete are not on the page (the Black Box settings card has "Clear history", and tripRecorder.exportTrip() / deleteTrip() remain in the API).

Everything is asynchronous: selecting a drive calls tripRecorder.requestDetail(id) and requestSeries(id, param, 360) and the page fills in on detailReady / seriesReady; the live trip re-requests both on tripRecorder.liveTripChanged (new samples reached the store, at most every 5 s) while shown. The recording pill's clock is tripRecorder.currentTripElapsedS and the live row's count currentSampleCount, both from the recorder. The parameter defaults to SPEED, then RPM, then the first recorded one; titles and units come from OBDParameterModel.allParameters / nativeUnit(id), the recorded units (the gauges' °F / km/h settings don't apply here: the chart and stats are the stored values, as in the CSV).

A Drives | Trends switch under the title (tripView_drives / tripView_trends, page property view) turns the page into the car's health across drives (Health across drives). Left: a card per health number (tripTrendMetric_<id>): a status dot (green ok, amber watch, grey no data), the average of the last three drives, a sparkline of one point per drive with the usual range shaded (scaled to the data, never finer than a quarter of the usual range, so noise stays flat), and the verdict's note (in full on the selected card). Right: the selected number's title and unit, window chips 7 d / 30 d / 90 d / All (tripTrendDays_<n>, 30 by default), the note and the drive count, a chip row of every parameter recorded in the window (tripTrendParam_<id>), and a Canvas chart (tripTrendChart) over dates: a dot per drive, out-of-range dots in the warning colour, the usual range shaded, the earlier drives' average as a dashed "before" line, and for a parameter its lowest–highest per drive as a whisker. Tapping a point opens that drive in the Drives view. The first number shown is the first that needs a look, else the first with data.

Opening Trends (and every window change) calls tripRecorder.requestHealthTrends(days), a parameter chip requestParamTrend(id, days); answers for another window or parameter are ignored. When the trip list changes (a drive ended or was deleted) the trends are asked for again after 800 ms. tst_triphistoryview drives the view against a fake tripRecorder.

Backend managers used

tripRecorder (trips, recording, currentTripStartMs, currentTripElapsedS, currentSampleCount, liveTripChanged, storageBytes, canOpenFolder, refresh(), requestDetail(), requestSeries(), requestHealthTrends(), requestParamTrend(), endTripNow()), settingsManager (blackBoxMaxMB), OBDParameterModel.

SensorHome.qml

frontend/SensorHome.qml is the sensors hub, "styled to match OBDHome's palette so the two hubs feel like the same surface": an IMU status bar (green dot / "IMU Connected" or red / "IMU Disconnected", from berryIMU.connected and connectionStatusChanged) above a two-card row.

CardSubtitleBehaviour
SensorsPitch, roll, heading, G-forcepushSubpage("SensorMenu.qml")
3D View"Live vehicle attitude", or "Requires the QtQuick3D module"pushSubpage("CarMenu.qml"); opacity 0.5 and MouseArea.enabled: false when has3DView is false

Only the 3D View card is conditionally dimmed, and only by module availability: has3DView is the hasQuick3D context property, which main.cpp sets from the QtQuick3D qmldir in the engine's import paths (src/util/qmlmodules.cpp) without loading the module; the page used to compile CarMenu.qml synchronously to find out. IMU connection state changes nothing but the status bar — both live cards stay tappable without an IMU. pushSubpage() opens the page with stackView.openPage() and persists lastSensorPage only after that succeeds; the comment records why: writing it up front once made an unloadable CarMenu the BottomBar's remembered target, "so the sensor button silently did nothing on every later launch" (commit 0c29e64). On its first StackView.onActivated (a real visit, not the hidden pre-build), when has3DView is true, it calls mainWindow.prewarmPageSoon("CarMenu.qml") so the 3D View page is built and warm-rendered in the background before the tap.

Backend managers used

berryIMU, settingsManager.

SensorMenu.qml

frontend/SensorMenu.qml is a 3×2 grid of BerryIMU readouts: SensorCards for PITCH, ROLL (bar = (value + 90) / 180), HEADING (value plus the compass point from units.cardinal(), bar = value / 360), ALTITUDE and TEMPERATURE, and a GForceDisplay for lateral / longitudinal / magnitude. Below the grid, ZERO (berryIMU.calibrateTare()) and RESET (resetTare()) buttons appear while the IMU is connected.

Display settings (bound to settingsManager, live)

The page binds to typed SettingsManager properties, so a change in Settings → Accessories → IMU shows as soon as you return, although the page stack keeps this page alive. (It used to read them once in Component.onCompleted and show the old values until a restart.)

KeyDefaultEffect
sensorDecimalPlaces (0–4)1toFixed() precision everywhere
sensorTempUnit"F"Celsius → Fahrenheit in tempDisplay() through units.cToF()
sensorAltitudeUnit"m""ft" converts in altDisplay() through units.mToFt()
sensorCards map (Pitch, Roll, Heading, GForce, Altitude, Temperature; saved as sensorShow<card>)trueCard visibility; the temperature card is also hidden when berryIMU.hasTemperature === false
sensorEmitRate60Not read here: BerryIMUManager follows settingsManager.sensorEmitRate from startup
Emit rate, not sample rate
sensorEmitRate controls how often the IMU manager emits its signals into QML (10–120 Hz, default 60). The sensor read loop and the Madgwick filter keep running at their ~200 Hz target on the manager's own thread regardless; the setting only throttles the queued signal traffic. Main.qml separately drops the emission to 5 Hz whenever no page is showing IMU values (IMU Rate Gating, Hardware Managers → IMU emission).

The page subscribes to pitchChanged, rollChanged, headingChanged, altitudeChanged, accelMagnitudeChanged, lateralGChanged, longitudinalGChanged, baroTempChanged and connectionStatusChanged directly on berryIMU (it does not go through OBDParameterModel).

Backend managers used

berryIMU, settingsManager.

CarMenu.qml

frontend/CarMenu.qml renders the rigged 2003 Jeep TJ in a Qt Quick 3D View3D and rotates it with the live IMU attitude. It uses unversioned Qt 6 imports plus QtQuick3D, so it only loads when the optional module is present (CMake links Quick3D when found; SensorHome greys the card out otherwise). It requires stackView only; mainWindow is a plain property.

The page sits on Style.backgroundColor. The 3D panel is a rounded #18232F rectangle (the scene's clear colour, so controls and view read as one panel) with an accent border; its stock Basic-style Buttons, Sliders and Labels get a page palette with light text and accent fills, since they sit on that dark panel whatever the theme. The IMU strip below is contentColor with theme-coloured text; its badges use statusConnected (LIVE), the accent (Tare) and statusWarning (Reset), and "NO IMU" is an outlined secondaryTextColor badge.

Scene

  • SceneEnvironment — clear colour #18232F, MSAA medium, no AO, lightProbe: Texture { source: "./assets/vehicle_studio.hdr" } at probeExposure: 0.8; a key DirectionalLight (brightness 1.3, ambient #777777) and a cool blue fill light (#C3D9FF, 0.7). No shadows.
  • Node { id: cameraOrbit } rotated by cameraPitch / cameraYaw (defaults −25 / −35) carries a PerspectiveCamera (45° FOV) whose distance is the larger of cameraDistance (200) and a value computed from jeep.radius and the view's aspect ratio to "fit the vehicle's rotation envelope even in a narrow view". A MouseArea over the view orbits the camera by drag (yaw 0.5°/px, pitch 0.3°/px clamped to ±89°).
  • Node { id: carModel; scale: 30 } — "Metre-scale TJ, +Z forward / +Y up, pivot at chassis centre" — containing JeepVehicle { id: jeep }, the rig described in Components → The Jeep TJ Rig.

IMU drives the body, buttons drive the rest

Connections {
    target: berryIMU
    // Quaternion drives the 3D model directly — no gimbal lock
    // Remap sensor frame (Z-up) to Qt3D frame (Y-up): (w,x,y,z) → (w,y,z,x)
    function onOrientationChanged(w, x, y, z) { carModel.rotation = Qt.quaternion(w, y, z, x) }
    // Euler angles for gauge displays only — NOT applied to the model
    function onPitchChanged(val) { currentPitch = val }
    function onRollChanged(val) { currentRoll = val }
    function onHeadingChanged(val) { currentHeading = val }
    function onAltitudeChanged(val) { currentAltitude = val }
    function onAccelMagnitudeChanged(val) { currentGForce = val }
    function onBaroTempChanged(val) { currentBaroTemp = val }
    function onConnectionStatusChanged(status) { imuConnected = (status === "Connected") }
}

The whole-body rotation is the only thing the sensor controls. A Flow of Buttons above the viewport (enabled once jeep.ready) toggles Driver door, Passenger door, Rear glass (shown only when jeep.hasRearGlass; hidden for the installed soft top), Tailgate, Hood, Headlights, Fog lights, Brake lights and Reverse lights (each objectName: "jeepControl_<key>", checkable, bound to the matching jeep.* boolean), plus "Open all" (jeepOpenAll), "Close all", a Steer slider (jeepSteering, −1..1, sign-inverted into jeep.steeringInput), "Center wheels" (centerWheels), a "Spin wheels" toggle (jeepSpinWheels, 0 ↔ 10 km/h), a wheel-speed slider (jeepWheelSpeed, −30..30 km/h) with a km/h label, and an Engine slider (jeepEngineRpm, 0..6000 rpm) that spins the crank pulley and fan. While the rig is loading, a centred label reads "Loading Jeep…" or "Unable to load Jeep: <error>".

Live OBD drive. While obdManager.connected is true (or the OBD simulator runs), two Bindings feed OBDParameterModel's RPM into jeep.engineRpm and SPEED (MPH, converted to km/h) into jeep.wheelSpeedKph. The wheel and engine sliders are then disabled and the engine label adds "live OBD"; on disconnect the bindings release and the manual values return. CarMenu registers the "carMenu" poll demand for ["RPM", "SPEED"] only while it is visible (it is pre-built hidden in the StackView), and clears it on destruction.

Bottom control panel

A #333333 bar at the bottom shows one of two rows. Connected: a green LIVE badge, ZERO (objectName: "imuZero" → berryIMU.calibrateTare()) and RESET (imuReset → resetTare()) buttons, and text readouts for pitch, roll, heading with cardinal, altitude in metres and barometer temperature in °C (the received G magnitude is not displayed). The two objectNames were added in commit 19d8904 so a script can level the vehicle before a capture. No IMU: a grey "NO IMU" badge, Start/Stop Sim, Reset and manual Pitch / Roll sliders (±90°); the simulation is a 50 ms timer writing 30·sin(t) pitch and 45·cos(1.5t) roll into carModel.eulerRotation.x/z, and it only runs while simulationRunning && !imuConnected. There are no gauge overlays around the viewport.

Backend managers used

berryIMU for attitude, connection and calibration; obdManager for connection state and polling demand; OBDParameterModel for live RPM and speed. The page reads no settings.

ClockMenu.qml

frontend/ClockMenu.qml is a full-screen clock drawn entirely in theme tokens on Style.backgroundColor: an analog face on top — a contentColor circle (dp(240), capped at 45 % of the page height) with a 60 % accent border, twelve hour ticks (every third one longer and in primaryTextColor), hour and minute hands in primaryTextColor (26 % and 38 % of the face), an accent second hand at 42 % and an accent centre dot, all hands with transformOrigin: Item.Bottom anchored to the centre — then a dp(64) bold digital time in clockTextColor and the date as Qt.formatDate(now, "dddd, MMMM d, yyyy") in dp(22) secondaryTextColor.

A Timer (running: visible, triggeredOnStart) calls refresh() and then sets its interval to 1015 - ms, so every tick lands about 15 ms after the real second boundary (a plain 1000 ms repeat kept the phase the page opened with, so the second hand trailed by up to a second and drift made it skip or repeat). refresh(), which reads new Date() and sets the digital text, the date and hourHand.rotation = (h % 12) * 30 + m / 2, minuteHand.rotation = m * 6 + s / 10, secondHand.rotation = s * 6. The digital text follows clockFormat24Hour and clockShowSeconds (Qt.formatTime with HH:mm[:ss] or hh:mm[:ss] AP), matching the clock manager. It no longer follows clock.timeChanged: that signal fires only when the text changes (once a minute without seconds), so a freshly opened page stayed blank until the next minute and the second hand never moved.

The file declares required property StackView stackView and required property ApplicationWindow mainWindow and has no objectName. Tapping the BottomBar clock opens it (openClockSection() → stackView.openPage("ClockMenu.qml"), which supplies both); the command server's Main.navigateTo("ClockMenu.qml") reaches it too. See Page Routing.

Backend managers used

settingsManager for 12/24-hour format and seconds visibility. Time itself comes from JavaScript Date; this page does not consume the clock manager.

PhoneMirrorView.qml

frontend/PhoneMirrorView.qml: OCTAVE's built-in scrcpy-protocol client streams the phone or a virtual display onto a VideoOutput and forwards touch over the control socket. It requires adb and the bundled phone-server jar, but no external scrcpy executable. It imports QtQuick.Controls.Basic and QtMultimedia. The BottomBar shows its button only when !isAndroid && settingsManager.phoneMirrorEnabled and opens the view with stackView.openPage("PhoneMirrorView.qml"), so the single cached instance (and its running session) is reused; the view sets its own objectName: "phoneMirrorView". Its StackView.onActivated calls startMirror() on every visit, which is a no-op while the stream is already up.

  • Video — a VideoOutput (PreserveAspectFit) whose videoSink is handed to the manager in Component.onCompleted (phoneMirrorManager.videoSink = videoSink); decoded frames land on it directly. frameCounter is bumped on every frameReady ("proof of life"); hasVideo = frameCounter >= 1.
  • Touch — a MultiPointTouchArea (1–10 points, mouseEnabled) maps each point through the letterboxed video.contentRect to 0..1 and calls phoneMirrorManager.injectTouch(pointId, action, relX, relY) with 0 pressed, 2 updated, 1 released/cancelled — real multitouch; mouse input arrives as one point.
  • Nav buttons — ◁ / ○ / ▢ call pressBack(), pressHome(), pressAppSwitch(); shown only while mirroring the phone's own screen (activeDisplaySize === ""), because a virtual display "shows system decorations, i.e. Samsung's DeX taskbar with its own back/home/recents along the bottom, which this row would cover".
  • Overlays — "Connecting..." until the first frame; a "Reconnecting to phone…" badge after 2 s of quiet reconnect; a "Phone is asleep / Waking it up… tap to retry" scrim (after 1.5 s of phoneAsleep, tap → wakePhone()); an "X" that calls stopScrcpy() and pops.
  • No phone: QR pairing — when the error is "No Android device..." (noPhone) and no Wi-Fi phone is remembered, the page becomes the PhoneQrPairingPanel (code at 55 % of the view height, plus "Or plug the phone in over USB..."), starts startQrPairing() on its own and cancels it when the view is deactivated or mirroring starts. With a remembered phone the error page is titled "Waiting for your phone" (not "Phone Mirror Failed") and offers "Connect a different phone" (wantQr), whose panel has a Cancel back to the waiting page. Pairing success needs nothing from the view: the 2 s recovery timer sees the phone and starts mirroring.
  • Errors and recovery — the manager decides and retries; the page follows phoneMirrorManager.sessionState (see Phone Mirror). "reconnecting" (a drop after video was flowing) keeps the last frame up with a "Reconnecting to phone…" badge after 2 s — "a USB blip (the phone re-enumerates when picked up / put down) kills the session". "waitingForPhone" shows the error page (the QR screen when lastErrorKind is "noPhone"), and "starting" from there (the manager saw the phone and restarted) goes back to "Connecting…". "failed" (setup or fatal) shows the error, or getInstallInstructions() when setupOk is false. The page reports setViewActive(StackView.status === StackView.Active); the manager's retries and adb probes run only then. It has no timers of its own.

Manager surface used: startScrcpy(), stopScrcpy(), isRunning, videoSink, activeDisplaySize, phoneAsleep, wakePhone(), injectTouch(), pressBack/Home/AppSwitch(), getDeviceState(), environmentOk(), getInstallInstructions(), nativeAvailable, serverVersion, adbPath, signals scrcpyStarted, scrcpyStopped, scrcpyError, frameReady. Protocol and manager details: Phone Mirror & Android Auto.

Backend managers used

phoneMirrorManager.

AirPlayView.qml

frontend/AirPlayView.qml (objectName airPlayView, dev navigate page airplay) is the iPhone page over airPlayManager (iPhone (AirPlay)). While an iPhone mirrors it shows the picture in a VideoOutput whose sink it hands to the manager; a MultiPointTouchArea maps touches through the letterboxed contentRect to 0..1 and calls injectTouch() (a touch that starts on the bars is ignored), and a WheelHandler calls scroll(). Top right: a touch badge (tap to open the Bluetooth pairing window) or Calibrate touch, and X, which only leaves the page. Without mirroring it shows the setup: Turn on AirPlay, the PIN in large digits while a new iPhone pairs, the track while AirPlay audio plays, the steps with OCTAVE's addresses, and the touch card with Pair touch. Main.qml opens it on airPlayManager.mirroringStarted.

AndroidAutoView.qml

frontend/AndroidAutoView.qml has two modes, following androidAutoManager.backend: the built-in head unit ("native", below) and Google's Desktop Head Unit ("dhu"). For the DHU it imports OCTAVE.AndroidAuto 1.0 for EmbeddedDhuItem — the one custom type src/main.cpp registers with qmlRegisterType — and QtQuick.Controls.Basic. Like the phone mirror, its BottomBar button is hidden on Android and gated by settingsManager.androidAutoEnabled; the shared page cache reuses its instance.

EmbeddedDhuItem {
    id: embeddedDhu
    anchors.fill: parent
    Component.onCompleted: if (androidAutoManager) connectToManager(androidAutoManager)
    onStreamStarted: ...
    onStreamStopped: dhuEmbedded = false
    onErrorOccurred: function(error) { launchFailed = true; errorMessage = error }
}
  • "The actual DHU window becomes a child of this" item; onVisibleChanged calls embeddedDhu.detach() when the page is covered and reattach() when it is shown again, and StackView.onActivated either resumes an already-embedded DHU (androidAutoManager.isDhuEmbedded) or calls launchDhuSeamless().
  • Manager signals: error(msg), dhuWindowReady(hwnd) (→ dhuEmbedded = true), dhuEmbeddedChanged(embedded).
  • States: "Starting Android Auto..." with a bouncing progress bar while !embeddedDhu.isStreaming; an error screen ("Could not start Android Auto") with a four-step setup checklist (USB debugging, developer mode, head-unit server, Google DHU), a "< Back" button and "Try Again" (launchDhuSeamless() again).
  • A semi-transparent "X" at the top-right calls androidAutoManager.closeDhu() and pops the page.

Built-in mode (backend === "native")

The androidAutoNative item replaces the DHU items (which are hidden in this mode):

  • A VideoOutput (PreserveAspectFit) whose videoSink is handed to androidAutoManager.videoSink in Component.onCompleted; shown while nativeState === "running".
  • A MultiPointTouchArea (androidAutoTouchArea, up to 10 points, mouse enabled) that maps each point through the video's contentRect to 0..1 and calls injectTouch(pointId, action, x, y) (0 press, 2 move, 1 release or cancel). A touch that starts on the letterbox bars is ignored until it is released.
  • A status screen while not running: a title per nativeState ("Waiting for the phone", "Waiting for the phone's head unit server", "Android Auto needs a certificate", "Android Auto stopped", else "Starting Android Auto..."), nativeStatus under it (androidAutoStatus), the one-time phone setup hint while waiting for the phone or its server, and "Retrying on its own." after an error.
  • StackView.onActivated calls setViewVisible(true) and startNative(); onVisibleChanged passes visibility to setViewVisible() (the phone pauses video while the page is hidden, audio continues). The "X" (androidAutoClose) calls stopNative() and pops the page.

Backend managers used

androidAutoManager. See Phone Mirror & Android Auto for the built-in receiver, the DHU pipeline and the dhuframe image provider.

SettingsMenu and its Layouts

frontend/SettingsMenu.qml is the settings orchestrator. It requires stackView, mainWindow and initialSection (hence navigateToSettings() in Main.qml), owns the pageModel — the single source of truth for the six settings pages — and loads one of four layout files into a single Loader according to settingsManager.settingsLayoutStyle (default "Sidebar"):

SectionPage (settings/)Hub widget (settings/widgets/)GroupSub-sections
displaySettingsDisplaySettingsPageDisplayWidgetAppearanceLayout, Appearance, Clock
mediaSettingsMediaSettingsPageMediaWidgetAppearanceLibrary & Playback, Now Playing
obdSettingsOBDSettingsPageOBDWidgetConnectivityConnection, Adapters, Parameters
accessoriesSettings (desktopOnly)AccessoriesSettingsPageAccessoriesWidgetConnectivityVolume Knob, IMU Sensor, Gesture Sensor, Phone Dock
deviceSettingsDeviceSettingsPageDeviceWidgetSystemDevice, Service (the service switch, pairing codes and paired devices, from servicePairing), Frontends (installed frontends from frontendManager: install from a folder or zip, choose the screen, remove; see Installing Your Frontend), Diagnostics
aboutAboutPageAboutWidgetSystem—

buildHubModel() drops desktopOnly pages when typeof isAndroid !== "undefined" && isAndroid and any section the user hid (settingsManager.is_settings_section_visible()). navigateToCategory(section) persists the section with set_last_settings_section() and delegates to the layout (Carousel: sets currentIndex; others: navigateToCategory()); navigateToHub() is what the BottomBar's Settings button calls when Settings is already showing.

Layout filesettingsLayoutStyleShape
SettingsSidebarLayout.qml"Sidebar" (default, shown as "Grid")One SettingsTilePage grid of every section's tiles, one section per column, short columns stretched to fill (15 tiles on desktop); tiles open SettingsCardPopup with a hero-morph, the About tile opens the whole About page in that popup, and the media_now_playing tile hands off to mainWindow.openNowPlayingStudio(); tapping Settings again closes the open card
SettingsHubLayout.qml"Hub"Landing grid of SettingsHubGridCards (3 columns above 800 px, else 2) each carrying a live summary widget (OBD status, ESP32 knob, theme, …), then a full-width detail page under a "← Section" heading; 150/200 ms cross-fades
SettingsCarouselLayout.qml"Carousel"Equal-width icon tabs over a SwipeView of SettingsHubCards; exposes currentIndex; swipe or tap to change section
SettingsDashboardLayout.qml"Dashboard"Two-row "pyramid" landing page (Display / Media / OBD featured on top, the rest below) of SettingsDashboardCards that can jump to a sub-section; detail view has a 40 dp draggable touch scroll rail; no transitions

None of the four layout files sets an objectName; the page root is settingsMenu, and settings cards are found by an objectName equal to their tile title. The component library, tile system, persistence and per-page details are on Settings UI; the settings themselves are in the Settings Reference.

Backend managers used

settingsManager, networkManager (update badge on About); the Hub layout also reads obdManager and esp32VolumeManager for its summaries, both behind typeof.

NowPlayingStudio.qml

frontend/NowPlayingStudio.qml is not a StackView page: Main.qml instantiates it once at z: 5 above the StackView and drives it through openNowPlayingStudio(originRect) / closeNowPlayingStudio(). The Media page's "Now Playing" tile (cardId === "media_now_playing", in both SettingsSidebarLayout and SettingsHubCard) maps its rectangle to window coordinates and opens it; any stackView.currentItemChanged or sidebar navigation closes it. Its header comment is the spec: "PiP locks top-right; left panel runs full height; BACKGROUND lives in the strip directly below the PiP. No nav bar, no settings tabs", followed by a visual standard (every setting is a full-width row, named spacing tokens, "adding a 30th setting adopts the same rhythm as the first") and copy-paste recipes.

  • Hero-morph — openProgress (0 = collapsed onto the originating tile, 1 = filling the target rect) interpolates x/y/width/height between originRect and targetX/Y/Width/Height, which Main.qml sets to exclude the BottomBar only (the studio covers the settings rail, so the left settings panel gets that width); content opacity is max(0, (openProgress − 0.45) / 0.55) so "the user sees the card grow first and the controls resolve once there's room"; 320 ms OutCubic, disabled for the first frame so the studio does not appear to open itself at launch. Main.qml keeps it visible until openProgress returns to 0.
  • Header box — "→ Now Playing" with an accent underline, mirroring SettingsCardPopup's header, is the back control (closeRequested()); there is no X button.
  • Left panel sections — the Music Player Object picker selects album art or one of the optional 3D music devices; BACKGROUND (backgroundGrid Normal/2x2/4x4, backgroundBlurRadius, backgroundOverlayOpacity), ALBUM ART (roundedAlbumArt + albumArtCornerRadius, showAlbumArtShadow, vinylRecordMode, showWaveformVisualizer), 3D PREVIEW (show3DAlbumPreview, sideCardAngle, sideCardOpacity), ANIMATION & TEXT (textScrollSpeed, show3DButtonTilt, buttonTiltDuration) — built from SettingCategory, SettingSubcategory, SettingsSlider, SettingsToggle (compact), SettingLabel, ValueDisplay and NowPlayingButtonGrid. Every control writes the matching settingsManager.save_*() immediately and mirrors changes back through a Connections block.
  • PiP and strip — a MediaRoomLivePreview pinned top-right (height = 50 % of the studio, min dp(180)), with PRESETS (Minimal, Vinyl, Showcase 3D, Cinema — each a dict fanned out through applyPreset()) and TRACK TRANSITION chips beneath it "so the live PiP above shows the chosen animation fire as soon as a chip is tapped" (centerPip.previewTrackTransition(1, style)).

NowPlayingMiniToggle and NowPlayingVSlider exist in settings/ but the studio does not instantiate them (the mini toggle appears only in a recipe comment). More on the tile hosts in Settings UI → Now Playing Studio.

Backend managers used

settingsManager (the embedded MediaRoom preview reaches mediaManager and audioAnalyzer itself).

Primary navigation is the seven BottomBar buttons plus its clock; secondary navigation happens inside pages. Most destinations use stackView.openPage() and reuse a cached instance. Home and hub returns can pop the stack, and the editor is pushed by URL.

 BottomBar
 |-- Home ............ MainMenu (preserves Settings when its wiki reader is open)
 |-- OBD ............. OBDHome, or lastOBDPage (OBDMenu / OBDDiagnostics) when connected; on an obd* page: pop to OBDHome
 |-- Media ........... musicButtonDefaultPage (MediaRoom | MediaPlayer); on one of them: open the other
 |-- Sensor .......... SensorHome, or lastSensorPage (SensorMenu / CarMenu); on a sensor* / carMenu page: pop to SensorHome
 |-- Settings ........ SettingsMenu(initialSection = lastSettingsSection); already there: navigateToHub()
 |-- Android Auto .... AndroidAutoView          (desktop only, androidAutoEnabled)
 |-- Phone Mirror .... PhoneMirrorView          (desktop only, phoneMirrorEnabled; reuses a stacked instance)
 `-- clock label ..... ClockMenu

 MainMenu ---- media panel ----> MediaRoom | MediaPlayer
          `--- OBD panel ------> OBDMenu            (bypasses OBDHome)
 OBDHome ----- Dashboards -----> OBDMenu ----------> dashboards/editor/DashboardEditor (chooser: + New / Edit / Draft)
          `--- Trouble Codes --> OBDDiagnostics
 SensorHome -- Sensors --------> SensorMenu
          `--- 3D View --------> CarMenu            (only if QtQuick3D loads)
 MediaPlayer row -------------> MediaRoom
 Settings "Now Playing" tile --> NowPlayingStudio   (overlay, not a page)
 Main.navigateTo(...) --------> any of 14 pages     (command server / screenshot sweeper)

Remembered subpages are generic settings keys written by the hubs (lastOBDPage, lastSensorPage) and lastSettingsSection; the BottomBar clears lastSensorPage if the remembered page fails to load. The prefix tests it uses (indexOf("obd") === 0, indexOf("sensor") === 0 || name === "carMenu") are why page objectNames follow the camelCase-file-name convention listed in objectName Conventions.

Design Decisions

Reasons recorded in the files' own comments or in the commits that introduced them.

Hub pages in front of feature pages

OBDHome and SensorHome exist so that the BottomBar has a stable landing place and a place to show connection state and logs, while still letting the button jump straight to the last-used subpage. Commit 0c29e64 shaped the rule that the hub records the subpage only after a successful push and that the BottomBar falls back to the hub and clears the stale key — a "sensor button that silently does nothing" was the alternative. Placeholder cards ("Coming soon") reserve the grid for planned features without hiding the layout.

Grid-as-a-dashboard

Rather than replace the original parameter-card grid when JSON dashboards arrived, OBDMenu registers it as the built-in "grid" entry with a schematic preview; existing ids "still resolve to the same dashboards — they just render from JSON via DashboardRenderer now". The active choice is one generic setting, and every reference to dashboardManager is guarded so the page still loads if the manager is absent.

Chooser actions as visible buttons

Card actions are always-visible buttons rather than long-press menus because long-press is "discoverability-hostile in a glance-and-go in-car UI"; the two-tap delete keeps a destructive action safe without a modal.

Diagnostics pause polling by visibility

OBDDiagnostics enters and leaves diagnostic mode from onVisibleChanged, not from creation/destruction, because a page underneath another on the StackView is neither destroyed nor visible; polling resumes as soon as it is covered.

Recover in the vehicle, not on the error page

PhoneMirrorView's quiet reconnect and automatic restart (the manager's, see sessionState) exist because USB blips and nudged cables are the normal failure in a car; the last frame stays on screen and the session restarts itself the moment adb reports the device again, with the setup instructions reserved for genuinely missing tooling.

TapHandler over MouseArea on composite panels

MainMenu's media panel is a scrollable title, a scrollable metadata row and a slider; a MouseArea would steal their drags, so a TapHandler observes taps without interfering.

A window-level studio instead of a settings page

Editing Now Playing visuals needs the real MediaRoom in view while sliders move. NowPlayingStudio is a single overlay in Main.qml with a live MediaRoomLivePreview, opened with the same hero-morph as the settings card popups so it reads as "the tile grew", and sized to leave the BottomBar usable so navigation still works while it is open (the settings sidebar is covered to give the settings panel more room).