72c0e96f92cb7b08951582823a23ac8d87b85a2c
495
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
72c0e96f92 |
fix(player): stop the local player during a cast; capture transport flap
android / Build + lint + test (push) Failing after 1m11s
Two changes for the Sonos stutter the operator describes as rapid play-pause-play at the start of a track. The measurable one: nothing in the diagnostics could see it. player_state records source/loading/error but not whether we are playing, track_change needs the queue index to move, and the heartbeat samples once every 45s. A few seconds of oscillation that changes no index fell through all three, which is why the symptom has been described repeatedly and measured never. The poll loop now publishes raw GetTransportInfo readings on change, and TransportFlapDetector turns a burst of them into one summary event carrying the sequence alongside local-vs-Sonos track and position -- enough to tell a cursor disagreement from the renderer rebuffering. It samples at the 1Hz poll cadence, so a faster oscillation lands aliased; that still answers whether the renderer is leaving PLAYING, which is the open question. The suspect one: during a cast the wrapped ExoPlayer was paused, not stopped. pause() is only playWhenReady=false -- LoadControl keeps loading, so the phone went on downloading the track the renderer was streaming, over the same WiFi, re-arming at every track change via syncLocalCursorToRemote's seekTo. At FLAC bitrates that is a second full-rate download competing with the speaker, beginning exactly when a new track does. stop() ends it; Media3 keeps media items, index and position, and getPlaybackState() already reports STATE_READY while remote, so cursor sync and handoff are unaffected. The route teardown re-prepares for local playback. Whether that download is the cause is unproven -- hence the instrument landing alongside it rather than after it. |
||
|
|
e87516bbe4 |
refactor(player): split Sonos queue loading out of the picker — #2728
android / Build + lint + test (push) Successful in 3m48s
detekt flagged OutputPickerController as LargeClass once the verify path landed. Extracting rather than suppressing: how the renderer's queue is shaped is a different concern from which route is selected, and it had grown big enough to hide a bug — every write in here is a SOAP call that can fail on its own, and nothing ever read the result back. SonosQueueLoader now owns load / extend / verify / append and the incremental diff. The picker keeps route selection and asks it for queue work. No behaviour change. |
||
|
|
8e21bce103 |
fix(player): verify the Sonos queue actually landed — #2728
android / Build + lint + test (push) Failing after 1m18s
The renderer's queue was written and never read back. loadQueueOnSonos background-appends the tail one AddURIToQueue at a time and gives up after 3 consecutive failures; Sonos rate-limits burst adds, so that happens. The renderer was then left holding fewer tracks than we believed, played what it actually had, and stopped — which looked exactly like playback dying for no reason. GetMediaInfo's NrTracks is the cheap authoritative answer and was not being asked for anywhere in the app. Now: - verifyQueueLength after every load (including when there is no tail to append — the initial batch can be dropped the same way), appending what the renderer is missing, bounded at 2 passes. - RemoteStallWatchdog gains QueueState, so a stop is classified rather than assumed: a stream that died resumes, a truncated queue gets repaired at the next track, and a queue that simply ended does nothing at all. That last case was a bug shipped in #2700: the normal end of a queue is a confirmed STOPPED with play intent, so every cast session would have ended with three resume attempts and a `stalled` error for playback that finished perfectly. No test described the end of a queue, so CI had nothing to catch it with. Queue reads are gated on the transport being stopped and cached for 5s, so this never becomes a third SOAP call per second. |
||
|
|
b96285d6d9 |
test(android): TrackRef needs albumId and artistId — #2704
android / Build + lint + test (push) Successful in 3m46s
The queue-filter test built TrackRefs without them; they have no defaults, so compileDebugUnitTestKotlin failed. Caught by CI on the Android lane while I was reading the Go one. |
||
|
|
366692a1fc |
fix: stop the sync feed hiding missing files from clients — #2704
#2523 filtered missing tracks out of every path that CHOOSES music, but the client sync feed was never touched: GetTracksByIDs has no filter and the wire had no field for it. So every Android client held a cached library containing tracks whose files are gone, with no way to tell, and could queue them from any cache-first path -- the exact failure #2523 existed to prevent, reached by a different route. Ships the state rather than filtering the feed, of the two options the ticket weighed. A missing file is expected to come back: the scanner clears the mark, and adopts the row if it returns renamed (#2528). Withholding the row would mean a delete-and-recreate on every client for what is usually a transient unmount, churning caches and throwing away the identity #2528 works to preserve. Room goes to v8. No hand-written migration: the pre-v1 destructive fallback rebuilds from sync, which repopulates every row with the new column -- exactly the case that policy exists for. The interesting part was working out what "missing" means to a client, and it is NOT "unplayable". Two findings shaped the fix: Server search and album detail never filtered missing tracks either, and that turns out to be right rather than an oversight. The consistent rule the codebase already follows is that Minstrel never PICKS a missing track for you -- recommendation, discover, mixes and browse all exclude them -- but it does not hide one you went looking for by name or opened an album to find. Hiding track 4 makes an album look wrong. So the fix is to mark and to keep it out of queues, not to hide it. And a track whose server file is missing still plays perfectly if its audio is already in the device cache. ShuffleSource's offline pools filter to exactly those residents, so it now clears the mark on the way out: the bytes are local and the server's loss is irrelevant. Without that, the queue filter below would have thrown away tracks that work, turning a fix into an offline regression. The queue protection is one choke point rather than five call sites. setQueue is where playlists, album play-all, search, radio and cold-boot resume all converge. dropUnavailable is pure so the index arithmetic is pinned by tests -- removing entries ahead of the requested position would otherwise start playback on the wrong track, and asking to start on a missing track now starts the next playable one, which is the "gets skipped" behaviour the operator asked for. An entirely missing queue returns empty and the caller leaves the player alone rather than replacing what is playing with silence. |
||
|
|
03a8d12079 |
docs: stop pointing at the deleted Flutter tree — #2710
Every comment naming a path in flutter_client/ now resolves to nothing, which is the failure mode this project has already been bitten by twice -- drift #572 came from delete.go describing behaviour it no longer had, and that same docstring was still wrong when it was fixed last week. A pointer to a deleted directory is the same thing in slower motion: the reader follows it, finds nothing, and cannot tell whether the comment is stale or they are looking in the wrong place. Three treatments, per what each comment was actually doing: - Naming a concept ("mirrors db.dart's CachedTracks Drift table"): keep the concept, drop the path. The Drift table is why the entity looks as it does; the file it lived in is not. - Pure port bookkeeping ("Mirrors <path>." and nothing else): deleted. Git history records the port; the comment only restated it. - Substance introduced by a pointer (a lifecycle list, a 200 px/s threshold, an inverted control-row placement): keep the substance, drop the lead-in. The two Go comments were the valuable ones and got more than a trim. They stated a live contract -- "field names match the client's FromJson helpers exactly, or fields are silently dropped" -- against a client that no longer exists. They now name the real consumer, SyncResponseWire.kt, and say why the failure is silent there too: kotlinx.serialization skips unknown keys, so a renamed field arrives as a default value rather than an error. The ticket counted 64 files by grepping flutter_client/. A second tier turned up during the sweep: 15 more references naming bare Dart files (player_bar.dart, now_playing_screen.dart:464, auth_provider.dart) with no directory prefix. Same dead tree, same treatment, folded in here. Comments only -- verified no non-comment line is touched in the diff. |
||
|
|
0036f534db |
chore: delete the Flutter client — #2710
android / Build + lint + test (push) Successful in 4m1s
Superseded by the M8 native Android rewrite. Last touched 2026-05-31, no workflow has built it since flutter.yml was removed, and rule #22 says a replaced path goes rather than lingering as something a reader has to work out the status of. 245 files, ~24.6k lines. Config references go with it: the .gitignore block (and its now-empty "# Flutter" header), the .dockerignore entry, and renovate's ignorePaths entry, which was suppressing dependency scanning for a directory that no longer exists. ci-requirements.md said ci-flutter "will retire once that directory goes". It has gone, so the doc now says so -- CI-Runner can drop the image, and nothing in this repo needs a Flutter toolchain. One thing is kept rather than deleted: shared/fabledsword.tokens.json. It lived under flutter_client/shared/ but was never Flutter's property -- it is the canonical statement of the palette, the only place the dark, light and flat cohorts are written down together, and FabledSwordTokens.kt names it as its source of truth. Losing it would have been collateral damage, so it moves to the repo root with a README saying what it is and that neither client generates from it. That comment in FabledSwordTokens.kt is repointed here. What deliberately does NOT change: `runs-on: flutter-ci` in android.yml and release.yml. That is a runner LABEL, not a path -- the Android jobs schedule on it while pulling ci-android:36, per the label/image split ci-requirements.md documents. Removing it would break scheduling for a cosmetic win, so the doc now spells that out beside the retirement note. Left for #2710: 64 files whose comments still name flutter_client/ paths. Sweeping them here would have buried the deletion, and each needs a judgement -- keep the substance and drop the dead path, delete pure "ported from" bookkeeping, or leave design rationale that happens to mention the Flutter build. |
||
|
|
bfb6c9acfe |
style(android): satisfy detekt on the new browse tabs — #2467
android / Build + lint + test (push) Successful in 3m54s
Two findings, both fair: LibraryScreen was one line over the 60-line cap once Genres and Years were added to its pager. Split the page bodies into LibraryTabPage, so the screen is the scaffold and tab bar while the routing table lives on its own -- adding a tab is now one line there and one label in LIBRARY_TABS, rather than growing a function that was already at its limit. The decade arithmetic used a bare 10 twice. Named it YEARS_PER_DECADE: floor-to-decade reads as arbitrary without it. |
||
|
|
3eada70aac |
feat(android): Genres and Years browse axes in the Library — #2467
android / Build + lint + test (push) Failing after 1m22s
#367 shipped genre and year browsing on web only, which left the web tab bar's own comment -- "mirrors Android's LibraryScreen" -- half aspirational. Android now has both, straight after Albums, in the same order the web bar uses. Server-backed, and that is the one real decision here. Every other Library tab reads Room, and building these indexes locally was the obvious move: the cache is a full mirror and carries both genre and releaseDate. It does not work. /api/library/sync hydrates through GetTracksByIDs, which has no missing_since filter, and neither SyncTrackWire nor CachedTrackEntity has a field for it -- so the cache holds tracks whose files are gone and cannot tell you which, while the browse index excludes them. A locally-derived index would quietly disagree with the server's and with the web client, and could offer a genre that exists only in missing files. Filed as #2704; until it is resolved these two tabs need a connection, and their empty states say what they are rather than looking broken. Genre is a query parameter end to end, never a path segment: "Rock/Pop" is a real ID3 tag and a slash does not survive a path. That is also why the drill-down is a second state inside the tab instead of a nav destination -- a route would have had to carry the label. Index shapes mirror web because the reasoning was already worked out there: genres default to count order, since raw tags carry a long tail of one-offs that A-Z buries the real genres under, with an A-Z chip for when you already know the name; years group by decade, newest first, because a flat list of every year in a decades-deep library is a wall of numbers. Page size matches web's BROWSE_PAGE_SIZE so "Load more (N left)" steps identically on both. The orderings and grouping are pure functions, tested: server order left alone under count sort, case-insensitive A-Z, no mutation of the loaded state's list, a slashed tag surviving the filter intact, decade bucketing including the boundary year, and a count label that stays blank rather than flashing "0 albums" while the first page loads. |
||
|
|
d9238ec5be |
fix(android): notice when a Sonos stops on its own, and get it going again — #2700
android / Build + lint + test (push) Successful in 3m57s
The 2026-08-16 diagnostics show a session that did not stutter so much
as end. In Doze the 1Hz poll freezes -- 14:22:53 and 14:26:14 report
byte-identical snapshots, and that flat 5000ms sonos-vs-local delta is
the last poll's staleness held still, not drift. When the screen came
back on, the first poll in 3.5 minutes found queue track 10 at 113s,
stopped. The Sonos had advanced, played 1:53 of a flac and quit while
the phone slept. The app then reported that faithfully and did nothing
about it, twice more, until the operator noticed.
A UPnP renderer streams autonomously, which is the whole point of
casting and also why a dead stream is invisible: pollOnce read STOPPED,
called applyTransportStopped and returned. Nothing asked "we meant to
be playing -- why aren't we?"
RemoteStallWatchdog asks. It is pure decision state -- no coroutines, no
SOAP -- so the counting, keying and giving-up is testable without a
renderer, and pollOnce just acts on the verdict.
Conservative by construction:
- STOPPED or an error status only. PAUSED is left alone: that is
somebody at the Sonos app or a wall controller, and taking the
transport back off a person is a fight they always lose. A stream
that dies stops, it does not pause.
- Only against play intent. A stop we asked for is not a stall.
- Three consecutive polls must agree. Sonos passes through STOPPED
between queue items, so one reading would make every track change
fight itself.
- Three attempts per track, 5s apart, then give up -- an unplayable
file must not become an infinite retry loop against a speaker.
- Resume seeks back to the last position seen while playing, so a
stream that died 90s in comes back near there, not at zero.
GetTransportInfo now keeps CurrentTransportStatus, which it previously
parsed and discarded. ERROR_OCCURRED is the only unambiguous way to
tell "the stream died" from "somebody pressed stop", since both land in
STOPPED. Absent or unrecognised reads as OK so a quiet renderer is
never mistaken for a broken one.
Giving up reports kind="stalled" through the existing
PlaybackErrorReporter: snackbar for the user, admin-inbox row for the
operator. That kind has been in migration 0032's CHECK whitelist and
labelled on the admin page since the table was built, and nothing had
ever emitted it.
This does not explain WHY the stream died -- see #2700 for the
hairpin-routing lead. It does mean a dropout is a recoverable hiccup
instead of the end of the session.
|
||
|
|
aab90a7a39 |
feat(android): name the missing file behind a greyed playlist row — #2527
android / Build + lint + test (push) Successful in 3m42s
Android was already skipping these by accident: toPlayableTrackRefs
filters on a non-empty streamUrl, and the server stopped emitting one
for a missing file, so they never reached the queue. Correct behaviour,
no idea why -- the row just sat there greyed with the same treatment as
a track deleted from the library, which is a different and permanent
thing.
isAvailable now covers both cases explicitly rather than inferring one
from an empty URL, so every reader (row alpha, click gating, queue
building) gets the same answer from one place. The flag stands on its
own deliberately: a detail fetched before the file went missing can
still carry a stale streamUrl from cache, and that must not resurrect
the row.
The row says which kind of dead it is. A missing file gets "· File
missing" on the subtitle line, because that one can fix itself -- the
scanner clears the mark when the file returns and adopts the row if it
returns renamed (#2528) -- so it is worth telling the user about. A
removed track keeps its bare greyed treatment; there is nothing to act
on once it's gone from the library.
Matches the web treatment landed in
|
||
|
|
20bd7bfaf8 |
fix(android): let list content reach the MiniPlayer — #2681
android / Build + lint + test (push) Successful in 4m28s
The shell is a Column (content weight(1f), then the bar), so the content viewport already ends at the MiniPlayer's top edge. But nothing owned the bottom navigation-bar inset under edge-to-edge: each in-shell screen's own Scaffold claimed it via the default contentWindowInsets and padded its content up by the nav-bar height a second time. That padding is the dead strip the operator sees between the last list row and the bar — and the bar's own bottom was drawing under the gesture pill. ShellScaffold now owns the inset end to end: the content region consumes it, and a Spacer below the MiniPlayer re-holds the space for the system bar (unconditional — MiniPlayer renders nothing when no track is loaded). Modifier.consumeWindowInsets alone can't fix it: ScaffoldLayout reads contentWindowInsets.asPaddingValues() directly, outside the modifier consumption chain, so every in-shell Scaffold is handed the new zero ShellContentWindowInsets. The full-screen routes (NowPlaying / Queue / Login / ServerUrl) keep the default — no shell sits above them. Also drops the hardcoded 140dp bottom contentPadding on Album and Playlist detail, a Flutter-era value for a player bar that overlaid its list; here the shell reserves that space in layout already. |
||
|
|
78aa9befb6 |
fix(connectivity): probe on foreground; a burst can't corroborate ServerDown — #1209
android / Build + lint + test (push) Successful in 4m12s
Two changes so a network handoff stops making the app refuse to play music. ## Correction first: half of what I proposed already existed I recommended "require corroboration before ServerDown, since Unstable is non-gating." ReachabilityMachine has done exactly that since it was written — onProbeFailure takes Reachable → Unstable, and escalates only on corroboration or the 120s backstop. There is even a test named `single probe failure is unstable not down`. I proposed building a thing that shipped months ago. Reading the machine properly turned up the real gap, which is narrower and more specific. ## 1. Probe when the app returns to the foreground The genuine missing piece, and #1209's own note had it backwards: it listed this as "already happens via link probe." It doesn't. `recheck()` had exactly two callers — a button in VersionTooOldBanner and pull-to-refresh — and nothing observed ProcessLifecycleOwner. The link probe fires on a connectivity *change*, so an app backgrounded on stable Wi-Fi gets none. That made a stale ServerDown outlive its cause: the poll loop's delay() is throttled while screen-off/doze, so recovery waited for whenever the OS next let the loop run. June's capture recovering at "EXACTLY 22:31:10 app_foreground" was the throttled delay resuming, not a deliberate probe — same timestamp, different mechanism, and that difference is the whole bug. NetworkStatusController now implements DefaultLifecycleObserver and calls the existing recheck() on ON_START. force = true, so it also bypasses ARBITRATE_MIN_GAP_MS: a user opening the app is exactly when a stale banner and a refused track are most visible, and it's once per foreground. ## 2. A burst of op failures no longer corroborates itself The actual defect in the escalation path. Corroboration required 2 op failures within 30s — but a link handoff fails every in-flight request at once, so a burst is ONE event producing N failures, not N independent observations that the server is gone. Two simultaneous failures walked straight to Unreachable. onOpFailure now drops a failure landing within CORROBORATION_MIN_SPACING_MS (3s) of the last recorded one. Above the sub-second window a handoff occupies, low enough that a real outage still corroborates within seconds once anything retries. ## Why this matters more than the task implied #1209 called the follow-ups "cosmetic in the diagnostics". They aren't. OfflineGatedDataSource.gateOnHealth() throws OfflineException on ServerDown BEFORE touching the network, and TrackRow disables rows. So a spurious ServerDown means the app declines to play uncached tracks that would play fine — for a blip that already resolved. The note's "captured skips advanced fine" was timing luck, not evidence the gate is harmless. ## Tests `two op failures plus a failed probe escalate immediately` used timestamps 500ms apart, which the new rule treats as a burst — so I re-spaced it and renamed it `two SPACED op failures...`. That's a deliberate reversal of an encoded expectation, not a broken test being patched. Also re-spaced `stale op failures do not corroborate` (used 0 and 1_000): left alone it would still have passed, but for the wrong reason — burst-dropping rather than staleness — and a test that can't fail for its stated reason is worse than no test. Added: a burst of four failures plus a failed probe stays Unstable, and a burst that never recovers still escalates via the sustained backstop, so dropping duplicates can't make a real outage undetectable. The foreground hook itself is unverifiable in a JVM test (ProcessLifecycleOwner needs the framework, and there's no instrumentation lane). Checked instead that nothing constructs NetworkStatusController outside Hilt, so init's ProcessLifecycleOwner.get() only runs on the main thread during Application.onCreate — the same pattern LiveEventsDispatcher already uses. |
||
|
|
2e1a8a62d8 |
refactor(android): move cleartext opt-out into a networkSecurityConfig — #2439
android / Build + lint + test (push) Successful in 3m46s
`android:usesCleartextTraffic="true"` sat on <application> as a bare opt-out of the platform's network-security default, with nothing recorded about why. It's now a res/xml/network_security_config.xml carrying the same permission and the reasoning behind it. Behaviour is unchanged. networkSecurityConfig supersedes the attribute on API 24+ and our minSdk is 26, so the attribute is removed rather than kept alongside. Cleartext stays permitted because two independent things need it, and neither can be narrowed to a domain list: - The Minstrel server's host is user-entered at runtime, and plenty of self-hosters run plain HTTP on a LAN. - UPnP/DLNA/Sonos — device-description and SOAP control URLs arrive in SSDP responses at runtime and are plain HTTP essentially always. This one wasn't in the original ticket, which only considered the server; it independently rules out the "tighten it later to RFC1918" idea, since <domain-config> matches literal hostnames, not CIDR ranges, and renderer IPs are unknowable ahead of time. Trust anchors deliberately left at the platform default. Adding <certificates src="user" /> would let self-hosters use HTTPS with a private CA — which Mihon does, and which suits this product — but it also trusts every CA on the device including a corporate MITM proxy. Raised separately rather than assumed as a default. tools:ignore="InsecureBaseConfiguration" mirrors Mihon's config and keeps lintVitalRelease quiet about a choice that is deliberate and now documented. |
||
|
|
a4b6f22d86 |
feat(update): silent self-update via PackageInstaller session — #2438
android / Build + lint + test (push) Successful in 3m54s
Replaces the ACTION_VIEW + application/vnd.android.package-archive handoff with a PackageInstaller session, and declares UPDATE_PACKAGES_WITHOUT_USER_ACTION so the update can land with no confirm dialog at all. The platform grants the silent path when the installer opts in via setRequireUserAction(USER_ACTION_NOT_REQUIRED), the installed app targets API 29+, the installer holds that permission, and the target is the installer itself. Minstrel updating Minstrel satisfies all four. Where it can't be granted — anything pre-S — the platform returns STATUS_PENDING_USER_ACTION and we show its dialog instead, so this degrades rather than failing. Prior art: Mihon, which is out-of-store and self-updating and whose updates are quiet for exactly this reason. It also confirmed REQUEST_INSTALL_PACKAGES is not what draws install warnings — Mihon declares it too. No setRequestUpdateOwnership(true), despite it reading like the obvious declaration for a self-updater. Ownership can only be claimed on initial installation (a no-op on update) and additionally wants the privileged ENFORCE_UPDATE_OWNERSHIP permission. It's an API for app stores claiming the apps they install. Also: the install now has an outcome. The old path fired an intent and assumed, so a failure and a user declining were indistinguishable. Sessions report back, so InstallOutcome distinguishes Installed / Cancelled / Failed, and cancelling returns to IDLE rather than showing an error — the user chose it. DOWNLOADING and INSTALLING became separate stages because the install half now genuinely waits, and "Downloading…" through a confirm dialog is a lie. The FileProvider and res/xml/file_paths.xml are gone. They existed only to expose the cached APK as a content:// URI for the old intent; a session takes a stream. Nothing else used that authority. Two judgement calls worth naming: - The pending-user-action intent is only launched if it resolves to a system component. Below API 34 a dynamically registered receiver can't declare itself unexported, so another app can broadcast at us, and an unchecked startActivity on an attacker-supplied extra would be an escalation primitive. The real confirm activity is a system app, so the check costs the legitimate path nothing. - Cancellation unregisters the receiver but deliberately does NOT abandon the session. By then it's committed, and killing an install because the user navigated away from the banner misreads their intent. Untestable here: no androidTest source set and no Robolectric, so the gesture-level behaviour is operator on-device verification. |
||
|
|
8b630e71ca |
refactor(player): split the queue row out of QueueScreen.kt — #2435
android / Build + lint + test (push) Successful in 3m40s
detekt TooManyFunctions: the swipe work took the file to 12 functions against a limit of 11. Suppressing it was the option; splitting is the better one, because the seam was already there — the row carries two gestures, a swipe background, and its own accessibility surface, which is more behaviour than the screen that merely lists it. QueueScreen.kt keeps the screen, list, pill, and summary (4). QueueRow.kt takes the row and its helpers (8). No behaviour change: same code, same order, per-file imports recomputed, QueueRow internal so QueueList can still call it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5 |
||
|
|
1910a5ce61 |
feat(player): swipe a queue row left to remove it — #2435
android / Build + lint + test (push) Failing after 1m25s
Replaces the trailing X button on the Android queue row, for the same reason #2395 replaced the grip: horizontal space in the narrowest row in the app. Web keeps its X — the operator's call, and the right one, since the constraint being solved doesn't exist there. SwipeToDismissBox with enableDismissFromStartToEnd = false; a right-swipe means nothing here and would only delete tracks on a mis-aimed gesture. The red fill under the row is oxblood (LocalActionColors.destructive), not colorScheme.error — the design system keeps those apart because an error is a failure that happened and a destructive action is one about to. Adds a "Remove from queue" custom accessibility action. Both gestures the row now relies on are touch-only, and each replaced a control TalkBack could find, so without this the change would have quietly removed remove-from-queue for anyone not using touch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5 |
||
|
|
a92a9f2198 |
fix(player): extract the reorder a11y actions to clear detekt LongMethod — #2395
android / Build + lint + test (push) Successful in 3m51s
QueueRow hit 61 statements against detekt's 60 — the semantics block I added for the screen-reader move actions pushed it one over. Extracted to a `Modifier.queueReorderActions` extension, which mirrors the `queueReorderDrag` extension from the same change: the row now composes two named modifiers, one for the gesture and one for the accessibility actions, instead of carrying either inline. Better than suppressing the rule — the suppression would have been permanent and the split reads better anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6dea45a634 |
feat(player): album art is the queue's grab surface — #2395
The grip icon took a column out of every queue row, competing with the title
for space — worst on Android, where the row is narrowest and the icon plus
its 12dp gap cost roughly 36dp. Operator pre-approved dropping the icon and
making the album art the drag surface; that's what this does.
## Android: the gesture change is the load-bearing part
Moved the drag from the grip onto the thumbnail AND switched
detectDragGestures → detectDragGesturesAfterLongPress. That second half is
not cosmetic. The grip was a small target, so a plain drag detector on it
never competed with anything; a 48dp thumbnail is a large chunk of every
row, and with a plain detector any vertical pan starting on artwork would be
swallowed as a reorder instead of scrolling the queue. The list would have
felt broken exactly where it's easiest to touch. Long-press-then-drag
separates the three gestures: pan scrolls, long-press reorders, tap still
plays (the detector doesn't consume a plain tap, so it reaches the row's
clickable).
Dropping the grip also removed its contentDescription ("Reorder track"),
which was the ONLY thing telling a screen reader this list could be
reordered — and a long-press drag isn't operable with TalkBack regardless.
Added "Move up"/"Move down" custom accessibility actions on the row, the
Android counterpart to the web row's ArrowUp/ArrowDown. Without them this
change would have quietly removed reordering for anyone not using touch.
## Web: the grip was never the drag surface
`use:draggable` is on the row, not the handle, so dragging already worked
from anywhere — the grip's only unique jobs were being the visual cue and
the keyboard target. It now sits OVER the art, costing zero horizontal
space, and keeps both jobs.
Deliberately still VISIBLE at rest, just quiet, with the scrim appearing
only on hover/focus. Overlaying already solved the space complaint, so
hiding it buys nothing and would cost the only cue that the queue is
reorderable — on touch especially, which has no hover.
## Scope walked back
Also considered the web PlaylistTrackRow, which carries an identical grip.
Left alone: it has no album art, so the approved direction doesn't apply,
and its handle is already the smallest of the three at 14px. Forcing
consistency would have meant inventing a third treatment for a surface
nobody complained about. (Android has no playlist reorder at all — that
parity gap is pre-existing and out of scope here.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e1e591b520 |
feat(brand): Minstrel mark — favicon, header lockup, Android adaptive icon
A Didone M whose right leg is an eighth note: stem, flag and notehead in the
accent, the letter in parchment. Traced from the operator's reference at
99.74% IoU (potrace, 26 + 22 segments), so the geometry is theirs, not an
approximation of it.
Subject-neutral on purpose. "Minstrel" pulls toward a lute or a bard, which
would tell a new user this is a renaissance-faire player rather than one for
all music. A geometric letter plus universal notation says "music" without
saying which music. The family look arrives through palette and drawing
style instead of through the subject — see the design-system discussion.
Starting state: web/static/favicon.png was a 1x1 PIXEL placeholder, so there
was effectively no favicon at all; Android had legacy bitmaps only, so modern
launchers letterboxed the square instead of masking it.
## The colour problem, and why each surface differs
Parchment on white is invisible — the operator caught this. The M therefore
has to flip with its background, while the accent note holds in both:
- mark.svg / MinstrelMark.svelte use currentColor, so the letter takes the
surrounding text colour and one asset covers both palettes.
- favicon.svg bakes colours with a prefers-color-scheme swap, because a
favicon sits on browser chrome and has no cascade to inherit from.
- PNG fallback, apple-touch-icon and Android are PLATED. A PNG can't
respond to scheme and iOS composites onto white regardless.
MinstrelMark is inlined rather than <img src>, because an <img> cannot
inherit currentColor and inheriting it is the entire point.
## Plate colour chosen by measurement
Obsidian (#14171A), not the raised-surface iron. The accent note only clears
the 3:1 non-text contrast threshold against the darker value: 3.04:1 vs iron's
2.70:1. My own earlier suggestion — lighten the plate — is WRONG and the
numbers say so: slate scores 2.21:1, worse, because the note is a dark colour
and lifting the plate closes the gap. Recorded in colors.xml so the reasoning
sits with the value.
## Construction
Traced as a full ink silhouette with the note painted OVER it, rather than as
two separate shapes. Separate shapes needed either a 2px seam where letter and
note touch, or an anti-aliasing fringe (2,430 misclassified pixels) around the
note. Painting over avoids both and yields a monochrome version for free — the
base layer alone is the whole mark in one colour, which is what
mipmap-anydpi-v26's <monochrome> uses for themed icons.
Android foreground sits at 61% of the 108dp canvas so it stays inside the
66dp safe zone and no launcher mask can clip it.
Paths are duplicated between the component and the two static SVGs, since one
needs currentColor and the others need literals. A comment in each names the
others.
Verified by render at 16/20/32/64/180 on obsidian, white, parchment and
plated; one optical size holds across the whole range.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
eec59193fa |
feat(discover): explain the taste match on both clients — #2377 (clients)
"Matches your taste in shoegaze and dream pop." replaces the seed
attribution when the candidate's own tags overlap the taste profile.
The preference order is the point of slice 6: the tag reason describes the
MUSIC ("sounds like what you like"), while seed attribution describes the
graph ("adjacent to something you played"). When we can say the former, it
is strictly the better explanation. When we can't — the common case, since
tag coverage for out-of-library artists is partial by nature (#2376) — the
card falls back to attribution rather than going blank.
Both clients share the wording, Oxford comma included, and both have tests
asserting the exact strings. That's deliberate: identical copy across two
codebases silently diverges unless something fails when it does.
Android caps at 3 tags client-side even though the server already does.
The server contract could widen; a run-on subtitle shouldn't be how we
find out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f17356560d |
fix(discover): "in about a month" was unreachable in both clients — #2375
The days→months threshold (45) sat above the divisor (30), so a rounded month count of 1 — which needs 15..44 days — could never be reached: every one of those day counts hit the `in N days` branch first. The singular branch was dead code on Android AND web. Lowered the threshold to 30 in both clients, which makes 30..44 days read "in about a month" instead of "in 44 days", and documented the invariant (threshold must not exceed the divisor) next to each constant so the two can't drift apart again. Found by the unit test written for that branch, which is the whole reason to assert on copy that looks obviously correct. Both suites now pin the seam from both sides — 29 days and 30 days — so the branch can't go dead again silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
18a61f1065 |
fix(discover): complete the page-test mock + drop a return from returnsIn — #2375
Two CI failures from
|
||
|
|
6e39471a70 |
feat(discover): snooze affordance on Android + web suggestion cards — #2375
Completes the snooze from slice 3 (#2374), so it's now touchable on both clients (rule #27 — the server side alone was never shippable). Copy is "Not right now" everywhere, never a dislike (rule #101). The parked list even says so out loud: "Nothing here counts against your taste profile." Both clients flip the card in place to a "Not right now" state with an Undo, rather than yanking it out of the grid under the cursor. The row leaves on the next refetch; the persistent way back is a parked-list section below the deck. That list isn't optional garnish — a snoozed candidate is by definition absent from the deck, so without it the DELETE endpoint is unreachable. Android routes the write through the offline MutationQueue per rule #100, as ONE toggle kind (SUGGESTION_SNOOZE_TOGGLE) carrying the desired state rather than two action kinds. That reuses the LIKE_TOGGLE collapse: a queued snooze the user has since undone is dropped unsent instead of replaying after the undo and re-hiding an artist they asked to see. The collapse helper is now a pure top-level function so that rule is unit tested rather than inferred. The repository does NOT enqueue on a 4xx — a permanent rejection would replay to the same failure and would raise a misleading "will sync when online" hint. The common case is a 404 from un-snoozing a row that already lapsed, which is the user's intended end state anyway. Also: an empty deck used to have one meaning (no listening signal yet). It can now also mean "you parked them all", so the empty copy branches — telling that user to go listen to something would be wrong advice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cf7b489fec |
fix(playlists): make the playlist-track replace atomic
android / Build + lint + test (push) Successful in 4m4s
`refreshDetail` did an un-transacted `deleteByPlaylist` + `upsertAll` — the same shape as the Home index write that #2327 just fixed. Room's InvalidationTracker fires after the DELETE, so an observer of `observeByPlaylist` would see `emptyList()` before the new rows land, which is exactly what made every Home row visibly collapse to empty and refill. Nothing consumes `observeByPlaylist` today, so this is not a live defect — it's a landmine. Making playlist detail cache-first later would have silently reintroduced the flicker, and the reason would have been three layers away from the symptom. One `@Transaction` now costs nothing and removes that. `deleteByPlaylist` is left in place as the building block but is no longer called from outside the DAO. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3acac985cd |
feat(home): veil only when content changed; tell the user when it didn't — #2327
android / Build + lint + test (push) Successful in 4m8s
The veil raised eagerly: any trigger over a warm cache put it up before knowing whether the refresh would change anything. So every launch cost ~1-2s of opaque panel even when the pull returned exactly what was already cached — which, now that the section swap is atomic and the index flow dedups on ids, produces no visible churn to hide at all. The veil was covering nothing and only delaying first paint. The raise is now reactive: it fires when the content key actually differs from what was already on screen, and never for a no-op refresh. The baseline is the first state that HAS content, not the first state at all — over a warm cache the cached rows paint a moment after the session starts, and counting that first paint as "a change" would veil every launch, which is the thing being fixed. Cost of reacting rather than anticipating: the veil arrives one emission after the change, so a single atomic swap shows through. Everything messier that follows it — tile hydration, then artwork — still lands behind it. That leaves a hole this closes too: a manual pull where nothing changed would now produce no veil, no movement, nothing whatsoever, which reads as broken. So sessions report an outcome — CHANGED / UNCHANGED / FAILED — and Home surfaces it as "Already up to date" or "Couldn't check for updates". Only for refreshes a person actually asked for. "Already up to date" on every launch, every 03:00 rebuild and every reconnect would be worse than silence, so VeilSessionResult carries a userInitiated bit and background sessions stay quiet. The bit is tracked separately from the request token because the request channel is CONFLATED: coalescing drops the older token, and a user's pull must not be swallowed by a background trigger arriving on its heels. The surfaced failure is a deliberate narrowing of the earlier "silent on give up" call, which is now read as being about background refreshes: for a pull the user deliberately triggered, silence looks broken, and staying silent while the success case speaks would be incoherent. Recovery is unaffected either way. Pull-to-refresh now waits for whichever successor actually arrives — the veil, or the snackbar — via finishedSessions, instead of only ever waiting on the veil and timing out for 2s on an unchanged pull. The Error-state Retry goes through the controller as well, so it gets the retries and reports its outcome; over an empty cache there's no content to protect, so no veil appears. HomeViewModel.refresh() is gone, replaced by retry() and refreshFromPull() — the two things that actually exist. Tests: two changed meaning and are rewritten rather than patched. A failed pull writes nothing, so the veil no longer stands over the retries — it goes up when a retry finally lands. And "waits for content to paint" became "cached content painting is not mistaken for a change", which is the baseline subtlety above. Added coverage for UNCHANGED, FAILED, the cold-load CHANGED case, and the conflation of a user request with a background one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d3b40342b4 |
test(home): drive the veil tests' clock explicitly, not advanceUntilIdle
android / Build + lint + test (push) Successful in 3m54s
All seven new UpdateVeilController tests failed in CI run 3163, and the one test that passed is the tell: it was the only one that never called advanceUntilIdle(). advanceUntilIdle() advances only while *foreground* work remains. Every coroutine this controller owns lives in backgroundScope — it has to, because its consumer loop runs forever and would otherwise stop runTest from completing — so advanceUntilIdle() returned having run nothing at all, and the assertions landed on a session that never started. Hence "exhausts its attempts. Expected <3>, actual <0>" and, where an earlier advanceTimeBy had got a session partway, "retries until the pull succeeds. Expected <3>, actual <2>". Each wait is now an explicit advanceTimeBy sized for what that test still has pending, and the class KDoc says why so nobody folds them back. The drains stay deliberately under maxHoldMs. If a drain overshot the ceiling, "the veil lowered" would stop distinguishing "it settled" from "it gave up" — which is exactly what these tests exist to tell apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4f99b42844 |
ci(android): print full assertion messages for failing tests
android / Build + lint + test (push) Failing after 3m11s
CI run 3161 reported seven failures as bare "java.lang.AssertionError at
UpdateVeilControllerTest.kt:87" — and line 87 is the test's own `fun ... =
runTest {` line, not the assertion. Gradle picks the first stack frame
belonging to the test class, and assertions inside a `runTest { }` lambda
live in a generated suspend-lambda class that gets filtered out, so every
failure in a coroutine test collapses to the function declaration. With the
HTML report unreachable from CI, that leaves nothing to debug from.
testLogging with exceptionFormat = FULL prints the assertion message and the
whole stack trace for failures, which is what makes a coroutine-test failure
diagnosable at all here.
Also drop the NonCancellable floor-join from UpdateVeilController's finally.
Honouring the minimum hold while the scope is being torn down is pointless —
nothing is left to render the veil — and a finally that suspends is a finally
that can resist cancellation. The floor is now awaited in the try instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
5044e7a055 |
fix(home): hold the updating veil until Home actually settles — #2327
android / Build + lint + test (push) Failing after 3m7s
The "Updating your mixes…" veil wiped on and straight back off before the update finished, and a number of churn paths never raised it at all. Three reasons it lowered early. refreshBehindVeil held it for refresh().join() + a flat 500ms, but finishing the network pull is nowhere near the end of the visible work: refreshIndex writes only the section id lists, then each tile hydrates through MetadataProvider (null → skeleton → album), and only then does the cover art load. Second, updatingInternal was a plain Boolean cleared in a finally — reconnect and playlist.system_rebuilt routinely arrive together, so whichever pull finished first wiped the veil off while the other was still running. Third, refresh() swallowed every failure in runCatching, so join() returned "fine" after a failed pull: veil off, content unchanged, no retry. So the veil's lifetime is now driven by watching the screen instead of by a guess. UpdateVeilController raises, runs the work (retrying behind the veil), then holds until the content signature has been unchanged for a quiet window AND nothing is still loading — floored by a minimum hold so it cannot flash, capped by a hard ceiling so it cannot strand, and with overlapping triggers folded into one session rather than racing it. Giving up is silent and sets no latch: the reconnect-driven recovery and the freshness sweeper keep retrying afterwards exactly as before. Cover art was the most visible pop-in and the refresh coroutine cannot see it, so the composition reports it upward: ServerImage — the single choke point behind CoverTile for every album/artist/playlist cover — counts its in-flight loads into an ArtSettleTracker the veil waits on. Art also crossfades now (set once on the ImageLoader, so it applies app-wide) with the placeholder fading out over the same window, which softens the pop everywhere the veil isn't involved. Underneath all of it, the churn is largely no longer generated. replaceSection was delete-then-insert per section, un-transacted, so observeBySection emitted emptyList() — a visible collapse — before refilling, seven times in sequence. It is now one @Transaction across all sections (Room notifies once, on commit, so the empty gap is never observed), and the index flow dedups on the id list, so a section whose contents did not move no longer tears down and rebuilds every tile's hydration flow. fetchedAt is restamped on every write, which is why the dedup compares ids rather than rows. Same fix CachedQuarantineDao already carried for the same reason. Trigger set widened per the operator's call: the initial load over a warm cache (a full re-pull that churned every section completely unveiled), manual pull-to-refresh, scan.run_finished (Home never reacted to it at all), and the playlist.created/updated/deleted/tracks_changed kinds. The veil waits for content to be on screen before raising, so a genuinely cold load still gets its skeleton rather than an opaque panel over nothing. refreshError is now cleared on success rather than at the start of each attempt — with retries, clearing it up front made a failing cold start flash the "Welcome to Minstrel" empty state between attempts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0774f5f55f |
fix(player): suppress TooManyFunctions on PlayerViewModel facade — #1944
android / Build + lint + test (push) Successful in 3m43s
Adding the queue move/remove/clear pass-throughs pushed the VM to 12 functions (detekt cap 11). It's a thin transport facade forwarding to PlayerController, so suppress with a rationale rather than splitting the delegating surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
509cbe79b2 |
feat(player): Android queue — reorder, remove, art, auto-follow, clear — #1944
android / Build + lint + test (push) Failing after 1m26s
Queue screen gains: album-art thumbnails (ServerImage), drag-to-reorder via a grip handle (offset->delta on release, mirroring the web), a remove button per row, auto-follow of the now-playing track with a 'Jump to current' pill when scrolled away, a clear-queue action, and a header count + total-time summary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
dc7b9b78fa |
feat(player): queue move/remove/clear on PlayerController + VM — #1944
Adds moveInQueue/removeFromQueue/clearQueue, each keeping the domain queueRefs snapshot in lock-step with the Media3 timeline (mirrors playNext/enqueue). Media3 onEvents rebuilds uiState so the queue view reflects reorder/removal/clear. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
41ebf1405b |
fix(player): open Android queue scrolled to now-playing track — #1929
android / Build + lint + test (push) Successful in 4m8s
QueueList used a plain LazyColumn with no hoisted state, so the queue always opened at the top and the current track could be off-screen. Seed a rememberLazyListState with the current index (coerced into bounds) so the list renders already positioned on the now-playing row — no post-layout scroll flash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
659554df0e |
fix(player): route notification next/prev to Sonos while casting — #171
android / Build + lint + test (push) Successful in 4m9s
Device verification of v2026.07.15 found the notification/lock-screen next+prev buttons dead during a UPnP cast (play/pause worked). Root cause: the system media controls issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS -> Player.seekToNext()/seekToPrevious(), which are DISTINCT from the seekToNextMediaItem()/seekToPreviousMediaItem() the in-app buttons call and which MinstrelForwardingPlayer already routes to Sonos. seekToNext/Previous were un-overridden, so ForwardingPlayer forwarded them to the paused local delegate — nudging its cursor, which the identity poll then re-synced back to Sonos, so the buttons read as dead. Override seekToNext()/seekToPrevious() to delegate to the media-item variants (the full Sonos path: optimistic local advance + AVTransport Next/Previous + pending-transport gate) when a UPnP route is engaged; plain local playback keeps the default behaviour. Fixes notification/lock-screen/Auto/Wear skip during a cast. Completes milestone #171 Step 3 (#1606 / #606). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
41fe76b90c |
fix(player): identity-locked UPnP cursor + single index writer — #171
android / Build + lint + test (push) Successful in 4m23s
The local ExoPlayer cursor and the Sonos renderer were two competing
sources of truth for "what's playing" during a cast. The delegate cursor
lagged (forward-only, index-based, size-capped sync, skipped during every
load/re-cast window), and TWO writers of PlayerUiState.queueIndex fought:
PlayerController.onEvents (reading the lagging cursor) stomped the
Sonos-derived index the position tick published, so the in-app player
flickered to the pre-cast track and the notification metadata went stale.
Step 1 — MinstrelForwardingPlayer.syncLocalCursorToRemote (replaces
maybeSyncLocalCursor): align the paused delegate cursor to the track the
renderer is actually playing, matched by track-id parsed from the Sonos
TrackURI (/api/tracks/{id}/stream) against delegate MediaItem.mediaId
(== TrackRef.id). Both directions; survives queue-reload index wobble;
nearest-occurrence tiebreak for duplicate tracks; falls back to the Sonos
Track index; suppressed during load and while a user transport is pending
Sonos's ack. The cursor is now the single authoritative "current track"
that both the in-app UI (onEvents) and the notification (getCurrentMediaItem)
read.
Step 2 — PlayerController: the position tick now patches only
position/duration/play-pause/buffer; onEvents is the sole writer of
queueIndex/currentTrack. Removed desiredQueueIndex, the forward-only
trackChanged path, and publishTickIfChanged. One writer, no stomp.
Part of milestone #171 (unify local + UPnP behind one cursor). Fixes the
flicker + stale-notification symptoms; supersedes #1211/#608/#612.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
235839b696 |
feat(player): heart/like button in queue view (web + android) — #1596
The full-screen player's queue ("up next") track rows were the one
track-list surface missing the like heart that TrackRow/PlaylistTrackRow
(web) and playlist/album/artist detail (Android) already carried.
Web: render the shared <LikeButton> in QueueTrackRow between the row body
and the remove button (serves both the /now-playing aside and the mobile
QueueDrawer, same component). LikeButton already stops click propagation
so it won't trigger play-on-click.
Android: PlayerViewModel now exposes likedTrackIds (set-based, the same
idiom as the detail VMs) + toggleLikeTrack; QueueScreen threads
liked/onToggleLike through QueueList → QueueRow, which renders the shared
LikeButton after the duration. Liked state stays sourced from
LikesRepository by track.id — no TrackRef data-model change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5749f48b4a |
feat(taste): device-class context conditioning — #1551
Milestone #160 Opt 3b. Adds device class as a third context axis on top of the #1531 time-of-day/weekday affinity: on the radio path, a candidate is boosted when its artist concentrates in the current (daypart × weekday × device) cell. Client-sent (client_id is opaque; no UA stored), so it's captured going forward and applies to radio only (daily mixes are cron-built with no device → stay device-agnostic). Server: - Migration 0048: play_events.device_class text NULL (no CHECK; normalized in Go — one whitelist entry per new client class, not a migration). - events.go: eventRequest.device_class + normalizeDeviceClass (whitelist → mobile/web/…, else "other", empty → NULL); threaded through both RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent. - ListArtistContextPlayCountsForUser gains a current-device param; the cell FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531 time-only behaviour exactly (used by mixes). SessionVector.DeviceClass carries it; the radio handler derives the current device from the user's latest play (GetLatestPlayDeviceClassForUser) — request-free proxy. - No new tuning knob: device narrows the existing ContextAffinityScore (reuses context_time_weight). Clients: - web: play_started sends device_class 'web'. - android: play_started + offline replay send 'mobile' (EventsWire + PlayOfflinePayload + MutationReplayer + PlayEventsReporter). Test: LoadContextAffinity device-narrowing integration test (mobile vs web artist separation; device-agnostic parity). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
40056d2e9a |
feat(android): admin tag-enrichment sources screen (#1521)
android / Build + lint + test (push) Successful in 3m43s
Bring the tag-sources settings surface to the Android admin, over
/api/admin/tag-sources — the operator can enable/disable each provider,
paste an API key (e.g. Last.fm), and test the connection from the phone,
mirroring the web integrations card.
New vertical stack (mirrors the AdminUsers/AdminRequests pattern):
- AdminTagSourcesApi (Retrofit, api/admin/tag-sources GET/PATCH/{id}/test)
+ UpdateTagSourceBody
- AdminTagSourceWire / list envelope / TestTagSourceWire + domain
AdminTagSourceRef / TagSourceTestResult
- AdminTagSourcesRepository (shared Retrofit, .toDomain() at bottom)
- AdminTagSourcesViewModel (@HiltViewModel, sealed UiState, optimistic
toggle + key-save + per-row test result, network auto-recovery)
- AdminTagSourcesScreen (MinstrelTopAppBar + PullToRefreshScaffold; per
provider: Switch, password key field + Save, Test connection + result)
- nav route + graph registration; AdminLanding gains a "Tag sources"
section card (count = enabled providers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
d6ee5a304d |
fix(home): un-clip CoverTile overlay so play button isn't cut off (#1495)
android / Build + lint + test (push) Successful in 3m58s
The play button overlaid on artist circles sat under the circular frame: CoverTile clipped the Box that held both the artwork and the overlay, so a BottomEnd button on a CircleShape avatar — which falls in the square's corner, outside the circle — got clipped away. Draw the overlay on an outer un-clipped Box; clip only the inner artwork + background to `shape`. Corner-anchored overlays (play button, variant pill) now sit on top of the frame. Bounds/alignment unchanged, so Album and Playlist tiles keep their layout (pill is padding-inset; their play buttons are simply no longer clipped at the corner radius). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d497c57d6c |
fix(home/test): kotlin.test assertTrue message overload
android / Build + lint + test (push) Successful in 3m46s
The trailing-lambda assertTrue overload treats the block as the *condition*, not a lazy message — switch to assertTrue(Boolean, String). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2e92ba498c |
fix(home): buildSongsLikeRow ReturnCount ≤ 2 (detekt)
android / Build + lint + test (push) Failing after 3m4s
Collapse the three early returns into a single `when` expression — detekt's ReturnCount capped at 2. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c1d143cf4a |
feat(home): Songs-like → dedicated row + wider spread (#1491)
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.
Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
and daily rotation preserved.
Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
/ orderedRealPlaylists no longer reserve the 3 songs-like slots.
Offline shows cached mixes (available-first), hides the row when none.
Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
and removed songs-like from the Playlists carousel.
Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
772a52b23e |
feat(home): "Updating your mixes…" veil over daily-rebuild churn
android / Build + lint + test (push) Successful in 3m58s
The 03:00 system-playlist rebuild (playlist.system_rebuilt) re-pulls Home, and refreshIndex() rewrites every section delete-then-insert — so all 7 rows + the Playlists row visibly collapse to empty, refill with skeletons, then pop in per-tile as metadata hydrates. Read as a lot of busy on-screen movement. Raise an "Updating your mixes…" veil for automatic refreshes only (daily rebuild + reconnect re-pull): HomeViewModel.isUpdating, driven by a new refreshBehindVeil() the event/recovery collectors call in place of refresh(). It holds through the pull plus a short settle so hydration lands behind the veil, then wipes off. Manual pull-to-refresh keeps its PullToRefreshBox spinner; cold start keeps the skeleton. The veil is a near-opaque, background-tinted overlay that wipes in from the left and swallows taps while raised. Extracted HomeStateCrossfade so HomeScreen stays under detekt's LongMethod. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7628330f72 |
test(android/library): stub SyncController.lastSyncError with a real StateFlow
android / Build + lint + test (push) Successful in 3m45s
The VM now combines lastSyncError into uiState; a relaxed-mock flow never emits, so the combine never fired and the state sat at Loading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
09102dc615 |
fix(android/resilience): auto-recover failed loads on reconnect (#1245)
android / Build + lint + test (push) Failing after 3m3s
Failed loads used to stay failed forever: no screen ViewModel listened to server-health recovery, and the cache-first screens swallowed refresh errors so a cold load against a down server looked like an empty account. - connectivity/Recovery.kt: recoveries() — per-collector flow of down→Healthy transitions; the screen-level half of the idiom SyncController/MutationReplayer/DiagnosticsUploader already use. - Every screen VM now re-runs its load on recovery (cache-first screens unconditionally; direct-load screens when sitting in Error). - Cache-first error surfacing: Home / Playlists / Liked track refresh failure; Library reads SyncController.lastSyncError (new) — empty cache + failed refresh now renders Error-with-Retry, not welcome copy. - Requests: 12s poll also retries from Error (was structurally unable to escape it — the in-flight predicate required a Success state). - Search: retry() bypasses the distinctUntilChanged query pipeline so a same-text resubmit after a transient failure actually re-runs. - ArtistDetail: secondary sections (similar artists / top tracks) re-fetch on recovery instead of staying silently absent. - ErrorRetry: LazyColumn wrapper (pull-to-refresh works on error states, same rationale as EmptyState) + optional title; adopted on every error branch; PlaylistDetail's one-shot ErrorBlock removed in its favor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 |
||
|
|
392454b249 |
feat(android/diagnostics): track-identity enrichment + zero stale Sonos state
android / Build + lint + test (push) Successful in 3m27s
Numeric indices wobble across re-casts (offset +1↔0 seen during output toggling), making "same track?" ambiguous. Enrich both the track_change event and the heartbeat with local_track_id (TrackRef.id) and sonos_uri (RemotePlayerState.currentTrackUri — the URL the speaker is actually streaming), so a desync is unambiguous. Also fixes the cast→phone stale-state pollution (#1211): sonos_* is now zeroed unless a remote route is active, via a shared putSonos() helper — so a just-ended cast's RemotePlayerState can't masquerade as live Sonos data in the diagnostics. Refs Scribe M9 (#119), tasks #1210 #1211. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW |
||
|
|
cdfc79e6ab |
feat(android/diagnostics): per-skip track-change event
android / Build + lint + test (push) Successful in 3m35s
Heartbeats are 45s apart and missed a rapid skip burst (local_index 16→22 in one gap). Add a 'playback' track_change event emitted on each queue-index / current-track change, snapshotting local vs Sonos index+position + server_health + upnp_loading + route — so a transient skip-induced desync is captured at the instant it happens. (uiState is a conflated StateFlow, so a very rapid burst may coalesce intermediate indices; we still get the boundaries + the snapshot.) Refs Scribe M9 (#119), task #1210. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW |
||
|
|
79f2d79a2e |
feat(diagnostics): 'playback' kind, newest-first sort, fix active-route subtitle
Relabel (#1204): route + player_state events fire for every output route, not just UPnP — split them into a new 'playback' kind; 'upnp_sync' now means genuinely UPnP/Sonos signal (drops, resync). Migration 0037 adds 'playback' to the kind CHECK; server whitelist, Android reporter labels, and the web kind filter updated. Web sort: the diagnostics list gains a Newest/Oldest-first sort (default newest at top); export follows the displayed order. Fix (#1205): OutputRoute.isConnected was derived from RouteInfo.connectionState, which stays DISCONNECTED for local SYSTEM routes even when active — so a connected Bluetooth device showed "Available" and reported connected:false. The picker subtitle now uses isSelected (route == selected route); the dead isConnected field is removed and the misleading `connected` field dropped from the diagnostics route event (it only ever logs the active route). Refs Scribe M9 (#119), tasks #1204 #1205. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW |
||
|
|
bffa5b28bd |
fix(diagnostics): StateFlow distinctUntilChanged build error + AdminUser test fixtures
- DiagnosticsReporter.collectServerHealth: drop distinctUntilChanged() on networkStatus.state (StateFlow is already distinct; the deprecation warning is a hard error under allWarningsAsErrors). - web users.test.ts: add debug_mode_enabled to the alice/bob AdminUser fixtures now that the field is required on the type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW |
||
|
|
8a58f07237 |
fix(android/diagnostics): keep uploader drain within ReturnCount gate
android / Build + lint + test (push) Failing after 2m28s
drainSafe/drain each had 3 returns (detekt ReturnCount ≤ 2). Collapse the guard clauses and convert drain's loop to a `more` flag — same behavior, zero/two returns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW |