Bottom Bar
The persistent navigation and media control bar: layout, dual orientation, fitting any window size, volume routing through VolumeController, navigation button behaviour, the clock, and the update-available indicator
Overview
frontend/BottomBar.qml is the persistent control surface that is visible on every page. It is a Rectangle with a themed gradient background, docked along the bottom of the window or, as a side bar, along the left. It carries three groups: media transport controls, navigation buttons, and the clock. One set of controls serves both orientations: where each one goes is computed by frontend/BottomBarLayout.js from the bar's own size (see Fitting Any Size), so nothing is written out twice and nothing is sized from fixed percentages of the window.
It is instantiated once in frontend/Main.qml with z: 0; the page StackView sits above it (z: 1) and anchors to bottomBar.top (horizontal) or bottomBar.right (vertical). A 12 px gradient Rectangle (bottomBarShadow / sideBarShadow) in Main.qml draws a soft shadow along the bar's inner edge.
Horizontal layout (bottomBarOrientation = "bottom"):
+--------------------------------------------------------------------------------+
| [<<] [>||] [>>] [Shuf] [Mute] 50% [Home] [OBD] [Media] [Sensor] [Gear] [AA] [PM] 12:34 |
+--------------------------------------------------------------------------------+
media, packed from the left nav, centred on the bar clock, at the right
Vertical layout (bottomBarOrientation = "side"):
+----------+
| [<<][>||][>>] media rows, at the top
| [Shuf][Mute]
| 50% |
| |
| [Home] | nav, centred on the bar
| [OBD] |
| ... |
| [PM] |
| |
| 12:34 | clock, at the bottom
+----------+
Layout and Structure
The root is a Rectangle whose gradient direction follows the orientation. Note the unusual stop positions — the "start" colour is placed at 1.5, beyond the end of the gradient, so the visible bar is dominated by bottomBarGradientEnd (the theme's baseAlt) fading toward base.
Every control is a BarSlot (an inline component): the item itself covers the control's touch area (geo.hits.<name>) and a faceItem inside it covers the drawn part (geo.items.<name>). MediaButton builds on it for previous, play, next, shuffle and mute; the volume % and the clock are plain BarSlots; the nav buttons are a Repeater over navSections (home, obd, media, sensor, settings, plus androidAuto / phoneMirror when enabled). Each nav button's icon and action come from the navButtons table and its colour from navColor().
readonly property var geo: BarLayout.compute({
vertical: isVertical, width: width, height: height,
navCount: navSections.length,
showMedia: settingsManager.showBottomBarMediaControls,
showClock: settingsManager.showClock,
clockAuto: clockSizeAuto, clockPx: manualClockSize,
clockWidthPerPx: clockMetrics.advanceWidth / 100, ... // text measured at 100 px
minUnit: Math.max(24, 0.045 * Math.min(_parentW, _parentH)),
scale: App.Spacing.globalScale
})
geo is one binding, so it is recomputed only when one of its inputs changes (a resize, an orientation switch, a setting, the clock format).
Like every other QML file in the project, the bar defines local dp() / dpMin() wrappers (Math.round(size * (App.Spacing.effectiveScale || 1.0))) instead of calling App.Spacing.dp(). The comment at the top of the file explains why: calling functions on a QML singleton is unreliable on Android, so every file re-implements the two-line helper locally.
Dual Orientation
The isVertical property is bound to the persisted setting (Display → Layout → "Nav Bar Position", a SettingsSegmentedControl with the options "bottom" and "side"):
property bool isVertical: settingsManager &&
settingsManager.bottomBarOrientation === "side"
The bar places and sizes itself with bindings, with no anchors and nothing assigned from a handler, so a resize or an orientation switch can't leave a stale size behind. Its thickness follows the window, within limits:
x: 0
y: isVertical ? 0 : _parentH - height
width: isVertical ? thickness : _parentW
height: isVertical ? _parentH : thickness
thickness: isVertical
? max(min(10% of width, 16% of height), min(20% of width, dpMin(72, 56)))
: max(min(12.5% of height, 9% of width), min(20% of height, dpMin(56, 44)))
The first term keeps the bar in proportion and stops it becoming a thick slab on a portrait or ultrawide window; the second keeps it touchable on a small one. On the Jeep's 1280×800 screen this is 100 px along the bottom or 128 px at the side.
Main.qml reacts to the same signal: it flips isVerticalLayout, re-asserts the z-order (stackView.z = 1, bottomBar.z = 0) and, if the Settings page is open, restarts navReloadTimer (150 ms) which replaces the current SettingsMenu with a fresh instance on the same section and open tile (initialTile) so the settings layout re-measures against the new content area.
Fitting Any Size
frontend/BottomBarLayout.js is one pure function, compute(params), that returns a rect for every control's face and touch area in bar coordinates. Everything is a multiple of one unit, the nav button's size, so the bar grows and shrinks as a whole and keeps its proportions:
| Control | Size (units) |
|---|---|
| Nav button | 1 × 1 (side bar: up to 1.4 wide, never wider than the bar) |
| Previous, Play, Next, Shuffle, Mute | 1 × 1, as a nav button (side bar: square, three to a row) |
| Icon in a nav or media button | 0.7 of the button |
| Volume % | 0.36 font size |
| Clock (Auto) | digits 0.7 tall (as the icons), may give way to 0.45; its face is 1 tall |
| Gaps: margin / between groups / between buttons | 0.3 / 0.6 / 0.3, each with a smaller minimum; media buttons use the nav gap |
The ideal unit comes from the bar's thickness (68 % of a horizontal bar's height; the side bar's width / 1.4) times the UI scale. The side bar sets prev / play / next on one row, so their unit is also capped by the bar's width. Along the bar's length the controls are fitted in steps:
- At the ideal unit with comfortable gaps. The nav group sits at the bar's centre whenever the media and clock groups leave room for that, otherwise between them.
- Gaps tighten: margins and gaps between groups first, then gaps inside a group. Then the Auto clock gives up some size.
- Everything scales down together.
- If that would take the nav buttons below
minUnit(4.5 % of the window's shorter side, at least 24 px), controls are dropped in order: shuffle, volume %, mute, prev / play / next, clock. A control that would be too small on its own is dropped by itself (the clock undermax(12, 0.35 × minUnit)px, prev / play / next across a narrow side bar). Nav buttons are never dropped.
A manual clock size (Auto off) is a ceiling: the clock is never taller than 80 % of the bar, never wider than a side bar, and shrinks with everything else when room runs out.
Touch Areas
Each control's touch area is its face grown by half a unit along the bar and by the full thickness across it, clipped to the bar. Any two that would overlap are cut at the midpoint between their faces, so neighbouring controls never share a touch point and every press lands on exactly one control. Within those limits the areas are as large as they can be, which matters on a car touchscreen.
Tested Promises
tests/cpp/tst_bottombarlayout.cpp runs compute() in a QJSEngine over a grid of sizes (horizontal 320–3840 × 32–220 px, side 40–320 × 240–2160 px), 5 and 7 nav buttons, and every combination of media / clock / Auto clock. At each one it checks that every face and touch area lies inside the bar, that no two faces and no two touch areas overlap, and that each face lies inside its own touch area. It also checks a UI scale of 0.5–2, a 500 px manual clock, a zero-size bar, that 1280×800 keeps every control at full size, that shuffle is the first thing dropped, that the nav buttons are one size and evenly spaced, and that along the bottom the media buttons match the nav buttons in size and step and the Auto clock in height. Change the proportions in BottomBarLayout.js and run ctest -R tst_bottombarlayout.
Media Transport Controls
The media group is off by default: a fresh install (and the browser demo) shows only the nav buttons and the clock, and Settings > Display > Layout > Nav Bar Media Controls (showBottomBarMediaControls) turns it on. A settings file that already saved the old default keeps it. The media group contains, in order: Previous, Play/Pause, Next, Shuffle, Mute and the volume percentage. Each transport button is a MediaButton, drawn as a nav button is: the same size, gap and accent outline, with a tinted SVG (TintedIcon, see SVG Icon Pattern) at 0.7 of the face, inside a face that squishes while pressed (see Button Animation Pattern), and a MouseArea over the whole touch area:
MediaButton {
face: bottomBar.geo.items.prev; hit: bottomBar.geo.hits.prev
source: "./assets/previous_button.svg"
color: App.Style.bottomBarPreviousButton
onTapped: bottomBar.mediaPrevious()
}
Play/Pause Button
The play/pause button binds source: bottomBar.playIcon, a property over nowPlaying.playing, which follows whichever source owns the music (the phone while mirroring, Android Auto, else local media). No Connections are needed:
readonly property string playIcon: (_np && _np.playing) ? "./assets/pause_button.svg" : "./assets/play_button.svg"
Volume Slider and Mute
Volume Label and Popup
The bar itself only shows the percentage as bold text (bottomBar.volumePercent + "%", primaryTextColor, sized by the layout: geo.volumePx). Tapping it opens popupSlider, a modal Popup as wide as the window and dp(140) tall (at most half the window), centred in Overlay.overlay, closed by Escape or by pressing outside. Its background is backgroundColor with a 1 px accent border. Inside: a dp(32) accent-coloured percentage label and a Slider:
Slider {
id: volumeSlider
width: parent.width * 0.95
height: dp(50)
from: 0; to: 100; stepSize: 1
value: bottomBar.volumePercent
background: Rectangle { // dp(16) tall track, hoverColor, volumeSliderColor fill
...
}
handle: Rectangle { // dp(40) circle in accent
...
}
// Single dispatch to settings + every audio output; raising it
// while muted unmutes (VolumeController)
onValueChanged: volumeController.applyVolume(Math.round(value))
}
Volume Routing: VolumeController
The bar never computes the volume curve and never calls mediaManager.setVolume() directly. Every change goes through volumeController.applyVolume(percent), a QML context property registered in src/main.cpp. The curve is quadratic: linear = (percent / 100)².
VolumeController (src/managers/volumecontroller.{h,cpp}) clamps the percent to 0–100, calls SettingsManager::setCurrentVolume(percent), then emits volumeApplied(percent, linear). src/main.cpp connects that signal to mediaManager.setVolume(linear), phoneMirrorManager.setVolume(linear) and (desktop builds) esp32VolumeManager.send_volume_update(percent), so the ESP32 LED ring gets the raw percent and the audio outputs get the curved value.
src/main.cpp applies the startup volume to every output before QML loads. The bar reads the level back from volumeController.percent (bottomBar.volumePercent is a read-only binding to it), and a Connections on volumeController.percentChanged moves the slider to a level set anywhere else (knob, gesture, media room), but only while the slider is not being dragged (!volumeSlider.pressed). No curve math happens in QML.
VolumeController::toLinear()) and every source — QML sliders, the gesture sensor, the ESP32 knob — funnels through applyVolume(). See the Frontend Overview and Media Manager pages for the consumers.
Touch-Friendly Slider
Nine dp(80) square MouseAreas are laid along the track at index * (volumeSlider.width / 8), each mapping its press position back to a slider value. preventStealing: false lets the slider itself still receive the events. This turns a thin 16 px track into a band of large targets:
Repeater {
model: 9
MouseArea {
x: (index * (volumeSlider.width / 8)) - width/2 + volumeSlider.x
y: volumeSlider.y + volumeSlider.height/2 - height/2
width: dp(80); height: dp(80)
onMouseXChanged: {
if (pressed) {
var globalX = mapToItem(volumeSlider, mouseX, mouseY).x
var newPosition = Math.max(0, Math.min(1,
(globalX - volumeSlider.leftPadding) / volumeSlider.availableWidth))
volumeSlider.value = newPosition * 100
}
}
preventStealing: false
}
}
Mute Button
Tapping Mute calls volumeController.toggleMute(); the rules live there (VolumeController). At 0 % the tap restores the startup volume (toPercent(startUpVolume), e.g. 32 % for the default 0.1) and unmutes instead of toggling; presses within 150 ms of the last are ignored. Mute is in memory only, never saved.
// At 0 % this restores the startup volume instead
onClicked: volumeController.toggleMute()
Mute Icon States
bottomBar.muteIcon, a binding, picks the SVG from frontend/assets/. Muted wins over any level; the level thresholds are strict < comparisons:
| Condition | Icon file |
|---|---|
volumeController.effectivelyMuted (muted, or volume 0) | mute_on.svg |
| volume < 33 | mute_off_low.svg |
| volume < 66 | mute_off_med.svg |
| 66 and above | mute_off_high.svg |
The icon is tinted with bottomBarVolumeButton. It follows volumeController.percent and effectivelyMuted through the binding.
Navigation Buttons
The nav group holds up to seven buttons, each a transparent rectangle (corner radius 14 % of its shorter side) bordered in accent with a tinted SVG at 70 % of its shorter side. They are square on the bottom bar and up to 1.4× as wide as tall on the side bar.
| Button | Icon / colour token | Behaviour on tap |
|---|---|---|
| Home | home_button.svg / bottomBarHomeButton |
Normally pops to the root. When Settings owns an open wiki reader, openHomeSection() keeps that page on the stack and opens or reuses a MainMenu above it so the reader can resume later. |
| OBD | obd_button.svg / bottomBarOBDButton |
openOBDSection(): reads stackView.currentItem.objectName. On "obdHome": nothing. On any page whose name starts with "obd": pops until obdHome is on top (opens OBDHome.qml if it was not on the stack). From anywhere else: opens the remembered subpage stored in the generic setting lastOBDPage (written by OBDHome.qml), falling back to OBDHome.qml if none is stored or it fails to load. While no adapter is connected (obdManager.connected false) it skips the remembered subpage and opens OBDHome.qml, which is then only the connect prompt. |
| Media | media_button.svg / bottomBarMediaButton |
openMediaSection(): on mediaRoom opens MediaPlayer.qml; on mediaPlayer opens MediaRoom.qml; otherwise opens the page named by settingsManager.musicButtonDefaultPage ("mediaRoom" or "mediaPlayer", the "Default to Library View" toggle in Media settings). |
| Sensor | sensor_button.svg / bottomBarSensorButton |
openSensorSection(): on sensorHome nothing; on a page named sensor* or carMenu pops back to Sensor Home; from elsewhere opens the generic setting lastSensorPage (written by SensorHome.qml). If that page fails to load (for example CarMenu.qml when QtQuick3D is missing) the stale preference is cleared and SensorHome.qml is opened instead. Every failure is logged with console.warn by openPage(). |
| Settings | settings_button.svg / bottomBarSettingsButton |
If the current item is settingsMenu, calls handleSettingsButton(): an open wiki reader closes back to About; otherwise it calls navigateToHub() (returns the Hub / Dashboard layouts to their card grid, closes the open card in the default grid layout, a no-op for Carousel). If an open wiki reader is preserved below the current page, it pops back to that Settings instance and restores the reader. Otherwise it pops to root and opens the cached SettingsMenu.qml (built the first time with initialSection: lastSettingsSection, a Main.qml property bound to settingsManager.lastSettingsSection; on later visits the page simply keeps the section it was on). Press-and-hold (onPressAndHold) opens the Toggle View popup instead. |
| Android Auto | android_auto_button.svg / bottomBarAndroidAutoButton |
Pushes AndroidAutoView.qml. Visible only when settingsManager.androidAutoEnabled and not on Android (typeof isAndroid === "undefined" || !isAndroid). |
| Phone Mirror | phone_mirror_button.svg / bottomBarPhoneMirrorButton |
openPhoneMirrorSection() → stackView.openPage("PhoneMirrorView.qml"): pops back to the cached view if it is on the stack (so the running mirror session is reused) or pushes it. Pressed while the mirror view is already showing and mirrorRunning, it calls phoneMirrorManager.pressHome() instead, so the button doubles as the phone's Home button. Same visibility rule as Android Auto, keyed on phoneMirrorEnabled. |
Navigation helpers use stackView.openPage() or pop to an existing page (see the page cache): the page is built once with stackView and mainWindow supplied by cachedPage(), and every later press re-pushes or pops back to that same instance. Each nav button's MouseArea only calls its open*Section() function on bottomBar, looked up in navButtons. They fire on onPressed, not onClicked, so a page changes on touch-down instead of waiting ~100 ms for the finger to lift. The switch is delayed by about two frames so the press animation is on screen first; see Button Animation Pattern. Settings keeps onPressAndHold, so holding it opens Settings and then the Toggle View popup on top.
Active Page Highlight
The button for the page you are on gets a faint accent-tinted fill (18% opacity) inside its outline. bottomBar.activeSection maps stackView.currentItem.objectName to a section: mainMenu → home, obd* → obd, mediaRoom / mediaPlayer → media, sensor* / carMenu → sensor, settingsMenu → settings, androidAutoView → androidAuto, phoneMirrorView / airPlayView → phoneMirror. Any other page leaves every button unlit. Each nav button has its own plate, drawn behind its face but outside it, so the press bounce leaves the plate still. On a page change the new button's plate grows out from its centre while the old one shrinks away (160 ms scale, OutCubic, plus a 140 ms fade). A new page only lights a button if its objectName matches one of these rules.
Update Notification
The Settings button carries an orange dot (14 % of the button's shorter side, at least 6 px) (#FF9800) anchored to its top-right corner, visible while networkManager.updateStatus === "update-available". The pulse animation is bound to the same condition through running, so it costs nothing while hidden:
Rectangle {
width: Math.max(6, Math.min(parent.width, parent.height) * 0.14); height: width; radius: width / 2
color: "#FF9800"
visible: navSlot.modelData === "settings" && networkManager && networkManager.updateStatus === "update-available"
SequentialAnimation on opacity {
running: networkManager && networkManager.updateStatus === "update-available"
loops: Animation.Infinite
NumberAnimation { from: 1.0; to: 0.3; duration: 1200 }
NumberAnimation { from: 0.3; to: 1.0; duration: 1200 }
}
}
The same dot, with the same colour and 1200 ms cadence, is repeated inside the settings UI on the "About" entry of every layout (carousel tab, hub card, dashboard card; the default grid's About tile shows a static dot) and on the About page's "Check for Updates" button, so the user can follow it all the way to the update controls. updateStatus is produced by NetworkManager (see Network Manager); the other values it can take are "checking", "up-to-date", "ahead", "diverged" and "error", none of which show the dot.
Clock Section
The clock is a Text fed by the clock context property (Clock emits timeChanged(QString); the format and seconds options live in Display → Clock). It uses clockTextColor (the theme accent), the global font, and automatic sizing by default (clockSizeAuto): its digits stand as tall as the nav icons (0.7 of the unit, measured from the font's own digit height via TextMetrics.tightBoundingRect, so a custom font with a tall or short line height still lines up), giving way when room is short (see Fitting Any Size). With Auto off, settingsManager.clockSize (default 18, slider 10–85 px) is used as a ceiling: the clock never grows past the bar. Tapping the clock opens ClockMenu.qml (openClockSection()). A press highlight uses hoverColor.
The text binds bottomBar.clockTimeText, which binds clock.time, so it shows the current time from the first frame. The layout measures the text with every digit replaced by "8" (and "88:88 PM" before the first tick), so the bar does not shift as the minutes change.
Shuffle State
isShuffleEnabled on the bar root is the single source of truth for the shuffle button. It is initialised from mediaManager.is_shuffled() and kept current by mediaManager.shuffleStateChanged(enabled). While the phone has the controls the shuffle button does nothing (the phone's shuffle belongs to its app).
When active, the button background becomes bottomBarToggleShade (corner radius 12 % of its width) and the icon is tinted bottomBarActiveToggleButton (the accent). When inactive the icon uses bottomBarShuffleButton, the per-theme bottombar.shuffle colour.
External Volume Sources
Volume can change without the slider being touched. All of these end in volumeController.applyVolume() on the C++ side, so the bar simply reacts to volumeController.percentChanged; turning the volume up from any of them while muted unmutes:
| Source | Path |
|---|---|
| ESP32 rotary encoder | esp32VolumeManager.volumeChangeRequested(delta) → applyVolume(currentVolume + change) (wired in src/appcore.cpp). The knob's mute button calls volumeController.toggleMute() and the bar's icon follows effectivelyMutedChanged. |
| PAJ7620U2 gesture sensor | Up/down gestures call applyVolume(currentVolume ± step) with gestureVolumeStep. |
| Startup | currentVolume (from startUpVolume) is applied once by src/main.cpp. |
Manager Communication
Every media action goes through nowPlaying, which routes it to the phone or Android Auto while one of them owns the music and to the local player otherwise; useRemote (nowPlaying.source !== "local") only dims the shuffle button, and isShuffleEnabled binds nowPlaying.shuffled:
mediaPrevious()→nowPlaying.previous()mediaTogglePlay()→nowPlaying.togglePlay()mediaNext()→nowPlaying.next()mediaToggleShuffle()→nowPlaying.toggleShuffle()(does nothing while the phone or Android Auto has the music)
Mute and volume feedback always come from mediaManager (the local player is the audio device that is actually muted). The context properties the bar depends on — settingsManager, mediaManager, nowPlaying (media), phoneMirrorManager (optional, checked with typeof; the home button while mirroring), volumeController, networkManager, clock, and the optional isAndroid flag — are registered by src/main.cpp.
Toggle View Popup
At the bottom of the file a modal Popup named settingsVisibilityPopup ("Toggle View") lists six settings sections (Device, Media, Display, OBD, Accessories, About) with animated switches bound to settingsManager.is_settings_section_visible(section) and writing save_settings_section_visibility(section, state). The Settings nav button's MouseArea calls settingsVisibilityPopup.open() from onPressAndHold, while a plain tap keeps the navigate / navigateToHub() behaviour described above. The visibility map is honoured by SettingsMenu.buildHubModel() (see Settings UI → Section Visibility).
Sizing Tokens
The bar's thickness reads App.Spacing.bottomBarHeightPercent (0.125 of the window height, see Dual Orientation); everything inside it is sized by frontend/BottomBarLayout.js (see Fitting Any Size), not by Spacing.qml. The user's UI scale (App.Spacing.globalScale) scales the unit, within what the bar can hold. The settings pages use App.Spacing.bottomBarHeight as bottom padding.
Button Animation Pattern
Transport buttons (previous, play, next, shuffle, mute) track the press: scale to 0.8 with an Easing.OutBack overshoot of 1.1 over 200 ms, and opacity to 0.7 over 150 ms, while the finger is down:
scale: mouseArea.pressed ? 0.8 : 1.0
Behavior on scale {
NumberAnimation { duration: 200; easing.type: Easing.OutBack; easing.overshoot: 1.1 }
}
opacity: mouseArea.pressed ? 0.7 : 1.0
Behavior on opacity { NumberAnimation { duration: 150 } }
Navigation buttons use PressBounce instead, an inline component in BottomBar.qml. Each button's onPressed calls restart() on it, and it scales the button's face 1.0 → 0.8 (90 ms, OutQuad) → 1.0 (220 ms, OutBack 1.1), with opacity 1.0 → 0.7 → 1.0. It is built only from ScaleAnimator / OpacityAnimator, so under the threaded render loop the whole bounce runs on the render thread and keeps moving while the GUI thread is busy switching pages. The pressed-bound Behavior version froze for as long as a page build took (up to ~350 ms for CarMenu before pre-building finished), and a press and release handled in the same stall never showed the squish at all. The trade-off is that the bounce plays in full on every tap and does not hold while the finger stays down.
The page switch itself runs through bottomBar.navAfterFrame(fn), which waits for two frameSwapped signals, or an 80 ms fallback, before calling the open*Section() function. Two frames, because the first swap after a press can belong to a frame synced before it. By then the bounce has been handed to the render thread. The cost is about two frames of latency.
SVG Icon Pattern
All icons are flat single-fill SVGs from frontend/assets/. The Image is hidden and used only as the source of a ColorOverlay (Qt5Compat.GraphicalEffects), which recolours every opaque pixel with a theme token:
Image {
id: iconImage
source: "./assets/home_button.svg"
sourceSize: Qt.size(width * 2, height * 2) // 2x supersample
fillMode: Image.PreserveAspectFit
smooth: true; antialiasing: true; mipmap: true
visible: false
}
ColorOverlay {
anchors.fill: iconImage
source: iconImage
color: App.Style.bottomBarHomeButton
}
Because every token in Style.qml has a Behavior on colour animation, the icons cross-fade when the theme changes (see Theme System → Colour Transitions). In the bar the pair lives in one inline component, TintedIcon, with the sourceSize kept at least 1 px so a control at zero size does not load the SVG at its natural size.
Design Decisions
| Decision | Rationale (from code comments and commit history) |
|---|---|
| One set of controls placed by a pure function, for both orientations | The bar used to be two complete layouts (horizontal and vertical) with fixed section percentages (40 % / fill / 20 %), button sizes taken from the window's shorter side rather than the bar, and touch areas up to 3× the button that overlapped their neighbours. At some sizes controls ran into each other: on the 1280×800 side bar the prev / play / next row was 163 px wide in a 128 px bar. Placing every control from one function of the bar's size makes "nothing overlaps, nothing leaves the bar" a property that a test can check over hundreds of sizes, and leaves one copy of every control (2,620 lines down to about 960). |
| Size and place the bar with bindings, not anchors set from handlers | The old updateLayout() (and a twin in Main.qml) assigned anchors, width and height from onIsVerticalChanged and resize handlers, which broke the bindings; after an orientation switch the side bar could be left 0 px wide. |
minUnit in proportion to the window, not dp() |
dp() follows the screen (screenAutoScale), so a small window on a large monitor would drop controls it had room for. |
Local dp()/dpMin() wrappers |
"Work around Qt Android singleton-function bug" — singleton functions are unreliable on Android, so only singleton properties (App.Spacing.effectiveScale) are read across the boundary. |
All volume changes go through volumeController.applyVolume() |
Commit d2b047b ("volume curve de-duplication") replaced the per-site Math.pow(value/100, 2) and the four manual setVolume calls with one dispatcher. Changing the curve is now a one-line edit in toLinear() and every output stays in sync. |
| Nine circular touch areas on the popup slider | A 16 px track is impractical to grab while driving; the overlapping dp(80) areas turn the whole band into a target while leaving the Slider functional. |
| Mute at 0 % restores the startup volume | Comment: "If volume is 0, restore to startup volume instead of toggling mute". Toggling mute on a silent output would do nothing visible. |
| Sensor button remembers and validates the last subpage | Comments and commit 0c29e64 ("Fix sensor menu dead-end"): a stored lastSensorPage that no longer loads (CarMenu needs the optional QtQuick3D module) used to make the button appear dead. The failure is now logged, the preference cleared, and Sensor Home shown. |
| Second tap on Settings returns to the hub | The Hub and Dashboard layouts have a card-grid landing state; navigateToHub() gives the nav button a purpose while already in Settings. The section-visibility popup moved to press-and-hold on the same button (see above), so a tap never opens a modal by surprise. |
| Phone Mirror pops back to an existing view | Comment: "Check if PhoneMirrorView is already on the stack - if so, pop back to it". Pushing a second view would start a second mirroring session over the live one. |
Percent from volumeController, not mediaManager.volumeChanged |
The player reports linear volume; the bar used to invert the curve itself (sqrt) in six places across QML. VolumeController.percent is the number the user set, and toPercent() is the one inverse. |
| StackView transitions disabled in Main.qml | Comment: "Disable transitions for better performance" — every push from the bar is instant, which matters on the Raspberry Pi / Orange Pi head units. Together with the page cache this also makes StackView.onActivated fire synchronously inside push(). |