Compact mode + notification queueing hardening [2/4] - #1504
Open
theboringhumane wants to merge 15 commits into
Open
Compact mode + notification queueing hardening [2/4]#1504theboringhumane wants to merge 15 commits into
theboringhumane wants to merge 15 commits into
Conversation
This was referenced Aug 29, 2026
theboringhumane
force-pushed
the
stack/02-compact-mode
branch
from
August 29, 2026 17:12
72bacb9 to
1734916
Compare
Read Atoll's implementation for reference (its enableMinimalisticUI swaps openNotchSize for a smaller one and renders a dedicated player). Took the shape of the idea, not the code — its player view is ~1500 lines and assumes managers this project doesn't have. Built on the pieces the full layout already uses — MusicSliderView, HoverButton, MarqueeText, and the musicControlSlots preference — so seeking and the configured transport buttons behave identically in both layouts rather than drifting apart. Opened notch shrinks to 150pt tall, content capped at 380pt wide, and the tab bar is dropped: it switches between tabs the compact layout doesn't have, and it spans the full notch width, which would defeat the narrowing. Off by default so existing users keep their current layout. Three things the compiler caught that were assumptions on my part: this project has no useMusicVisualizer preference (removed), NotchHomeView's `padded` helper is fileprivate so the slot padding is done locally rather than widening its access, and AudioSpectrumView takes a plain Bool plus its own tintColor — the gradient-and-mask wrapper I'd written around it would have fought the colour it already applies.
The first pass was my own arrangement and looked it. This follows
Atoll's MinimalisticMusicPlayerView proportions directly — both
projects are GPL-3.0 and Atoll is itself a boring.notch fork, so this
is license-clean with attribution (credited in the file header).
Ported to MusicSliderView, additively:
- TimeLabelLayout .inline, putting the times either side of the track
instead of stacked beneath it
- TrailingLabel .remaining, which counts down ("-2:56")
- configurable resting/dragging track heights, replacing hard-coded
5/9 in CustomSlider
Every new parameter defaults to the previous behaviour, so the standard
layout renders exactly as before.
Compact layout now: 50pt art, 12/10pt title and artist, a 42pt
visualizer block sized to the trailing time label so its bars centre
over it, inline progress row, 10pt-spaced transport. Panel is 420x180,
matching the size these proportions were designed against.
Three things were making this diverge from Atoll:
- Only three buttons rendered. The row was driven by the
musicControlSlots preference, whose default is
[.none, .previous, .playPause, .next, .none] — so shuffle and media
output never appeared. Compact mode now uses a fixed five; that
preference exists to configure the full layout.
- No media-output control existed at all. Added one showing the
current route (laptop / headphones / AirPods / speaker) via a new
AudioOutputRouteResolver.outputRouteSymbol(). Atoll's opens a
popover that switches device inline, which needs its
AudioRouteManager — this project only classifies the current route,
so this opens Sound settings instead rather than faking a picker
that can't switch anything.
- The Spotify badge spilled out of the artwork. AlbumArtView's badge
is a fixed 30pt at +10/+10, sized for the 120pt art in the full
layout; on 50pt art it overflows the corner. Compact mode draws its
own art with an 18pt badge at +5/+5.
Adds AudioRouteManager: enumerates output devices via CoreAudio and sets the system default. Adapted from Atoll's manager of the same name (GPL-3.0, itself a boring.notch fork) — credited in the file. Distinct from the existing AudioOutputRouteResolver, which only classifies the current route into an icon and can't switch anything; that stays as-is and is still the fallback icon before the first enumeration completes. The button now opens a popover listing devices with the active one checked and sorted first, switching on click. Verified against CoreAudio directly rather than assuming: enumeration returns both devices on this machine, the output-stream filter correctly drops the microphone (otherwise the picker would offer a mic as somewhere to send audio), the active device resolves, and its transport type 'bltn' maps to the laptopcomputer icon that matches Atoll's screenshot. Devices are enumerated when the popover opens rather than polled — AirPods connect and displays wake, so a list built at launch would be stale by the time anyone opened it.
Two numbers were wrong, and together they made the panel read sparse. Height was 180 — Atoll's minimalisticBaseOpenNotchSize. That constant is its window allowance, not the panel: calculateDynamicHeight() sums 50 header + 6+4 progress + 54+2 controls + 15 top + 3 bottom = 134. The extra 46pt was empty space the content floated in. Controls were HoverButton's 30/40pt. Atoll uses 36pt secondary buttons with 18pt glyphs and a 54pt play/pause with a 26pt glyph — noticeably larger, which is what gives the row its weight against 50pt artwork. Added CompactControlButton for that, mirroring their squircle-fills-on- hover treatment rather than HoverButton's capsule. Padding now follows their formula exactly: 15 top, 3 bottom, 6 before the progress row, 2 before the controls.
Width was maxWidth: 420, so the content sized itself to ~350 and never reached it. Fixed width instead. Height measured 189 rather than the intended 180. Atoll's 15/3 padding formula assumes the player is the whole panel; here a 38pt notch-clearance spacer sits above it, so their numbers overshot by 9. Trimmed the player's own top/bottom padding to 8/1 so the total lands on 180 — matching the dimension that actually shows, rather than an internal padding value that doesn't.
420 less 20%. Also lands within a few points of Atoll's own Dynamic Island width (340) — the tighter of their two compact sizes — so it's a size that layout was already designed to hold. The title column is computed from available width rather than fixed, so it absorbs the change: ~200pt at this width, still comfortably wider than the marquee needs before it starts scrolling.
Media output is now a real MusicControlButton case rather than the .none slot compact mode was overloading, so it appears in the settings picker and works in the standard layout too. defaultLayout becomes [shuffle, previous, playPause, next, mediaOutput] — the old default left two empty slots, which is why a fresh install showed three transport buttons with dead space either side. Fixed a hover bug I introduced with the height change: setting the compact frame to a fixed 118pt while the content is ~153pt (38pt notch spacer + player) left the transport row outside the frame. That frame bounds hit-testing as well as drawing, so moving toward the buttons registered as a hover-exit and closed the notch. Compact now sizes to its content; its height is governed by its own padding, which is the real lever regardless. Also: rounder opened corners in compact (35 vs 19), matching Atoll's separate minimalisticCornerRadiusInsets — at this size the standard radius reads square rather than pill-like. Components trimmed ~10% (45pt art, 32/48pt controls) and the battery indicator is back, overlaid top-right since compact hides BoringHeader (which spans the full notch width) and took the battery with it.
The standard layout has no idle branch — it renders whatever MusicManager last cached, so a paused or stopped track keeps its artwork, title and scrub position. Compact mode had its own idle placeholder, which meant it dropped state the full layout holds onto. Removed, so both behave the same. Settings → Media needed no change for the new controls: the slot palette iterates MusicControlButton.pickerOptions and renders each control's iconName, both of which mediaOutput was already added to. Verified rather than assumed — it shows in the palette with the laptop glyph and can be dragged or tapped into any slot, alongside shuffle which was already there.
show() overwrote activeNotification directly instead of going through dismissActive, so a superseded notification's hold was never released: dismissActive only releases whatever activeNotification happens to be *when it runs*, and by the time an overwritten notification would have been dismissed, it was already gone from that property. Any time a second notification arrived while the first was still being held (open notch, or within the 8s closed-pill window), the first's banner window was permanently parked off-screen at (-5000,-5000) with nothing left to release it — held/heldOffScreen never lost that token, so the 2.5s refresh loop kept it alive and off-screen indefinitely. Likely why clicking the system clock stopped opening Notification Center: notificationcenterui can reuse a window instance for its own real NC panel, and if that reuse landed on one we'd shoved off-screen and left permanently parked, the panel would "open" invisibly. Fixed by releasing the previous active notification's hold in show() before the new one replaces it. Also clears held/heldOffScreen in NotificationWatcher.stop() — not the live-leak fix (the refresh loop already stops there, so anything still held dies on its own within a couple seconds), but stale tokens surviving a stop/restart cycle were still wrong.
Four defects in that path, all specific to the inline sneak peek:
- HStack was .top aligned, so the title and artist sat visibly high
against the album art next to them, which is vertically centered.
- Width was a hard-coded 380. The title sits left of the notch cutout
and the artist right of it, separated by a spacer as wide as the
notch itself — so on a wider notch there was no budget left for the
artist and the labels collided. Now derived from closedNotchSize
with a fixed label budget either side.
- That hard-coded width also dropped liveActivityEdgeMargin, which
the non-peek path includes precisely so content clears the physical
bezel. Restored.
- No horizontal padding, so labels butted directly against the
artwork and the visualizer.
Also widens the chin to match while the peek is showing; it was still
sized for the un-expanded pill, leaving the hover region narrower than
what was actually drawn.
Typing a reply and having a new message yank the notification out from under you loses whatever was typed. Now, while the reply field has focus, incoming notifications queue instead of taking over, and the header shows a "+N" badge for what's waiting — with the app's icon when they're all from the same app, so a burst from one conversation reads as "2 more from WhatsApp" rather than an anonymous count. The queue promotes oldest-first when the reply finishes, so a burst is read in arrival order. Queued notifications keep their banners held: that hold is what makes replying possible past the banner's few seconds on screen, so dropping it would leave them unreplyable by the time they're promoted. That makes the queue a resource, not just a list — so it's capped at 5, and every path that drops one (over-cap, stop, clear) releases its hold first. A dropped-but-unreleased entry would leave a window parked off-screen with nothing left to free it. Promotion is skipped while still composing, since the field can hold focus for a beat after a send and promoting there would replace the sent confirmation before it's seen; the isComposingReply didSet covers the case where the notification is dismissed while still focused, which would otherwise stall the queue.
The fill height was `(batteryWidth - 2.75) - 18`. The -18 is absolute, so the formula only lands correctly at the 30pt the standard layout passes; compact mode's 24pt collapsed it to a 3.25pt sliver floating inside the outline. The charging bolt was likewise pinned at 17x17. The outline itself is an SF Symbol scaled by width, so everything drawn inside it has to scale by width too. Expressed relative to a 30pt reference, which keeps that case numerically identical (verified: 9.25 before and after) while fixing every other size — 24pt now yields 7.40 rather than 3.25. Worth noting 26pt was broken too, and that's BoringBatteryView's own default parameter, so any caller not passing 30 explicitly was already drawing a too-short fill.
Tapping the "+N" badge now rotates to the next queued notification and sends the current one to the back, so repeated taps cycle through everything and come back around instead of consuming as they go. Cycling rebuilds the compose view for a different notification (it's keyed by id), so a half-typed reply would vanish the moment you looked at something else — the exact lost-typing problem the queue exists to prevent. Drafts are now kept per notification in the manager and restored on appear, and dropped when one is sent, dismissed, or evicted from the queue. Rotation deliberately doesn't release the outgoing notification's held banner: it's going back into the stack, not away, and without the hold it wouldn't be replyable when it comes back around.
WhatsApp has no scripting interface, but its URL scheme opens a specific conversation with text pre-filled — much better than the clipboard, which leaves the user to find the chat and paste. Uses whatsapp:// rather than wa.me: the scheme is registered to WhatsApp.app directly (verified), so the message text doesn't detour through a browser. Needs a phone number, which the notification never carries — only a display name. Resolved through Contacts, and deliberately only when unambiguous: exactly one matching contact, and a stored number that already has a country code. A local-format number can't be made international without guessing the country, and the cost of guessing wrong is dropping someone's private reply into a stranger's chat. Anything that doesn't clear that bar falls back to the clipboard. Group chats resolve to nothing, which is correct — a group name isn't a contact and has no single number. Reported as its own outcome rather than folded into "sent": the conversation opens with the text ready, but the user still presses send, so the UI keeps the same non-committal treatment as the clipboard hand-off.
theboringhumane
force-pushed
the
stack/02-compact-mode
branch
from
August 30, 2026 06:09
1734916 to
242388a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack 2 of 4. Based on #1503; base for #3. Split out of #1496 for reviewability.
Summary
Test plan
xcodebuild -scheme boringNotch buildat this tip — clean