diff --git a/BACKLOG.md b/BACKLOG.md index 03cb8cd..d865490 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2,17 +2,16 @@ Post-v1 features, intentionally deferred. -## Experimental model work (Inspire XH1 pilot) +## Protocol capture tooling (in-app Capture Studio) -- [ ] **Verify XH1 BLE UUIDs** — candidate UUIDs from APK analysis (`0000ae0x` family); confirm with nRF Connect on real hardware -- [ ] **Verify XH1 ANC wire format** — opcode 0x68 candidate from APK; confirm mode bytes (0x08/0x09/0x0A for Commute/Outdoor/Indoor) via Frida hook -- [ ] **Implement XH1 ANC SET** — once wire format confirmed, wire `execute_command` for adaptive modes -- [ ] **XH1 HomeTab** — show single headphone battery card instead of L/R/case grid -- [ ] **Promote XH1 from Experimental → Verified** — after ANC + battery confirmed by an owner +The APK-derived Inspire XH1/XP1/XC1 models were removed (never hardware-verified). The +replacement is tooling that lets real owners capture and contribute *verified* support: -## Other Baseus models +- [ ] **In-app Capture Studio** — a dev mode in the desktop app: scan any BLE device, auto-detect notify/write chars, live hex log, guided "toggle ANC now → captured N frames", export a shareable capture bundle. (Owns its own design spec.) +- [ ] **Declarative model format** — define models as data (UUIDs, opcode→event map, frame layout) read by a generic decoder, so adding a device is data + a golden test, not a Rust module. The Capture Studio can auto-draft it. +- [ ] Re-add community-contributed models through the above, each verified on real hardware. -- [ ] Add more models using `tools/extract_apk_model.py` — the workflow is now established; any contributor can add a model as a draft PR +## BP1 Pro ANC remaining work ## BP1 Pro ANC remaining work diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e044702 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project are documented here. Format loosely follows +[Keep a Changelog](https://keepachangelog.com/); versions match the git tags. + +## [0.3.0] — 2026-07-03 + +### Added +- **Game / low-latency mode** for the BP1 Pro — a toggle that sends `BA 24 01/00` + and tracks state from the device's `AA 23` confirmation. Community-verified over + both SPP and BLE in [#3](https://github.com/elaxptr/baseus-desktop/issues/3). +- **UX motion pass** — an animated game-mode switch (sliding knob + accent halo), + battery rings that sweep and count up, a gliding sidebar indicator, spring tab + transitions, and hover/press/ripple micro-interactions. All motion respects + `prefers-reduced-motion`. + +### Changed +- ANC ack handling is now firmware-tolerant: some units ack every ANC command with + a flat `AA 34 01` (including Off), so state is resolved against the last commanded + mode rather than trusting the ack payload as a mode value ([#3]). +- Numeric readouts (battery %, ANC strength, session timer) use a monospace face. + +### Removed +- **Inspire XH1 / XP1 / XC1 support.** These were extracted from the Baseus Android + APK and never confirmed on real hardware. Rather than ship unverified promises, + they've been removed. The model registry is kept (BP1-only) as the extension point + for future owner-contributed, hardware-verified models — see `BACKLOG.md` for the + planned in-app Capture Studio and declarative model format. + +[0.3.0]: https://github.com/elaxptr/baseus-desktop/releases/tag/v0.3.0 + +## [0.2.1] and earlier + +See the git history and release tags (`v0.2.1`, `v0.2.0`, `v0.1.0`). This changelog +was introduced in 0.3.0. diff --git a/Cargo.lock b/Cargo.lock index b667efc..1c8dd43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,7 +247,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "baseus-app" -version = "0.2.1" +version = "0.3.0" dependencies = [ "baseus-protocol", "baseus-transport", diff --git a/README.md b/README.md index d8a8e0e..b97dbd5 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,24 @@ the official Baseus Android app. ## Supported hardware -| Model | Status | Battery | ANC | EQ | -|---|---|---|---|---| -| Bass BP1 Pro ANC | ✅ Verified | ✅ L/R/case | ✅ 3-mode + strength | ✅ 4 presets | -| Inspire XH1 | 🧪 Experimental | ⚠ Untested | ⚠ 5-mode adaptive (untested) | — | -| Inspire XP1 | 🧪 Experimental | ⚠ Untested | ⚠ 3-mode (untested) | ⚠ Untested | -| Inspire XC1 | 🧪 Experimental | ⚠ Untested | ⚠ 3-mode (untested) | ⚠ Untested | - -**Experimental** means the protocol was extracted from the Baseus Android APK via static analysis — -no physical device has been tested. If you own any of these, install the app and -[report what works](https://github.com/elaxptr/baseus-desktop/issues). See -[docs/protocol/inspire-xh1.md](docs/protocol/inspire-xh1.md) for the full protocol draft. +| Model | Status | Battery | ANC | EQ | Game mode | +|---|---|---|---|---|---| +| Bass BP1 Pro ANC | ✅ Verified | ✅ L/R/case | ✅ 3-mode + strength | ✅ 4 presets | ✅ Low-latency toggle | + +Only hardware-verified models are supported. Earlier drafts included Inspire XH1/XP1/XC1 +support extracted from the Baseus Android APK, but since none was ever confirmed on a real +device it has been removed rather than ship promises we can't back. + +**Own a different Baseus model?** Adding it is the goal — see +[docs/re-methodology.md](docs/re-methodology.md) for how to capture your device's protocol +and contribute it back. Protocol capture tooling to make this much easier is on the roadmap. ## Features - Live L / R / case battery with charge state indicators - Session timer (time since buds connected) - ANC mode switching (Off / Active Noise Cancellation / Transparency) with strength slider +- Game / low-latency mode toggle - EQ preset selection (Balanced / Bass Boost / Voice / Clear) - Find-my-buds (plays a tone on one earbud) - Low-battery desktop notifications diff --git a/apps/baseus-app/package.json b/apps/baseus-app/package.json index 8b28a25..f336e39 100644 --- a/apps/baseus-app/package.json +++ b/apps/baseus-app/package.json @@ -1,6 +1,6 @@ { "name": "baseus-app", - "version": "0.1.0", + "version": "0.3.0", "description": "", "type": "module", "scripts": { diff --git a/apps/baseus-app/src-tauri/Cargo.toml b/apps/baseus-app/src-tauri/Cargo.toml index 91bc6a8..385c7fb 100644 --- a/apps/baseus-app/src-tauri/Cargo.toml +++ b/apps/baseus-app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baseus-app" -version = "0.2.1" +version = "0.3.0" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/apps/baseus-app/src-tauri/src/commands.rs b/apps/baseus-app/src-tauri/src/commands.rs index 966392b..20dc6ca 100644 --- a/apps/baseus-app/src-tauri/src/commands.rs +++ b/apps/baseus-app/src-tauri/src/commands.rs @@ -15,12 +15,6 @@ pub fn set_anc_mode( "off" => AncMode::Off, "anc" => AncMode::Anc, "transparency" => AncMode::Transparency, - // XH1 adaptive modes — command is accepted but will no-op in execute_command - // (wire format unverified). Logged server-side for debugging. - "adaptive_self" => AncMode::AdaptiveSelf, - "adaptive_indoor" => AncMode::AdaptiveIndoor, - "adaptive_outdoor" => AncMode::AdaptiveOutdoor, - "adaptive_commute" => AncMode::AdaptiveCommute, other => return Err(format!("unknown mode: {other}")), }; let byte = level.unwrap_or(0x68); diff --git a/apps/baseus-app/src-tauri/src/device.rs b/apps/baseus-app/src-tauri/src/device.rs index ecacf81..884b5c0 100644 --- a/apps/baseus-app/src-tauri/src/device.rs +++ b/apps/baseus-app/src-tauri/src/device.rs @@ -2,8 +2,8 @@ use std::time::Duration; use baseus_protocol::{ framing::Frame, - models::{bp1_pro_anc::Bp1ProAnc, inspire_xh1::InspireXh1}, - types::{AncMode, BaseusModel, DeviceEvent, EqPreset, ModelStatus}, + models::bp1_pro_anc::Bp1ProAnc, + types::{AncMode, BaseusModel, DeviceEvent, EqPreset}, }; use baseus_transport::win::ble::GattTransport; use serde::Serialize; @@ -45,7 +45,6 @@ pub fn command_channel() -> (CommandSender, CommandReceiver) { #[derive(Debug, Clone, Serialize)] pub struct ModelInfo { pub name: &'static str, - pub status: ModelStatus, } pub async fn run_loop(app: AppHandle, mut cmd_rx: CommandReceiver) { @@ -87,7 +86,6 @@ pub async fn run_loop(app: AppHandle, mut cmd_rx: CommandReceiver) { "model-info", &ModelInfo { name: connected_model.display_name(), - status: connected_model.status(), }, ); @@ -130,11 +128,7 @@ async fn notification_loop( tracing::debug!("raw notification: {}", hex(&data)); if let Ok(frame) = Frame::decode(&data) { let event = match model { - // XP1/XC1 share the same earbud protocol as BP1 (same 0x0C ANC - // family, same battery framing) — APK-extracted, unverified. - BaseusModel::Bp1ProAnc - | BaseusModel::InspireXp1 - | BaseusModel::InspireXc1 => { + BaseusModel::Bp1ProAnc => { if frame.cmd == 0x34 { // Ack payload semantics vary by firmware (issue #3): // some units send a flat AA 34 01 for every ANC command, @@ -153,15 +147,6 @@ async fn notification_loop( } } } - BaseusModel::InspireXh1 => { - match InspireXh1::decode_frame(&frame) { - Ok(ev) => Some(ev), - Err(e) => { - tracing::debug!("unhandled frame cmd={:#04x}: {e}", frame.cmd); - None - } - } - } }; if let Some(ev) = event { maybe_alert_battery(app, &ev, &mut thresholds); @@ -236,56 +221,28 @@ async fn execute_command( model: BaseusModel, ) -> Result<(), String> { let bytes: Vec = match (cmd, model) { - // BP1 / XP1 / XC1 — verified for BP1; XP1/XC1 assumed same protocol (APK-extracted). - ( - DeviceCommand::SetAncMode(AncMode::Off, _), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x34, 0x00, 0xFF], - ( - DeviceCommand::SetAncMode(AncMode::Anc, level), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x34, 0x01, *level], - ( - DeviceCommand::SetAncMode(AncMode::Transparency, level), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x34, 0x02, *level], - ( - DeviceCommand::SetEqPreset(preset), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x43, preset.to_byte()], - // Game/low-latency mode — verified for BP1 over both SPP and BLE (issue #3). - ( - DeviceCommand::SetGameMode(on), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x24, u8::from(*on)], - ( - DeviceCommand::FindEarbud(Side::Left), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x10, 0x00, 0x01], - ( - DeviceCommand::FindEarbud(Side::Right), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x10, 0x01, 0x01], - // Inspire XH1 — setting ANC/EQ not yet supported (wire format unverified). - (DeviceCommand::SetAncMode(mode, _), BaseusModel::InspireXh1) => { - tracing::info!("XH1 ANC set for {mode:?} not yet supported — wire format unverified"); - return Ok(()); + // BP1 Pro ANC — verified on hardware. + (DeviceCommand::SetAncMode(AncMode::Off, _), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x34, 0x00, 0xFF] } - (DeviceCommand::SetEqPreset(_), BaseusModel::InspireXh1) => { - tracing::info!("XH1 EQ preset not yet supported — wire format unverified"); - return Ok(()); + (DeviceCommand::SetAncMode(AncMode::Anc, level), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x34, 0x01, *level] } - (DeviceCommand::SetGameMode(_), BaseusModel::InspireXh1) => { - tracing::info!("XH1 game mode not yet supported — wire format unverified"); - return Ok(()); + (DeviceCommand::SetAncMode(AncMode::Transparency, level), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x34, 0x02, *level] + } + (DeviceCommand::SetEqPreset(preset), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x43, preset.to_byte()] + } + // Game/low-latency mode — verified for BP1 over both SPP and BLE (issue #3). + (DeviceCommand::SetGameMode(on), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x24, u8::from(*on)] } - (DeviceCommand::FindEarbud(_), BaseusModel::InspireXh1) => { - tracing::info!("XH1 find not yet supported"); - return Ok(()); + (DeviceCommand::FindEarbud(Side::Left), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x10, 0x00, 0x01] } - // Adaptive ANC modes — only used by XH1; unreachable for earbud models. - (DeviceCommand::SetAncMode(_, _), _) => { - return Err("ANC mode not supported for this model".to_string()); + (DeviceCommand::FindEarbud(Side::Right), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x10, 0x01, 0x01] } }; transport.send(&bytes).await.map_err(|e| e.to_string()) @@ -325,18 +282,6 @@ fn maybe_alert_battery(app: &AppHandle, event: &DeviceEvent, thresholds: &mut Ba thresholds.left_was_ok = left_now_ok; thresholds.right_was_ok = right_now_ok; } - DeviceEvent::HeadphoneBatteryUpdate(h) => { - let now_ok = h.pct >= LOW || h.pct == 0; - if thresholds.left_was_ok && !now_ok { - let _ = app - .notification() - .builder() - .title("Baseus — Headphone low") - .body(format!("{}% remaining", h.pct)) - .show(); - } - thresholds.left_was_ok = now_ok; - } DeviceEvent::CaseUpdate(c) => { let case_now_ok = c.case_pct >= LOW || c.case_pct == 0; if thresholds.case_was_ok && !case_now_ok { diff --git a/apps/baseus-app/src-tauri/tauri.conf.json b/apps/baseus-app/src-tauri/tauri.conf.json index 64c2957..99686ff 100644 --- a/apps/baseus-app/src-tauri/tauri.conf.json +++ b/apps/baseus-app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "baseus-app", - "version": "0.2.1", + "version": "0.3.0", "identifier": "com.baseus.desktop", "build": { "beforeDevCommand": "pnpm dev", diff --git a/apps/baseus-app/src/App.tsx b/apps/baseus-app/src/App.tsx index 28642c3..2410432 100644 --- a/apps/baseus-app/src/App.tsx +++ b/apps/baseus-app/src/App.tsx @@ -24,7 +24,6 @@ import { startTimer, stopTimer, useElapsed } from './lib/timer'; type ConnStatus = 'connected' | 'connecting' | 'disconnected'; const BP1_ANC_MODES: AncMode[] = ['off', 'anc', 'transparency']; -const XH1_ANC_MODES: AncMode[] = ['adaptive_self', 'adaptive_indoor', 'adaptive_outdoor', 'adaptive_commute']; export default function App() { const [status, setStatus] = createSignal('connecting'); @@ -41,16 +40,8 @@ export default function App() { const [eqPreset, setEqPresetSignal] = createSignal('balanced'); const [updateVersion, setUpdateVersion] = createSignal(null); - const isExperimental = createMemo(() => modelInfo()?.status === 'experimental'); const connectedModelName = createMemo(() => modelInfo()?.name ?? 'Bass BP1 Pro ANC'); - const supportedAncModes = createMemo(() => { - const info = modelInfo(); - if (!info) return BP1_ANC_MODES; - if (info.name === 'Inspire XH1') return XH1_ANC_MODES; - return BP1_ANC_MODES; - }); - // Game mode is verified for the BP1 family; XH1 wire format is unknown. - const supportsGameMode = createMemo(() => modelInfo()?.name !== 'Inspire XH1'); + const supportedAncModes = createMemo(() => BP1_ANC_MODES); onMount(async () => { const unlisteners: Array<() => void> = []; @@ -64,10 +55,6 @@ export default function App() { pushRight(e.data.right_pct); setLeftCharging(e.data.left_charging); setRightCharging(e.data.right_charging); - } else if (e.type === 'headphone_battery_update') { - // XH1: single battery — display in left slot; right/case stay at 0. - pushLeft(e.data.pct); - setLeftCharging(e.data.charging); } else if (e.type === 'case_update') { pushCase(e.data.case_pct); setCaseCharging(e.data.case_charging); @@ -89,12 +76,7 @@ export default function App() { onModelInfo((info) => { setModelInfo(info); - // Reset mode to a sensible default for the newly connected model. - if (info.name === 'Inspire XH1') { - setAncModeSignal('adaptive_self'); - } else { - setAncModeSignal('off'); - } + setAncModeSignal('off'); }).then((fn) => unlisteners.push(fn)); onUpdateAvailable((version) => setUpdateVersion(version)) @@ -142,8 +124,6 @@ export default function App() { const statusText = () => status() === 'connected' ? 'Connected' : status() === 'connecting' ? 'Connecting…' : 'Disconnected'; - const tab = (id: Tab) => ({ display: activeTab() === id ? 'block' : 'none' }); - return (
{/* Title bar */}
-
+
{statusText()}
- {/* Experimental model warning */} - -
- - - Experimental — APK-derived protocol, untested on real hardware.{' '} - - Report what works - - -
-
- {/* Body: sidebar + content */}
-
- r.pct)} - rightHistory={right().map((r) => r.pct)} - elapsed={useElapsed()()} - showTimer={getSettingsStore().show_session_timer} - /> -
- -
- -
- -
- -
- -
- setUpdateVersion(null)} /> -
+ +
+ r.pct)} + rightHistory={right().map((r) => r.pct)} + elapsed={useElapsed()()} + showTimer={getSettingsStore().show_session_timer} + /> +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ setUpdateVersion(null)} /> +
+
diff --git a/apps/baseus-app/src/components/AncTab.tsx b/apps/baseus-app/src/components/AncTab.tsx index dad09e4..f6233a5 100644 --- a/apps/baseus-app/src/components/AncTab.tsx +++ b/apps/baseus-app/src/components/AncTab.tsx @@ -14,19 +14,21 @@ interface Props { } const MODE_META: Record = { - off: { icon: '🔇', name: 'Off', desc: 'Passthrough — no processing' }, - anc: { icon: '🎧', name: 'Active Noise Cancellation', desc: 'Blocks ambient sound' }, - transparency: { icon: '🌬️', name: 'Transparency', desc: 'Lets ambient sound in' }, - adaptive_self: { icon: '👤', name: 'Adaptive — Self', desc: 'Calibrated to your hearing profile' }, - adaptive_indoor: { icon: '🏠', name: 'Adaptive — Indoor', desc: 'Optimised for indoor environments' }, - adaptive_outdoor: { icon: '🌳', name: 'Adaptive — Outdoor', desc: 'Handles wind and open spaces' }, - adaptive_commute: { icon: '🚇', name: 'Adaptive — Commute', desc: 'Transit noise suppression' }, + off: { icon: '🔇', name: 'Off', desc: 'Passthrough — no processing' }, + anc: { icon: '🎧', name: 'Active Noise Cancellation', desc: 'Blocks ambient sound' }, + transparency: { icon: '🌬️', name: 'Transparency', desc: 'Lets ambient sound in' }, }; -// Level slider only applies to BP1 ANC/Transparency modes. +// Level slider only applies to ANC/Transparency modes. const LEVEL_MODES: AncMode[] = ['anc', 'transparency']; +function prefersReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + export default function AncTab(props: Props) { + let gameRef: HTMLButtonElement | undefined; + function sliderInput(e: Event) { props.onLevel(Number((e.target as HTMLInputElement).value)); } @@ -34,6 +36,18 @@ export default function AncTab(props: Props) { props.onLevelCommit(Number((e.target as HTMLInputElement).value)); } + function handleGameClick() { + props.onGameMode(!props.gameMode); + if (gameRef && !prefersReducedMotion()) { + gameRef.animate( + [{ transform: 'scale(1)' }, { transform: 'scale(0.98)' }, { transform: 'scale(1)' }], + { duration: 240, easing: 'ease-out' }, + ); + } + } + + const showStrength = () => LEVEL_MODES.includes(props.mode); + return (
Noise Control
@@ -45,6 +59,8 @@ export default function AncTab(props: Props) { const isLoading = () => props.loading === mode; return ( ); })}
- {/* Level slider — only shown for BP1 ANC/Transparency modes */} - {LEVEL_MODES.includes(props.mode) && ( - <> -
Strength
-
+
Strength
+
+
+ Level + + {props.level} / 10 + +
+ -
- Level - - {props.level} / 10 - -
- -
- LowHigh -
+ /> +
+ LowHigh
- - )} +
+
{/* Game / low-latency mode — independent toggle, not an ANC state */} {props.showGameMode && ( <>
Game Mode
- + +
)}
diff --git a/apps/baseus-app/src/components/HomeTab.tsx b/apps/baseus-app/src/components/HomeTab.tsx index f5401e6..c116e56 100644 --- a/apps/baseus-app/src/components/HomeTab.tsx +++ b/apps/baseus-app/src/components/HomeTab.tsx @@ -1,3 +1,4 @@ +import { createSignal, createEffect, onCleanup, untrack } from 'solid-js'; import SparkLine from './SparkLine'; import { findEarbud } from '../lib/tauri'; import { formatElapsed } from '../lib/timer'; @@ -20,6 +21,38 @@ interface Props { const CIRC = 2 * Math.PI * 28; +function reduceMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/// Smoothly tween a displayed value toward `target`, re-running whenever it +/// changes (so rings sweep + count up on mount and on each new battery reading). +function useCountUp(target: () => number): () => number { + const [shown, setShown] = createSignal(0); + let raf = 0; + createEffect(() => { + const to = target(); + if (reduceMotion()) { + setShown(to); + return; + } + cancelAnimationFrame(raf); + const from = untrack(shown); + let startTs = 0; + const dur = 900; + const tick = (now: number) => { + if (!startTs) startTs = now; + const t = Math.min((now - startTs) / dur, 1); + const eased = 1 - Math.pow(1 - t, 3); + setShown(Math.round(from + (to - from) * eased)); + if (t < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + }); + onCleanup(() => cancelAnimationFrame(raf)); + return shown; +} + function BudRing(p: { pct: number; charging: boolean; @@ -28,14 +61,16 @@ function BudRing(p: { label: string; history: number[]; }) { + const shown = useCountUp(() => p.pct); const isLow = () => p.pct > 0 && p.pct < 20; const color = () => (isLow() ? '#ef4444' : '#22c55e'); - const offset = () => CIRC * (1 - p.pct / 100); + const offset = () => CIRC * (1 - shown() / 100); const wearColor = () => !p.wearKnown ? '#2a2a2a' : p.inEar ? '#22c55e' : '#3f3f3f'; return (
- - {p.pct} + + {shown()} %
@@ -98,7 +133,7 @@ function BudRing(p: { {p.label} {p.charging && ( - + )}
@@ -106,6 +141,7 @@ function BudRing(p: { } export default function HomeTab(props: Props) { + const caseShown = useCountUp(() => props.casePct); const caseColor = () => props.casePct > 0 && props.casePct < 20 ? '#ef4444' : '#eab308'; @@ -132,6 +168,7 @@ export default function HomeTab(props: Props) {
Case
Case - - {props.casePct}% + + {caseShown()}%
{props.caseCharging && ( - + )}
@@ -182,6 +218,7 @@ export default function HomeTab(props: Props) {
Today
-
+
{formatElapsed(props.elapsed)}
Current session
@@ -211,9 +248,24 @@ function Divider() { } function FindBtn(p: { label: string; onClick: () => void }) { + function handleClick(e: MouseEvent) { + const btn = e.currentTarget as HTMLElement; + if (!reduceMotion()) { + const rect = btn.getBoundingClientRect(); + const r = document.createElement('span'); + r.className = 'ripple'; + r.style.left = `${e.clientX - rect.left}px`; + r.style.top = `${e.clientY - rect.top}px`; + btn.appendChild(r); + setTimeout(() => r.remove(), 600); + } + p.onClick(); + } + return ( ))} - {/* Spacer + settings at bottom */}
+ + + +
+
+ +
+ + +
+ +
+
+
Earbuds
+
+
+ +
+ + + + +
0%
+
+ +
LEFT
+
+
+ +
+ + + + +
0%
+
+ +
RIGHT
+
+
+
+ +
+
Case
+
+
+ 🗃️ +
+
+ Case + 62% +
+
+
+ +
+
+
+ +
+
Find Earbuds
+
+ + +
+
+ +
+
Today
+
+ +
+
1:24:07
+
Current session
+
+
+
+
+ + +
+
Noise Control
+
+ + + +
+ +
+
Strength
+
+
Level7 / 10
+ +
LowHigh
+
+
+ +
Game Mode
+
+
+ 🎮 +
Low Latency
Reduces audio delay for gaming
+
OFF
+
+
+
+ + +
+
Equalizer
+
+ + + + +
+
+ + +
+
Settings
+
Launch at login · Low-battery alerts · Session timer — unchanged in this pass.
+
+
+
+
+
+ + + + + + + + +