Pages
Every view that can be pushed on the StackView (plus the window-level Now Playing Studio): what it shows, how it is reached, which managers it talks to, and the QML techniques worth knowing
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.
| View | objectName | Properties it takes | Reached from |
|---|---|---|---|
| MainMenu | mainMenu | stackView, mainWindow, windowWidth, windowHeight (plain) | StackView.initialItem; BottomBar Home normally pops back to it; an open wiki reader can require a cached Home above Settings |
| MediaRoom | mediaRoom | stackView, mainWindow (plain) | BottomBar Media, MainMenu media panel, MediaPlayer row tap |
| MediaPlayer | mediaPlayer | stackView, mainWindow (required) | BottomBar Media (toggles with MediaRoom), MainMenu media panel |
| OBDHome | obdHome | stackView, mainWindow (required) | BottomBar OBD |
| OBDMenu | obdMenu | stackView, mainWindow (required) | OBDHome "Dashboards" card, MainMenu OBD panel, BottomBar OBD via lastOBDPage |
| OBDDiagnostics | obdDiagnostics | stackView, mainWindow (required) | OBDHome "Trouble Codes" card, BottomBar OBD via lastOBDPage |
| TripHistory | obdTripHistory | stackView, mainWindow (required) | OBDHome "Trip History" card, Black Box settings card, BottomBar OBD via lastOBDPage |
| SensorHome | sensorHome | stackView, mainWindow (required) | BottomBar Sensor button |
| SensorMenu | sensorMenu | stackView, mainWindow (required) | SensorHome "Sensors" card, BottomBar Sensor via lastSensorPage |
| CarMenu | carMenu | stackView (required), mainWindow (plain) | SensorHome "3D View" card, BottomBar Sensor via lastSensorPage |
| ClockMenu | — | stackView, mainWindow (required) | BottomBar clock; navigateTo("ClockMenu.qml") (command server / screenshot sweeper) |
| PhoneMirrorView | phoneMirrorView | stackView, 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 |
| SettingsMenu | settingsMenu | stackView, mainWindow, initialSection (required) | BottomBar Settings, navigateToSettings() |
| DashboardEditor (dashboards/editor/) | — | stackView, callerPage, optional editingId / draftJson | OBDMenu 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.
MainMenu.qml
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
Imageof the current art withlayer.effect: MultiEffect { blurEnabled: true; blurMax: 64; blur: min(1, settingsManager.backgroundBlurRadius / 64) }under a#C0000000overlay; the panel border is drawn by a separate border-only rectangle atz: 10so 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 sTimer+ 5 sInOutQuadNumberAnimationping-pong oncontentX, running only while the text is wider than its container. - Seek slider — a custom
Slider(accent fill, round handle,userSeekingflag) that followsnowPlaying.positionMs(aBindingpaused whileuserSeeking) and on release callsnowPlaying.seek(), flanked by position/duration labels bound to the same values. - Either source —
currentArt/Title/Artist/AlbumbindnowPlaying, 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 orConnections: the phone's position clock is innowPlaying. - Tap to open the player — a
TapHandler, not aMouseArea, pushesMediaPlayer.qmlorMediaRoom.qmlaccording tosettingsManager.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 useMusicDeviceScenewhen 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.onArtClickedopensalbumArtPopup;onArtLoadFailedswaps in missing_art.png. - Blurred background — a
Gridof repeated art tiles (backgroundGrid= "Normal" 1×1, "2x2", "4x4") with oneMultiEffectblur applied to the whole grid ("16x cheaper on 4x4"),blurMax: 64,blur: backgroundBlurRadius / 64,layer.live: !_cardAnimBusyso the blur freezes during card animation,autoPaddingEnabled: falseso the halo cannot bleed under the BottomBar (the StackView does not clip), and a black overlay atbackgroundOverlayOpacity / 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 throughvolumeController.applyVolume()) and a "Phone" source pill, shown only while the phone has the controls, whose tap callsphoneMirrorManager.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.positionMsthrough aBindingpaused 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 withnowPlaying.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 fromnowPlaying.trackChanged(not after a source switch); local changes animate frommediaManager.currentMediaChanged, which also drives the carousel's bookkeeping. The "Phone" pill callsphoneMirrorManager.releaseMedia(true). - Waveform visualizer — visible when
showWaveformVisualizer && !playlistEmpty;audioAnalyzer.numBarsbars (fallback 96) as pill-shaped gradient rectangles in aRowwithlayer.enabledso 96 gradient draws collapse into one texture blit; fed byonFftDataChanged(levels), the analyzer follows what is playing by itself (file analysis at the playback position, or live PCM for the phone); the page only callsaudioAnalyzer.setViewActive(visible && !_cardAnimBusy). Both feed the same bars throughfftDataChanged. See Audio Analyzer. - Empty playlist —
playlistEmpty = !usePhone && localSongCount === 0hides 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 callsmediaManager.extract_colors_from_album_art()and offers "Save as Theme", which storesStyle.albumArtThemethroughsettingsManager.save_custom_theme(name, json, artPath). previewTrackTransition(direction, style)— runs one transition without a track change, temporarily overridingtransitionStyle; 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(frommediaManager.get_playlist_names()) feeds one dropdown popup; a 100 msdropdownCooldowntimer stopsCloseOnPressOutsidefrom 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 areSheetButtons; every key handler ends withnewPlaylistInput.forceActiveFocus(). - Forced rebinding —
playlistRefreshCounteris incremented by a 300 msalbumArtRefreshTimeronce 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 —
ListViewwithreuseItems: true,cacheBuffer: height * 0.5,displayMarginBeginning/End: dp(40), set oneoverallMarginbelow 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-artImagethe full height of the card, flush with its left edge, corners rounded with anOpacityMask(sourceSize1.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 intitleColumnRatio/artistColumnRatio/albumColumnRatio, shared by the header and the rows. The list usesboundsBehavior: Flickable.StopAtBounds. - Highlighting — the current song's card is filled with
App.Style.accentat 35 % alpha with a 2 px accent border and a left accent gradient; the title turns accent-coloured. (TheplayingHighlightColor/pausedHighlightColortokens inStyleare 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 pushesMediaRoom.qmlunlessreturnToLibraryAfterSelectionis set. A 600 ms long-press openssongActionSheet: 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-localSheetButtoninline component; delete uses the fixeddangerColorso it never matches the theme accent. No search field, no drag reorder. - Initial scroll —
calculateScrollPosition()estimates the current row's offset fromSpacing.mediaPlayerRowHeight, thenscrollToCurrentTrack()setscontentYonce the real list height is known (50 ms timer,onHeightChanged). While the page is showing, a track change (next / previous, track end;onCurrentMediaChanged) callsfollowCurrentTrack(), which animatescontentYover 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.
| Card | Subtitle | Action |
|---|---|---|
| Dashboards | Live gauges & parameters | pushSubpage("OBDMenu.qml") |
| Trouble Codes | Read & clear DTCs | pushSubpage("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 andparameterCardsEmptyexplains why (it also covers every other dashboard while disconnected, see above): "No vehicle connected" (plusobdConnectButton, the shared OBDConnectButton, "Connect Bluetooth OBD2", which opensOBDAdapterSheetto 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 ismax(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'sparamIdsas"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.obdCardStyleCircularproperty (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 msOutCubicwidth behaviour), or a circular card whose 270° background/value arcs areQtQuick.ShapesPathAngleArcs ("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 useOBDParameterModel.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).
Trends
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.
| Card | Subtitle | Behaviour |
|---|---|---|
| Sensors | Pitch, roll, heading, G-force | pushSubpage("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.)
| Key | Default | Effect |
|---|---|---|
sensorDecimalPlaces (0–4) | 1 | toFixed() 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>) | true | Card visibility; the temperature card is also hidden when berryIMU.hasTemperature === false |
sensorEmitRate | 60 | Not read here: BerryIMUManager follows settingsManager.sensorEmitRate from startup |
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" }atprobeExposure: 0.8; a keyDirectionalLight(brightness 1.3, ambient#777777) and a cool blue fill light (#C3D9FF, 0.7). No shadows.Node { id: cameraOrbit }rotated bycameraPitch/cameraYaw(defaults −25 / −35) carries aPerspectiveCamera(45° FOV) whose distance is the larger ofcameraDistance(200) and a value computed fromjeep.radiusand the view's aspect ratio to "fit the vehicle's rotation envelope even in a narrow view". AMouseAreaover 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" — containingJeepVehicle { 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) whosevideoSinkis handed to the manager inComponent.onCompleted(phoneMirrorManager.videoSink = videoSink); decoded frames land on it directly.frameCounteris bumped on everyframeReady("proof of life");hasVideo = frameCounter >= 1. - Touch — a
MultiPointTouchArea(1–10 points,mouseEnabled) maps each point through the letterboxedvideo.contentRectto 0..1 and callsphoneMirrorManager.injectTouch(pointId, action, relX, relY)with0pressed,2updated,1released/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 callsstopScrcpy()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..."), startsstartQrPairing()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 whenlastErrorKindis"noPhone"), and"starting"from there (the manager saw the phone and restarted) goes back to "Connecting…"."failed"(setup or fatal) shows the error, orgetInstallInstructions()whensetupOkis false. The page reportssetViewActive(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;
onVisibleChangedcallsembeddedDhu.detach()when the page is covered andreattach()when it is shown again, andStackView.onActivatedeither resumes an already-embedded DHU (androidAutoManager.isDhuEmbedded) or callslaunchDhuSeamless(). - 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) whosevideoSinkis handed toandroidAutoManager.videoSinkinComponent.onCompleted; shown whilenativeState === "running". - A
MultiPointTouchArea(androidAutoTouchArea, up to 10 points, mouse enabled) that maps each point through the video'scontentRectto 0..1 and callsinjectTouch(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..."),nativeStatusunder 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.onActivatedcallssetViewVisible(true)andstartNative();onVisibleChangedpasses visibility tosetViewVisible()(the phone pauses video while the page is hidden, audio continues). The "X" (androidAutoClose) callsstopNative()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"):
| Section | Page (settings/) | Hub widget (settings/widgets/) | Group | Sub-sections |
|---|---|---|---|---|
displaySettings | DisplaySettingsPage | DisplayWidget | Appearance | Layout, Appearance, Clock |
mediaSettings | MediaSettingsPage | MediaWidget | Appearance | Library & Playback, Now Playing |
obdSettings | OBDSettingsPage | OBDWidget | Connectivity | Connection, Adapters, Parameters |
accessoriesSettings (desktopOnly) | AccessoriesSettingsPage | AccessoriesWidget | Connectivity | Volume Knob, IMU Sensor, Gesture Sensor, Phone Dock |
deviceSettings | DeviceSettingsPage | DeviceWidget | System | Device, 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 |
about | AboutPage | AboutWidget | System | — |
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 file | settingsLayoutStyle | Shape |
|---|---|---|
| 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 betweenoriginRectandtargetX/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 ismax(0, (openProgress − 0.45) / 0.55)so "the user sees the card grow first and the controls resolve once there's room"; 320 msOutCubic, disabled for the first frame so the studio does not appear to open itself at launch. Main.qml keeps itvisibleuntilopenProgressreturns 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 (
backgroundGridNormal/2x2/4x4,backgroundBlurRadius,backgroundOverlayOpacity), ALBUM ART (roundedAlbumArt+albumArtCornerRadius,showAlbumArtShadow,vinylRecordMode,showWaveformVisualizer), 3D PREVIEW (show3DAlbumPreview,sideCardAngle,sideCardOpacity), ANIMATION & TEXT (textScrollSpeed,show3DButtonTilt,buttonTiltDuration) — built fromSettingCategory,SettingSubcategory,SettingsSlider,SettingsToggle(compact),SettingLabel,ValueDisplayandNowPlayingButtonGrid. Every control writes the matchingsettingsManager.save_*()immediately and mirrors changes back through aConnectionsblock. - PiP and strip — a
MediaRoomLivePreviewpinned top-right (height = 50 % of the studio, mindp(180)), with PRESETS (Minimal, Vinyl, Showcase 3D, Cinema — each a dict fanned out throughapplyPreset()) 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).
Navigation Summary
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).