Skip to content

Compact mode + notification queueing hardening [2/4] - #1504

Open
theboringhumane wants to merge 15 commits into
stack/01-notification-live-activityfrom
stack/02-compact-mode
Open

Compact mode + notification queueing hardening [2/4]#1504
theboringhumane wants to merge 15 commits into
stack/01-notification-live-activityfrom
stack/02-compact-mode

Conversation

@theboringhumane

Copy link
Copy Markdown
Member

Stack 2 of 4. Based on #1503; base for #3. Split out of #1496 for reviewability.

Summary

  • Compact mode: a player-only opened notch matching Atoll's layout and dimensions (420x180 pinned, then narrowed 20% to 336pt), full transport row, media-output device switching, shuffle/output in both layouts, cached track state instead of "Nothing Playing", hover-collapse fix
  • Notification hardening (interleaved in history, kept in order): held-banner leak fix that could block the real Notification Center, queue mid-reply arrivals, browsable queued-notification badge stack, WhatsApp replies drafted in-conversation
  • Inline song-change peek layout fix; battery fill scales with width

Test plan

  • xcodebuild -scheme boringNotch build at this tip — clean
  • Manual: enable compact mode → open notch → transport controls; queue two notifications mid-reply

Comment thread boringNotch/managers/SystemNotificationManager.swift Dismissed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants