64-android-system-volume
1168
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f26b44db08 |
docs(player): close three of the four gaps with a real device
A Light Phone III (Android 14, SDK 34, arm64, Chrome 113 at 424x439) was attached after the PR was opened, so what it listed as unverifiable was checked rather than left as a caveat. SystemOwnsVolume answers true on the device -- the build tag, the constant, the field and the generated binding, end to end, which is the one thing a source sweep only approximates and which nothing else here compiles at all. The control is absent in both mount points on the real engine, and the transport measures 143px, exactly what the desktop-headless "after" predicted. A stored volume of 37 survives a session that demonstrably rewrote the row. The duck is the one that stays open, and now for a stated reason rather than for want of hardware: the foreground service omits setWillPauseWhenDucked from Oreo, so the framework attenuates us itself and never sends AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -- the device logs `requestAudioFocus() ... flags=0x0` saying so. minSdk is 21, so that path is live code on Android 5.0 to 7.1 and unreachable above it. Asking for a modern phone will not test it. |
||
|
|
b43172a60c |
docs(player): record who owns the volume, and what it gave back
CLAUDE.md's volume paragraph ended "#64 asks for it to be gone on Android outright, which is a platform question the frontend cannot currently ask", which is no longer true -- it can, and the paragraph now says why the answer is a capability rather than a viewport and what that costs. The mediacontrols entry gains the corollary: on that platform "the user's level" is a constant, and the duck is the one thing that may still move the output. NOTES.md carries the measurements: the per-element budget at 424x439 before and after, the :host([hidden]) specificity trap, the fact that the bar's centring survives the control going away, and what no tier here could check. |
||
|
|
2be6fb3066 |
feat(player): draw no volume control where there is no volume
volume-control asks the player whether there is a volume of ours to
control, and renders nothing when there is not. The decision is in the
control rather than at either mount point because there are two, and
one of them -- the bottom bar's -- lives in index.html, which has no
module scope to make it conditional.
It could not have been a width, and that is the whole design decision.
Every other stand-down rule in this app is keyed on a viewport, because
a width is what a browser can answer and what every tier can test.
This one is a property of the build: keyed on width, an Android tablet
at 600px or more draws the bar's slider over a level the backend has
pinned -- a control that cannot act, on exactly the platform the rule
exists for, which library-status-indicator settled is worse than none.
The same rule is wrong the other way below 600px, where a narrow
desktop window has no hardware keys to fall back on. index.css keeps
its phone rule, which is now about room and says so.
Rendering nothing and hiding the host are both needed and are separate
assertions: an empty shadow root is what stops a by-role or positional
query finding a button that cannot act, and :host([hidden]) is what
stops the element taking a flex item's worth of the transport. The
host rule has to be written down, since :host { display: inline-flex }
outranks the UA's [hidden].
Measured at 424x439 by flipping the constant and rebuilding: the album
art goes 39px to 68px and the transport 172px to 143px -- 29px, being
the 21px control plus the 8px gap a hidden box stops drawing. The
bar's centring is unaffected, since #23's outer columns are the same
min() expression rather than content-sized.
volume-ownership.test.ts is the tier that can exercise the Android
rendering, on an ordinary Linux runner, because the predicate is a
stubbable backend answer. Both of its tests were confirmed to fail on
the build before this.
Closes #64
Closes #172
|
||
|
|
867ced8c81 |
feat(player): leave the volume to the system where the system owns it
On Android the hardware keys are the volume control and the framework mixes our stream against the device level, so a second control inside the app moves something the user already moved. Where that is true the player's level sits at maximum, SetVolume / ChangeVolume / MuteToggle are refused, and nothing persists a level nobody chose: restore remembers the stored value instead of applying it, and saveState writes that same value back rather than recording the synthetic maximum. Mute is in that list because it is a level of zero by another name -- and because with no control rendered it would be the one state on such a platform the user could not get out of. The predicate is named after the capability rather than the platform, because that is what makes it testable. Only platformOwnsVolume is behind a build tag, in two files that declare nothing else; everything else is decided against Player.systemVolume, a field a test sets either way. That is mediacontrols' split, with androidpayload.go's reasoning for keeping the contract out of a tagged file, and the tagged pair is covered by a source sweep since no tier here compiles both halves. SetDuck is deliberately untouched: it applies its attenuation by re-applying the *user's* level through setVolumeLocked, so pinning that level to maximum leaves the offset arithmetic exactly as it was. It is the only thing that may still move the output on such a platform, and TestSystemVolumeStillDucks is that property rather than a comment. |
||
|
|
fd71ef53c5 | Merge pull request 'The phone transport: three controls, sized for a thumb' (#173) from 59-slim-the-mini-player into main | ||
|
|
e3b64f9255 |
test(player): assert the desktop bar's size by mechanism, not by pixels
WebKit draws the same button 36x24 where Chromium draws 33x21, so the literal this pinned failed in CI on a build where nothing was wrong. A button's box comes from the UA stylesheet when the author sets nothing, and what each UA sets is its own business. What must not happen is that *we* set something. So: `min-width` and `min-height` compute to 0px, the font-size still equals that of a bare button probed in the same page, and all five boxes are identical -- which is what says the desktop is neither sized context. Checked by re-introducing the `font-size: inherit` regression, which it catches in Chromium; the literal form could only be checked by hand. |
||
|
|
c7e5a4f086 |
docs(player): record the phone transport, and four silent failures
The model in CLAUDE.md beside the volume rule it qualifies; the measurements and the four things that cost a cycle each in NOTES.md, dated. Three of the four are invisible to every assertion in the repo: a button not inheriting its font, a nested rule out-specifying a later one, and art whose height is bounded by nothing. |
||
|
|
f65822c4b2 |
test(player): pin the phone transport, and the desktop bar not moving
Ten tests, five of which fail on the build before this. The desktop guard is meant to pass there -- that is its job, and it is the one that caught a three-pixel regression nothing else could see. `openTheQueue` moves to the fixtures, because hiding one button failed ten tests in four files about the back stack and about layout: every one of them opened the queue by clicking `#queue-button`, and so was quietly asserting *which* route exists as well as what the queue does. The route differs by width now and that is the feature. Two smaller things. The play button is named for its action, so an exact 'Play' waits out a fixture track -- 11.1s per test, passing by luck, and it would have failed outright against LONG_TRACK. And the "nothing playing" case clears the queue itself rather than trusting the app not to have played anything: `make e2e` runs one long-lived app across every spec (#168), which is how a deterministic bug first showed up as a flake. |
||
|
|
32d4dc2c82 |
feat(player): slim the phone's mini player to three controls
Shuffle, repeat and the queue button leave the phone's bottom bar. They are not gone: all three are on the full-screen Now Playing view, one tap away through the mini player's art, which is the "reachable only from Now Playing" this issue asks for. #55 is what makes the queue half safe -- it is a screen with an entry in the back stack now, rather than a panel with no way out but the button being removed here. Removing a control is only allowed because it is still reachable, which is plan 018's matrix promise, so that is what the spec walks rather than counting buttons. It found that the route did not exist in the state that matters: `now-playing` renders two branches and the no-track one had no `.expand` button on its placeholder, so with nothing loaded there was no way to the full-screen view at all -- and once the queue button left the bar, no way to the queue. The queue is persisted across restarts, so "tracks queued, nothing playing" is a state the app launches into, not a corner. The favourite stays on the bar and was 18x14px, the smallest control in the app, against the 48x48 art beside it. One CSS trap, because it failed silently. The phone block is last in index.css on purpose -- a media query adds no specificity -- but the rule it overrides here is written *nested* inside `.bottom-bar`, so it builds to a descendant selector one class more specific and a bare `#queue-button` lost to it. Being last is not enough when the thing above is more specific. Closes #59 |
||
|
|
218e4f5e99 |
feat(player): give the transport a context, and thumb-sized controls
Measured at the reference device's 424x439, every button here was 33x21px -- in the bottom bar and on the full-screen view alike. #56 reports them as "the most important thing in the mobile app and they are tiny", and that is the number behind it. The context is a **property, not a media query**, and that is the whole design. Everywhere else in this app a component states what it drops at phone width itself, because a media query inside a shadow root is answered by the viewport and that is the honest signal. Here the two hosts want different answers at the *same* viewport: on a phone the bar wants three controls sized for a thumb and now-playing-view wants five, larger still. So the host says which context and the viewport says which size band, and neither alone can express it. Play/pause alone goes above the 44px floor. A row of five identical squares says every action is equally likely, which is not true of play -- "large play/pause, adequate prev/next" is the Direction, and a spec caught that the first version had sized all three the same. Two things that fail silently: The desktop bar must not move, and a `<button>` does not inherit its font from its parent -- the UA stylesheet gives it one. So a generic `font-size: inherit` is not the no-op it reads as: it took every desktop control from 33x21 to 36x24. The box rules take a zero fallback and the font-size rules are scoped to the two contexts that set one. And the art on now-playing-view overflowed its own box, drawing over the header above and the title below, because `aspect-ratio: 1` with a definite width derives a height that nothing bounds -- 60vh bounds the viewport, not the room left over. `max-height: 100%`. Pre-existing; found by reading a screenshot, which is the only tier that can see it. What is left is #172: with the transport at 172px of a 439px screen the art is a 39px sliver. Closes #56 |
||
|
|
56a5ff99fe | Merge pull request 'The queue is a place while it covers the content' (#169) from 55-queue-as-a-screen into main | ||
|
|
af4b28b0d7 |
docs(queue): record why the queue is not a detail view
The measurement that decided it, dated, in NOTES.md -- the overlay's rect against the main panel's, the three things that were genuinely missing, and the computed containment of both candidate mounts. The model itself goes in CLAUDE.md beside the overlay rule it extends. |
||
|
|
4ee5b4b473 |
test(queue): pin the back stack and the mount that was not taken
Nine tests, and the header says which of them reproduce the defect: three do, and the other six cannot. "The entry is not orphaned" and "a docked column is not in the stack" are both vacuously true of a build that pushes no entry at all. That was established by reverting the source and re-running, not assumed. The containment assertion is the one worth reading twice. It asks where the panel *is* rather than whether a menu is clipped, because CI's Chromium and WebKit both have the Popover API -- so the symptom is invisible here and a spec asserting "not clipped" is green on the broken build. `.planning/NOTES.md` states the mechanism. The rest assert the entry rather than `aria-expanded`, which is the shell's own bookkeeping and was right throughout the defect: what has to be true is that one back press closes the queue and the *next* one navigates. |
||
|
|
a70a7ed9eb |
fix(queue): size the queue screen's way out for a thumb
Measured at 424x439: the three header actions were 25x21px. That matters more than it looks, because with the panel spanning the whole width the scrim underneath it has no uncovered pixels at all -- so the close button is the only pointer route out of a full-screen surface, and it was below the 24x24 floor in one dimension. Sized only in overlay mode. Inline these sit in a 320px column beside the content, where a mouse is what reaches them and 44px of header is 44px the queue does not get. |
||
|
|
de2cb2693a |
feat(queue): give an overlaid queue a place in the back stack
The queue's pixels were already right. Measured at the reference device's 424x439, #24's overlay is 424x318 -- `.main-panel`'s rect exactly -- so the `DETAIL_LOADERS` mount the issue's Direction asks for would draw the same rectangle in the same place. What was missing was the navigation model: opening the queue on Artists and pressing back moved the page *underneath* to Albums and left the queue up, which is a press that changes something the user cannot see and costs them their place. So the queue is a *place* exactly while it is an overlay, and a *control* while it is a column. A column is a thing the user docked -- back must not undock it and a navigation must not take it away -- and that reuses #24's computed mode rather than adding a breakpoint, so the drag-resizable panel width keeps deciding it. It is in neither `VIEW_TAGS` nor `DETAIL_LOADERS`, because there is nothing to mount and moving it would cost something. `.main-panel > *` computes `contain: content` under a `.main-panel` that does too, and paint containment clips the `position: fixed` a `wa-popup` falls back to on Chrome 113 (#60) -- so the detail-view mount would have broken `queue-panel`'s working context menu on the one device this is about. The panel's ancestry today is paint-free to `body`. Two details that fail silently otherwise. The entry is unwound from the panel's `open` attribute in the observer that already ran for `aria-expanded`, not at each of the four ways out -- without that the entry is orphaned and the *next* back press is the one that closes the queue, which is this defect moved one press later. And the navigation writes neither `dataset.activeView` nor `searchStore.setCurrentView`, because both describe what is *in* the main panel and the queue covers that panel without replacing it. `now-playing-view`'s copy of the button went through the helper too: it set `open` directly, so on a phone it produced exactly the queue with no entry behind it that this removes. Closes #55 |
||
|
|
880adff12c | Merge pull request 'Drop the phone's top bar; search becomes a button and a modal' (#167) from feat/57-drop-the-android-top-bar into main | ||
|
|
d6f7412e9d |
docs(shell): the phone has no top bar, and why the modal is a dialog
CLAUDE.md's shell prose said the phone's header "controls shrink or stand down"; there is no header there now. The search box's section gains the modal and the four rules behind it, page-header gains the count as the last thing to yield, and the top-bar-fit section gains what happens below its own band. NOTES.md gets the three measured facts, dated: `contain: paint` is why a Web Awesome popup is clipped on Chrome 113 and why no tier here can reproduce it, the arithmetic that cost the page header its count at 320px, and the shared long-lived e2e app that makes an absolute coordinate a hidden assertion about background jobs. |
||
|
|
1ab767a317 |
feat(shell): take the top bar out of the phone's layout
The row is deleted from the grid template below 600px, not the header hidden. That is 3.25em of a 439 CSS px viewport -- the single biggest vertical win the reference device has to give, and the reason the issue asks for the row rather than for a smaller bar. Each of the five things the bar held has somewhere else to be there: nav-history is the platform's own back gesture and was already gone from 899 down, the job indicator is <job-band> (#62, which is what this was blocked on), the search box is a modal opened from the view's own header, the library filter is Settings -> Libraries, and the wordmark stays where it is. Three things are load-bearing. **The header is visually hidden rather than display: none**, because that h1 is the document's top-level heading and several pages have no other one -- page-header renders no h1 when its heading is empty, and Settings has no page-header at all. Its four controls are display: none *inside* it, which is what keeps them out of the tab order: a visually-hidden container is still focusable, and tabbing into a search box nobody can see is worse than not having one. **The fit pass stands down**, from the bar's computed position rather than from a width. With the bar out of flow there is no content box to measure children against, and a pass that ran would collapse the wordmark on every resize and report success about a 1px box. **top-bar-fit.spec.ts keeps 390 and asserts the stronger property.** "Nothing hangs out of the bar" is trivially true of a bar with no row and would pass on a build that merely broke it, so what that width asks now is that the content starts where the row above it ends. Measuring against the window instead would have been asserting "and no background job is running", which that spec is not about and cannot arrange. Closes #57 |
||
|
|
ac8f86eb00 |
fix(settings): give the library selection a home that is not the top bar
library-filter is the only control in the app that calls setSelectedLibrary, and the phone already hid it with a comment saying it was "reachable from the drawer's Settings". It was not: Settings adds, removes, renames and scans libraries, and does not set the view filter, which is a different thing -- it decides what Albums, Artists and Genres show. A phone therefore inherited whatever a desktop session last chose and could neither change nor see it, which is #24's sentence broken in the band it was written for. It is a second *placement* of the same component, not a second control, and it is at every width rather than below 600px. A phone-only copy is the cheaper answer and is the fault rather than the fix: "where do I change which library I am browsing" having two answers by viewport is exactly what one control in two places avoids. Closes #148 |
||
|
|
47bd9ef211 |
fix(header): let the count yield before an action is clipped
Adding the phone's search button to this header is 43px more than the row has at 320px, which is a width the app promises and which header-action-overflow.spec.ts asks about. Measured on Playlists there, after the fit pass had already collapsed all three actions into "More actions" and truncated the title to nothing: title 0, count 50, sort 143, search 40, More 38, five 12px gaps and 32px of gutters -- 363 in 320, with the More button ending 27px past the edge. That is an action clipped, which is the exact defect this pass exists to prevent. The count is what yields, last, because it is the only item on that row that is neither an identity nor an action. The title yields first and may ellipsis away entirely, since the navigation also says which page you are on; the sort control and the buttons are each the only place they are said. An empty page says it is empty in its empty state and a full one is being looked at. With the count gone the header is 304 in 304, and the title comes back to 19px. It is rendered and hidden with an attribute rather than returned as `nothing`, for the reason the action buttons are: every pass starts from all-visible and needs a node to un-hide, or the first 320px window costs the count for the rest of the session. |
||
|
|
b801fa533a |
feat(shell): make search a button and a modal where searching applies
The phone's top bar is about to go, and the search box is the one thing in it that is an action rather than chrome. It becomes a button in the row that already says which page you are on, opening a wa-dialog with the real search box in it. Three decisions worth the words. **A wa-dialog, and that is a mechanism rather than a taste.** wa-popup renders `<div popover="manual">` and feature-detects the Popover API, falling back to `strategy: "fixed"` where there is none -- which is Chrome 113, the reference device, since `popover` is Chrome 114. And `position: fixed` escapes ancestor overflow but not `contain: paint`, which `.main-panel` carries, so a popup-shaped search panel opened from a view's header is structurally clipped on that device. `<dialog>` / `showModal()` is Chrome 37 and uses the real top layer. No tier here can see the difference -- CI's Chromium and WebKit both have the Popover API -- so the component test asserts the *mechanism*, a native `<dialog>` in the tree, rather than the symptom. **An element, not a PageAction.** Two of the seven searchable views are detail views with no page-header; they filter on the term and say so in their own headers. Declaring search as an action would mean seven hosts each writing it out, which is a second list of searchable views, and it would put a phone mode for actions inside page-header, which that component documents its refusal to grow. search-store's own map is the condition, asked by one component placed three times. **The modal carries the real search-bar**, so there is still one debounce, one clear button and one view-scoped placeholder. Escape closes it and *keeps* the term -- the input treats Escape as "clear the search", which is right in a header where the box stays on screen and wrong in a surface whose dismissal would then discard the search. |
||
|
|
8879192097 | Merge pull request 'Show background jobs in the phone's layout, not a popover' (#166) from feat/62-jobs-as-a-notification into main | ||
|
|
f76ee96ac4 |
docs(jobs): the phone's band, and why it is in flow
CLAUDE.md's jobs section said the header indicator is "the one view of everything at once, from every page"; that is now true on a desktop only, and the band is the phone's half. NOTES.md takes the measurement that decided the shape -- an overlay band at 424x439 is a lid, not a notification -- and the corollary about which tier can see it: ui-test, tsc, lint and the Go suite all passed on the broken version, and what failed was three e2e specs that have nothing to do with jobs. Run the suite, not the spec you wrote. |
||
|
|
23f5a0c53a |
feat(shell): show background jobs in the phone's layout, not a popover
The header indicator is a disclosure anchored to a bar 3.25em tall on a screen 439 CSS px tall, and it was reported as unreadable behind other UI. Background work is the one thing a phone should not make you open something to see, and #57 deletes the bar it hangs from and is blocked on it having somewhere else to live. Below 600px the indicator stands down and <job-band> takes over. It is the existing job-panel at `kinds="*"`, so pause, cancel, Details and the log come along, and so does applyJobControl. **It is in the layout, not over it**, and that was measured rather than assumed. The first version put the panel in notification-host's fixed band: it renders correctly, sits on top and stays inside the viewport, and is unusable -- at 424x439 a compact panel showing two jobs is ~216px of a 439px screen, drawn over the content and swallowing every tap under it. Four e2e specs caught it, and none of them was about jobs: two phone-shell journeys and the header's action menu, all failing on clicks the band was intercepting. As a grid row above the main panel it pushes instead, which is #24's one sentence deciding a layout question -- a band that hides the app to say the app is busy has traded the popover's fault for a worse one. It renders nothing above 600px, from matchMedia rather than a media query, because that decides whether the element exists: Settings already holds four job-panels and a fifth answering for every kind is bottom-nav's "resolved to 2 elements" trap again. index.css keeps it display:none off the phone for a second reason -- an in-flow grid child with no named area is auto-placed into one of the shell's rows, which is what the skip link is absolutely positioned to avoid. top-bar-fit's 390px case asserted the indicator was up, so that it could not pass by measuring the idle case under another name. At phone width it is now deliberately away, so the assertion takes the other branch of the same rule -- the indicator is hidden, the band has the row, and the bar still has nothing hanging out of it -- rather than the width being quietly dropped from the list. The report's own symptom is deliberately not asserted anywhere: it did not reproduce in this tier. Measured at 424x439 the popover was neither clipped nor covered, so a spec claiming a stacking fix would be asserting something that was never true here. The spec says so. Closes #62 |
||
|
|
502b814a65 |
feat(jobs): let a panel answer for every kind, at either density
Three properties the phone's band needs, added here so it is the same panel rather than a second job UI -- which is what keeps `applyJobControl` and its "you will discard hours of downloading" confirmation in the picture. `kinds="*"` is every kind, which is what the header indicator was for. Spelled as a star rather than taken as the meaning of an empty attribute, because empty is what a typo and a dropped binding both produce and "show everything" is the wrong thing to do by accident; empty still shows nothing. `density` is passed to `job-row`, whose `compact` variant its own source calls "the popover density" -- which is exactly what the band replaces. `full` stays the default, so the four settings call sites are untouched. `active-only` drops terminal rows. The band is in the layout, so a finished row there holds the content down after the work is done; Settings keeps them, because that is where "did the last scan work" is asked and a finished row there dismisses itself. |
||
|
|
c19a806298 | Merge pull request 'Give the seek bar's interpolation interval one owner' (#165) from fix/53-seek-bar-never-moves into main | ||
|
|
67eeb75e7b |
docs(android): the device can be driven, not just looked at
The runtime call does not go over HTTP on Android — the WebView cannot deliver a fetch() POST body to shouldInterceptRequest, so v3 routes runtime calls through the addJavascriptInterface bridge. Two things follow that cost an hour each before the v3 source was read: `.playwright/init-events.js` does not transfer to the device (its outbound half hooks fetch, and a POST to /wails/runtime answers "missing object value" — which reads like a wrong payload and is the interceptor getting no body at all), and hooking fetch from an eval is too late on any platform because the bundle captured its reference at module scope. The recipe that does work goes in, along with how to get audio onto the phone (scoped storage silently swallows a push into /sdcard/Android/data/<pkg>/files, and the fixtures are 2 seconds long, which is useless for watching a seek bar) and the permission dialog a reinstall raises, which looks exactly like the app failing to start. NOTES.md takes the #53 measurements: that its frontend is byte-identical to the v0.3.1 the phone carries, that the symptom does not reproduce on main in four scenarios, and that reverting only backend/player/ to v0.3.1 reproduces #125 instead — with the shim that makes that a ten-minute experiment rather than a full checkout. |
||
|
|
fe1fbefee7 |
fix(player): give the seek bar's interval one owner
`handleInput()` called `stopProgress()` and mutated no reactive state, so Lit scheduled no update, `updated()` never ran, and the tail of `updated()` that restarts the interval never executed. Only a `change` event or the next backend report could bring it back — so an `input` that never commits froze the interpolation: a drag cancelled outside the element, a pointer taken by a scroll, or a touch on the track treated as a scrub, all ordinary gestures on a phone. While playing the 1 Hz report papered over it within a second; with reports not arriving it was permanent. The drag is `@state` now and `updated()` decides whether the interval runs, so there is one place that knows. `handleChange` no longer starts it directly for the same reason. A flag set on `input` can strand, which would turn a stall of up to a second into a permanent one — the failure this removes. `change` is the ordinary end; `pointerup`/`pointercancel`/`touchend`/`touchcancel` on the document are the ends that are not, attached with the drag and dropped with it, because the pointer is routinely released outside the element it started in. The other half is that a report arriving mid-drag used to overwrite `seekValue` and pull the thumb out from under the finger once a second. It is skipped while dragging, and its seq is deliberately left unrecorded so the first report after the drag still counts as fresh. Three tests, all exercised against the fault: two fail on the old component, and the third fails if the drag flag is left set — which is the failure mode the fix introduces and the listeners exist to prevent. Verified on the device too (Chrome 113): mid-drag the bar holds its value and ignores reports, and on release it adopts the backend's real position and resumes ticking. Closes #164 |
||
|
|
de04339494 |
Merge pull request 'Android: install and launch the package the APK declares' (#163) from fix/159-android-task-app-id into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Successful in 1m27s
Build & publish Arch package / arch-package (push) Successful in 2m43s
Attach the desktop build to the release / linux (push) Successful in 57s
Sync Homebrew formula / sync-formula (push) Successful in 6s
|
||
|
|
998ce75fb6 |
docs(android): the identity is read back, not declared twice
android-tier.md carried a warning block telling the reader not to use run:device or deploy-device, and offered a manual sequence instead. Both are wrong now: the tasks are the way in, and the warning would read as a live hazard. It becomes a note about what changed, and the manual sequence stays as the smallest thing that works when you want no script between you and adb. "The identity is declared twice" was the section this file had carried for five phases saying nothing enforced that the two ids agree. It describes the enforcement now, plus what APP_ID means since it stopped being a setting it never was. NOTES.md takes the four measurements: that the uninstall existed only to cover a missing -r (which is what makes deleting it a fix rather than a trade), that the emulator tasks installed on a phone, what reading the id back costs, and the boot-wait race filed as #162. |
||
|
|
4b392cb4c4 |
fix(android): point the emulator script at the built APK's id
The third declaration of the app's identity, and the one #159 did not cash out in: PKG defaulted to "app.yellowjacket" while `make android-install` installs whatever is in bin/, which after `wails3 task android:assemble:apk` is app.yellowjacket.dev. So android-launch, android-logs and android-smoke addressed a package the build had not produced, and the certificate-change message named the wrong id to uninstall -- the release one. It is derived from bin/yellowjacket.apk the same way the tasks are, so it follows whichever variant was built last. YJ_ANDROID_PKG still overrides, and the literal survives only for a tree with no APK yet, where these commands are asking about whatever is already installed and there is nothing to read. cmd_inspect's probe order goes with it: "$PKG.dev" would append a second suffix to an id that already carries one, so the candidates are derived from the resolved id in either direction -- debug sibling first, release second, as before. |
||
|
|
8d2109b87e |
fix(android): install and launch the package the APK declares
The four adb-driven tasks in build/android/Taskfile.yml began with
`adb uninstall {{.APP_ID}}`, where APP_ID defaulted to
"app.yellowjacket" -- the release id. `run` and `run:device` build the
*debug* variant, whose applicationIdSuffix makes it
"app.yellowjacket.dev", so both uninstalled the user's released app,
took the library with it, installed a different package, and then
failed to launch the one they had just removed.
The id is read back from the built APK now (scripts/android-pkgid.sh,
`aapt2 dump packagename`) rather than written down a second time, so
the thing installed and the thing launched agree by construction --
whatever Gradle resolved the applicationId to, suffixes included, is in
the file. An APK it cannot read is a hard failure and never a fallback
to a default; guessing is the bug. APP_ID survives with no default as
an *assertion*: it is checked against the artifact and refused, naming
both, before anything is installed or a target is even chosen.
The uninstall is gone rather than corrected. It was there to make the
bare `install` on the next line work at all -- Android refuses an
install over an existing package without -r -- so `install -r` removes
the reason for it. What is left is the one case an uninstall is really
the remedy, a changed signing certificate, and that is exactly the case
where doing it silently costs the user their library. So it is reported
with the command to run, which is the answer scripts/android-emulator.sh
had already reached for `make android-install`.
And the emulator tasks now say "emulator" to adb. A bare `adb install`
with one device attached picks that device whatever it is, so with a
phone plugged in and no emulator running, the task whose summary reads
"in the Android Emulator" installed on the phone -- the same data loss,
from the task whose name gives no warning. Several matching targets is
an error naming them rather than a silent pick of the first.
Closes #159
|
||
|
|
b741b01cdf | Merge pull request 'Android: run main() once per process, not once per activity' (#161) from fix/52-android-activity-recreation-restarts-the-process into main | ||
|
|
8a757c9bb4 |
docs(android): record the lifecycle model and the device check
The lifecycle answer is load-bearing, so CLAUDE.md states it: an activity is a view onto the process, and main() runs once per process. The "restore the session or cold-start" question the issue asks for a decision on is settled by playback rather than by preference -- the audio lives in the Go process, so a cold start on every recreation stops the music mid-song, which is the thing the foreground service exists to prevent. android-tier.md's build table said "arm64, real device -- unverified, still" for five phases. It is verified now, on a Light Phone III (Android 14, arm64-v8a, WebView Chrome 113 at 424x439), and what the run found is a lifecycle section: how to force an activity recreation on demand, the three-line logcat signature, why `has died: fg TOP` is not a memory kill, and the second assertion that surviving does not imply working. It also carries the correction that "Don't keep activities" -- the report's own suggested lever -- does not work on this device at all, so nobody spends an afternoon on it. A configuration change the manifest does not declare does, in one line. And it stops recommending `wails3 task android:run:device`, which uninstalls the released app and the user's library to install a build with a different id (#159), in favour of the manual sequence. NOTES.md carries the measurements, dated: 8 of 8 recreations fatal before, 5 of 5 survived after, and the note that runs where no recreation happened are inconclusive rather than passes -- a harness that does not check for the second bridge init reports those as green and reads as flakiness. Refs #52, #159, #160 |
||
|
|
d714bd7090 |
fix(android): keep the Go app alive when the activity is destroyed
onDestroy called bridge.shutdown(), which is the natural reading of the
callback and is wrong for this app twice over. Android destroys and
recreates an activity without restarting the process, and when the user
really does leave, this app's reason for existing in the background is
that a song is playing -- which is what the mediaPlayback foreground
service holds the process alive for. Either way, tearing the Go side
down here stops the music.
It was harmless only by accident, and that is worth writing down:
nativeShutdown calls App.Quit(), whose Android destroy() is an empty
method, and Run()'s deferred shutdownServices() cannot fire because
platformRun is `select{}` and never returns. So **no ServiceShutdown has
ever run on Android**. Removing the call changes nothing today; it stops
the day someone implements destroy() from silently killing playback on a
rotation. There is no callback for the process going away -- Android
just kills it -- so durability here is the persist writers, which submit
on every mutation rather than at exit.
WailsBridge.initialize gains the comment for the trap next to it.
Making `initialized` static is the obvious reading of "initialise once
per process" and is wrong: nativeInit also stores the global JNI
reference to *this* bridge, so skipping it leaves Go executing
JavaScript against the destroyed activity's WebView, and the app opens,
renders, and never receives another backend event. The half that must
not repeat is latched in Go instead -- which is also where the damage
was, and the only place that can see it.
Refs #52
|
||
|
|
d64b069053 |
fix(android): run main() once per process, not once per activity
Wails' Android entry point is `nativeInit`, which `MainActivity.onCreate`
calls, and it runs `go mainFunc()` every time. Android destroys and
recreates an activity **without restarting the process** -- a
configuration change the manifest does not declare, memory pressure, or
every background under "Don't keep activities" -- so main() ran again on
a live app.
Every path out of that is fatal. `application.New` returns the existing
app rather than building a second one, `app.Run()` then refuses because
`a.starting` is still true behind Android's `select{}`, and the
`os.Exit(1)` under that error takes the **first**, healthy app down with
it: its database, its queue, and the audio a mediaPlayback foreground
service is holding the process alive to play. ActivityManager restarts
the app, which is the report.
Measured on a Light Phone III (Android 14, arm64): conditional on the
activity actually being recreated, the process died 8 times out of 8.
The runs that "passed" were runs where no recreation happened, which is
the whole of the report's "sometimes". After this, 5/5 recreations
survive on one pid, plus six background/foreground cycles.
It never left evidence because os.Exit is not a crash: no tombstone, no
AndroidRuntime stack, nothing in `logcat -b crash`, and the slog line
naming the error went to /dev/null with the rest of fd 1.
The latch is first in main() because everything below it -- above all
NewYellowJacketApp, which opens the SQLite database -- is work that must
not happen twice in one process. It is inert off Android.
Returning early is not a degraded mode: nativeInit has already
re-pointed the JNI reference at the new bridge, so the recreated
WebView talks to the app that is still running, with its queue and
playback position intact. Verified by hooking dispatchWailsEvent on the
recreated page: IndexStatusChanged, JobsChanged, android:storageAccess.
No tier here runs main() on Android, so the guard is a source sweep, in
the spirit of TestNoDirectRuntimeEmits. The failure it exists for is not
the latch being deleted -- that is loud -- but a line creeping in above
it.
Closes #52
|
||
|
|
5490b2423e |
Merge pull request 'Put the phone Now Playing button above the artwork' (#158) from fix/150-expand-button-under-the-art into main
The button tied with the cover placeholder on paint order and lost, so it did not work for any track without artwork. Closes #150 |
||
|
|
ffc9490a32 |
fix(player): put the phone's Now Playing button above the artwork
`.expand` is the phone's only route into the full-screen now-playing view. It is absolutely positioned with `z-index: auto` over `.cover-art`, which is a *later* sibling with the same z-index, so the two tie on paint order and the later one wins. An `<img>` costs nothing there; a track with no artwork renders a placeholder `wa-icon`, which takes every click aimed at the button underneath it. So the control did not work whenever the current song had no cover, on the one platform that has no other way in. Nothing to do with the fixture: any library has untagged files. Measured at 390px with elementFromPoint at the button's centre — the icon with a placeholder, the button with an image, and the button either way with the z-index. Chosen over `pointer-events: none` on the art, which would take the cover preview's mouseenter with it, and over reordering the DOM, which leaves the same tie to be won by the same accident in the other direction. This was filed as an e2e flake, and the diagnosis was wrong: it failed on both engines three times across two branches that could not have caused it, and passed on re-run each time, because the spec starts the *first* row of the track list and which track that is depends on the order the scan inserted rows — the same root cause as #156. The new spec picks a track *for* having no artwork, and asserts the placeholder is rendered rather than assuming it, so it cannot quietly go back to measuring the easy case. Two things it has to get right, both already documented traps: the track must be the 90-second one, since a 2-second one finishes before the assertions run; and `library.Track.CoverArt` is empty for all 31 fixture rows, so "the first track with no cover art" selects nothing in particular and picked a short one. Verified by mutation: without the z-index the new spec fails on the click in 30s, and the pre-existing one beside it passes, which is exactly how this survived. Closes #150 |
||
|
|
dc6625d33a |
Merge pull request 'Centre the transport, and show the volume inline' (#155) from feat/42-inline-volume-and-centred-transport into main
Three columns whose outer two match, so the middle is centred; the volume moves into the bar as a slider, with the popup as a setting. Closes #23 Closes #42 |
||
|
|
dc8db159f9 |
feat(player): centre the transport and show the volume inline
Two issues over one bar, because they are one relayout. #42's own findings say so: giving wa-slider a label grows it 6px to 14px and moves the transport, which is #23's subject, so doing them in sequence means measuring the bar twice and throwing the first set away. The bar was `320px 1fr auto`, so the transport sat in the middle of what the metadata and the queue button did not use — its centre was ~140px right of the window's at every width. The outer two tracks are the same expression now, so the middle is centred by construction. The side width is the metadata's, capped at a quarter of the bar, and the cap was measured as a regression before it was a decision: reserving the full `--now-playing-width` on both sides is perfectly centred and takes the seek bar's track from 257px to 61px at 800px, and to 0 at 200% text. The control you drag was paying for the symmetry. With the cap it is 246, which is parity. It is a `min()` rather than a breakpoint because that variable is user state — the metadata has a drag handle — and tying both sides to it is also what keeps dragging meaningful; a plain `1fr … 1fr` centres just as well and silently makes the handle a no-op. The volume moved out of `audio-player` into the bar because the transport column has to hold the transport and nothing else, and it joins the queue button in one cell rather than a second column, since the centring compares columns. It is a slider by default and a popup by setting. The stored flag names the *popup*, which is this config's polarity rule — the zero value has to be the intended answer, so an existing config.toml gets the new default with no migration. Inline, the icon is the mute toggle and is named after that action rather than the state, because with the slider beside it there is nothing to disclose; the component tier now covers both presentations rather than whichever is default. Three nested rules in this block began with a bare element selector, which Chrome 120 relaxed and the phone's Chrome 113 **silently drops** — including the ellipsis on the bar's own title and artist, which has therefore never truncated on the device. They are `&`-prefixed now. Filed as #154 for the class and for a check. `bottom-bar.spec.ts` pins both halves separately on purpose: an uncapped build is perfectly centred and fails only the seek-bar width, so a spec asserting centring alone would have passed the regression above. Both were verified by mutation. Closes #23 Closes #42 |
||
|
|
86e7444603 |
Merge pull request (#157) from fix/156-queue-selection-fixture-order into main
The spec asked for the first few tracks and needed one with an album. Closes #156 |
||
|
|
2365806d18 |
fix(e2e): ask the fixture for a track that can navigate
`queue-selection`'s name-click test failed on main on both engines, having passed in its own PR and in two consecutive local suite runs. I added it in #152; this is my defect and it had main red. It staged a queue from the first few rows of `GetTracks(0)` and clicked a track *name*, which `explore-link` routes to that track's **album** page. Four tracks in the fixture library have no album — `01 Tone A`, `02 Tone B`, `Title Only`, `no-tags-at-all` — and a name with nothing to route to renders as plain text rather than as a link. Which tracks arrive first is `audio_files.id` order, which is the order the *scan* inserted them, which depends on concurrency and directory traversal. Locally the first eight are all from two proper albums; CI rebuilds its seed with a real scan and got a different eight. The fixture had a requirement it did not state, so the queue now asks for tracks that have an album. A loose locator is what turned that into a mystery rather than a message. The row was located with `.explore-link` and `first()`, and a row has two — title and artist. With the title as plain text, `first()` silently resolved to the *artist* link, so the click went somewhere real and the assertion was about a destination the test had never exercised. It names `.track-title .explore-link` now. Reproduced before fixing, by staging the CI condition deliberately: a no-album track at row 2 fails the test in 30s on this machine, and the filtered fixture passes in 752ms. The Direction's sweep found one other spec slicing `GetTracks` — `queue-reorder`, which asserts on order alone and needs no property of the tracks it gets, so it is left as it is. Closes #156 |
||
|
|
7d348f243a |
Merge pull request (#153) from fix/151-fuse-the-scroll-guard-and-the-write into main
Removes the window between the scroll guard and the write it guards. Closes #151 |
||
|
|
ddd04623f7 |
test(e2e): fuse the scroll guard and the write it guards
`album-dropdown`'s "can be scrolled" failed twice over two sessions with `Expected 80, Received 10`, both times on a branch that could not have caused it. #133 strengthened the guard from "scrollable at all" to "has the range this assertion needs", which was necessary and cannot be sufficient: the guard and the write are separate round trips, so the page re-lays-out between them. Measured every frame across the resize, three runs: the range goes 0 → **88** at 1ms → 330 settled by 8-14ms. 88 satisfies a guard asking for 80 while the grid is still a pass from done, so the guard is capable of passing on a layout that is about to move. Under full-suite load the transient is worse — the observed failures read 10 — which is why this shows up on the second run of a suite and not in ten consecutive runs of the file alone (0/10 before the change and after it; isolation is not where this lives). So the probe sets `scrollTop` and returns what it reads back, in one page-side call, and the poll retries that. The assertion is now about what the grid did rather than about what it was ready to do, and there is no window between deciding and doing for anything to happen in. #133's own last line asked for the other viewport-shrinking specs to be swept for the same shape. One had it: `layout-overflow`'s sidebar probe already fused its scroll and its measurement into one evaluate but ran it once, so it read whatever the sidebar happened to be doing after the resize. It is polled now — safe to repeat, because scrolling to the bottom twice is scrolling to the bottom. Closes #151 |
||
|
|
9ad1477b1e |
Merge pull request 'Pin the queue panel'''s mouse model' (#152) from fix/43-queue-panel-selection into main
Pins the queue panel's single-click select, ctrl/shift extend and double-click play, none of which were covered in either tier. Closes #43 |
||
|
|
70ab3ddf94 |
docs: record two measurements from the queue selection work
The first is a second instance of a rule CLAUDE.md already states, with numbers: a virtualized list can be repainting for a reason you are about to delete, and here there are two such reasons — so removing either alone changes nothing observable, and removing both leaves the highlight seconds late rather than absent. That is the shape a poll cannot see, which is the general lesson worth keeping. The second is the hit-scan, because it stopped a wrong fix: the queue panel is 12% link and the track list 21%, which is the opposite of the assumption the fix was being built on. |
||
|
|
4f7529c315 |
test(queue): pin the panel's mouse model, and bound the highlight
Single click selects, ctrl and shift extend, double click plays from that row — all four already worked, and nothing in either tier pinned any of them, which is why the report could be made and could not be settled. `queue-reorder.spec.ts` covers the keyboard and `queue-overlay.spec.ts` the panel's mode; the pointer path had no coverage at all, so "selection is broken here" and "selection is fine here" were equally consistent with a green suite. Measured with real mouse events rather than dispatched ones, because a synthetic click aimed at the row bypasses the only thing that could be swallowing it: click row 1 selects 1, ctrl+click 4 gives 1 and 4, shift+click 7 extends to 1,4,5,6,7, a plain click collapses to one, and a double click on row 3 leaves the backend playing row 3. The three candidates the issue lists are all answered. The repaint was already correct, and already correct on the day the issue was filed. `resolveTrackIndexFromEvent` reads data-index, and DOM order matches data order. A row control does swallow the click — `explore-link` stops propagation on purpose, so a click on a name navigates and selects nothing — but a hit-scan across a row makes the queue 12% link against the track list's 21%, so the panel called broken is *less* covered by links than the list called correct. That measurement killed the fix this started out as. Two traps are written into the spec because both faked a defect while measuring. Fixture tracks are 2 seconds, so "double click row 3" read a moment later reports whatever auto-advance moved on to — recorded twice as an off-by-one that is not one, which is what `LONG_TRACK` exists for. And the selection assertions are bounded at 500ms rather than polled with the default 5s: `queue-panel` repaints two ways, the explicit `requestUpdate()` and a per-render `keyFunction` arrow, and with *both* removed the highlight still arrives — at 134ms, 3.9s and 5.8s against 5-17ms healthy. Four seconds is indistinguishable from broken to a user and invisible to a generous poll. Mutation-tested rather than trusted: `playAtIndex(index + 1)` fails both double-click tests, treating every click as ctrl+click fails both selection tests, and removing both repaint mechanisms fails all three selection tests — the last only because of the bound. Closes #43 |
||
|
|
bb21072386 |
Merge pull request 'The top bar decides what it can afford to show' (#149) from fix/143-top-bar-fits-its-window into main
Fits the top bar to its window by measuring it, at every supported width and with work in flight. Closes #143 |
||
|
|
ead1354e4d |
docs: record how the top bar decides what to drop
The shell section already states the three size bands and the promise that no action is unreachable at any of them; how the header chooses what to give up belongs beside them, because the promise is what decides it. Two measured facts go to NOTES.md rather than here. `scrollWidth` counts a box's left padding and not its right, so the obvious fit predicate under-reports by a gutter and passed on a bar with a control jammed against the window edge. And the overflow is 11px idle and 262px while working, which is why the issue was filed twice with different numbers — a seeded app that has finished scanning is idle by the time you resize it. Closes #143 |
||
|
|
ae85df0dad |
fix(shell): give the top bar a measured fit at every width
The bar was 611px inside a 600px viewport at the bottom of the Compact band, and 862px while a scan with a real library's title ran, because `job-indicator` is `hidden` when idle and 235px wide when it is not. `body` is `overflow-x: auto`, so a user got a horizontal scrollbar on a shell #24 promised would not need one — and the band is 600 to 899 with work in flight, not the 600 to 610 the idle measurement suggested. `services/top-bar-fit.ts` is `page-header`'s treatment one bar up: a ResizeObserver, every pass starting from all-visible, hiding the lowest-priority child until it fits. Measured rather than breakpointed because three of the five children are as wide as their content — the library filter by the longest library name, the indicator by the running job's title, the search box by its view-scoped placeholder — so any width picked is right for one library, one job and one view. What yields is decided by #24's own sentence, which rules out the two cheapest candidates in the Direction. Hiding the library filter takes away an action, since it is the only control in the app that selects a library (filed as #148, which is the phone already doing it), and collapsing search to an icon is #57's, which is blocked behind #62. So the wordmark yields first — a brand the window title bar repeats, and visually-hidden rather than `display: none` because that h1 is the document's heading — and then the indicator's label, leaving the ring, which the component already does below 600px and whose live region announces the state either way. "Fits" is the children against the content box, not `scrollWidth` against `clientWidth`: `scrollWidth` counts the left padding and not the right, so the first version read 700/700 with the indicator sitting in the whole right gutter. And the bar does not resize when a job starts, which is the case this is for, so every child is observed too. Pinned before it was fixed, as the issue asks. On the unfixed build the new spec fails at 600 idle and at 600, 800 and 900 with a job, and passes at 390, 899 and 1440; `layout-overflow.spec.ts` gains 600x600 and failed there. That spec asserts on the *shell*, so it was green throughout this defect — the per-child measurement is #69's lesson, and it is what caught the gutter case above. Closes #143 |