13.14.5
Changelog
Changes in v13.14.4..HEAD:
Highlights
- A Cached Song Now Survives a Restart — Hush kept its SpotiFLAC playback files inside Media3's player-cache directory, and
SimpleCachedeletes files it does not recognise anywhere under its own directory. So every cold start emptied the folder: measured on the reporting device, a force-stop and relaunch took the cache from 6 files to 1, withcache reconcile: orphans=0 stale=[bcdd50…, 96fd74…, ab23e9…, 3108506…]— Hush's own sweep had nothing to delete, it was told about the loss afterwards — and the engine's own index then reportedISRCIndex … 0 files. The player cache now has aplayer-cache/subdirectory to itself, so it can only delete its own bytes. Verified after the fix: a 29,038,603-byte cached file survives a restart and playback serves it directly —url resolve skipped mediaId=vvfBF9bt29g: already on disk (…media), with no provider contacted. (Hush) - The Format Row Names Its Evidence —
FLAC • 1411 kbpscould come from a resolve in this session, a row an earlier resolve saved, a file on this device, or the audio Media3 is decoding right now, and the screen did not say which — so a stale row beside a streamed track was indistinguishable from a lossless file being served. The row now carries its provenance as one value (FormatRow+ServedFormatClaim), and a track whose stored row had to be refused shows what is actually decoding (DecodedAudioRow) instead of an empty codec line. (Hush) - Motion That Survives a Device With Animations Off — with the system animator duration scale at 0 — a car head unit, or
animator_duration_scale = 0as measured on the reporting device — Compose silencesanimateFloatAsState,rememberInfiniteTransitionand the indeterminate*ProgressIndicators, so buttons jumped between two sizes, no ripple appeared, and a spinner that means "something is happening" painted one frame and held it. Press motion and the indeterminate indicators are now frame-driven (HushPressMotion,FrameDrivenMotion), which no system setting can silence: a press contracts, springs past its own size and throws a two-wave ring, and the play/pause button animates while a download is in flight instead of looking frozen. (Hush) - A Playback Confirmation Is Not a Flaky Stream — when every stream client answered
LOGIN_REQUIRED, media3 wrapped it asERROR_CODE_IO_UNSPECIFIED— the code a dropped connection produces — so the player spent its retry budget clearing caches and re-resolving with rotated clients, each round re-running the whole client sweep, and the panel naming the one action that works arrived late or was replaced by an auto-skip. The demand is now recognised through the wrapper (PlaybackConfirmationRequired) and surfaced immediately, with its "play this one from YouTube Music" action. (Hush) - A Blocked Address Heals Itself — a gateway block is the gateway refusing an address, and an address changes far more often than the app does. Measured on the reporting device: the same install that was refused with
{"retry_after":75358}was served normally the instant a VPN came up, while the block suppressed every source for the ~21 hours it had left, and the only way out was for the user to find "Try again now".SpotiFLACRouteWatchnow watches both halves of a route — the platform's network change, and the proxy preference, which nothing reports at all — and asks the gateway once per change, never on a timer. The way into a refused network is covered too: with no block recorded there was nothing to disprove, so the first play after a switch still walked the whole provider chain before falling back. (Hush) - Car Screens Get Spotify Playlists — Android Auto's library only exists once a head unit connects, which is why a missing folder, a switch that does nothing or a track a tap cannot play go unnoticed until someone is sitting in the vehicle. Spotify playlists now appear in "Mixes and radios" beside YouTube's — gated on their own switch and a connected account — the "Visible sections" order is finally consulted by the library service, the quick-add destination is a real session command with a real handler, and resolved online tracks are marked as browsable leaves, which media3's library session requires (
mediaMetadata must specify isBrowsable). (Hush)
SpotiFLAC Sources
- The engine's state is not the switch —
spotiflac=truewith every session lapsed put SpotiFLAC first in the resolve order, the sweep spent its budget on four sources the runtime refuses to download with, and the track ended in "no source could serve this" while YouTube was enabled and could have played it at once.SpotiFLACAvailabilityseparates the user's intent from the engine's state: an engine with nothing usable and sources waiting on the user is paused rather than consulted, and a source with no session contract (SoundCloud, the YouTube Music provider) is never the reason to pause. (Hush) - A stall is attributed to the provider that was actually tried — Hush hands the runtime the whole candidate list and the runtime falls through it by itself, so the provider an abandoned attempt went quiet on is routinely not the one it was requested with. Measured:
Native download start: source=amazon→Trying provider: deezer→Provider temporarily unavailable for extension deezer; retrying in 10s→provider stalled id=amazon … abandoned. That demotion moved an innocent provider behind the wedged one and sent the next sweep intodeezeragain, paying the same timeout.SpotiFLACRuntimeWalkreads the walk out of the runtime's own log, as evidence rather than a verdict — only lines whose whole purpose is to name a provider are matched. (Hush) - A failing source says whose service failed — the Test row reported
Zarz API is offline, which reads like a verdict on the user's session or verification, when deezer's own gateway check was{"ok": false, "error": "fetch failed"}. It now prefers the runtime's own sentence —Provider's own service - Deezer: fetch failed— scoped so it can never read as a session verdict, and the same verdict is kept (with its reason) and shown on the row without pressing Test,Deezer: tried last for 10m. It ends the moment the source serves again:Native download ok: source=qobuz-web bytes=8690812 codec=flacclears it, and a source that just served is not unavailable. (Hush) - An installed extension updates itself — a package file was written once and trusted for the life of the install, because the download only ran when the file was missing. Measured:
amazonsat at 2.3.8 while both registries published 2.3.10, with no code path that could replace it.SpotiFLACExtensionUpgradecompares the registry, stored and installed versions (a version it cannot parse is never treated as newer), and Audio Sources shows both versions with a one-tap update that reuses the refresh path. A startup sweep reports what it moved in one line, and each source keeps a check log (when it was last checked, which registry it came from). (Hush) - One proxy setting, not two — the relay calls leave through Hush's own Internet proxy (
SpotiFLACRelaySettings), built by the sameProxyUtils.createProxyOrNullthe rest of the app uses, so "enabled", "host", "port" and "type" can only mean one thing. A SpotiFLAC-only proxy would have been a second answer to the same question. (Hush) - The legacy gateway session is gone — Hush's own
GET /v2/bootstrap→ Turnstile →POST /v2/session/exchangesession can serve no source: the gateway binds a session to the app version that minted it, so the same path answered 403 signed astidal-web@1.2.6and 428 VERIFY_REQUIRED signed as the client version (measured). The gateway no longer hands one out either — bootstrap returns a fresh challenge on every launch — and the surface that could complete it was gone, so its state (CHALLENGE_PENDING, forever) had no repair.SpotiFLACInstallIdentitykeeps what is still true: one install id, and the client version and relay address every call is described by. (Hush) - The title a catalogue sees is a song name — providers were handed the video's own metadata, so a title like
"Mizhiyil Mizhiyil | Maayabazar | Mammootty | Sheela Koul | Rahul Raj - HD Video Song | Sujatha Mohan"credited to a channel ("Malayalam Hits","<Artist> - Topic") produced no match, an empty provider track ID andInvalid Tidal/Deezer track ID— which reads as "SpotiFLAC is broken" for any track whose video title is not already a clean song name.SpotiFLACLookupTexttakes the first|-separated segment, drops format tags while keeping real variants ("Slowed Down Version", "Remix"), and refuses a channel-shaped artist outright. (Hush) - One lookahead, asked twice — how many tracks are warmed now comes from
SpotiFLACPrefetchPlan, which both engines consult, so the count is the user's "Prefetch upcoming songs" setting rather than a second opinion, and the plan never warms the track being played in its own lookahead and never repeats a position when it wraps. The parallel-fetch switch is wired: with it on, every enabled source is asked at once and the first answer wins. (Hush)
Downloads and Caching
- One key, one file — the serial walk recorded the runtime's container-suffixed output while the race landed the canonical name, so a track could exist twice: measured on device, a 25,817,207-byte
.mediabeside a 24,542,701-byte.media.m4aof the same song, only one of which was indexed — so nothing counted the other. Every path now lands on the one path a key owns, and leftovers for that key are pruned when the file is recorded. (Hush) - The cache's file names are parsed from the first dot — every sweep and in-flight guard read
name.substringBeforeLast('.'), which turnsK.media.m4a.partialintoK.media.m4a— never a track key. So a partial was never protected while its download was running (a sweep could delete bytes mid-transfer, and the only symptom was a download that mysteriously had to start again) and was never recognised as rubbish afterwards. The rules live once, inSpotiFLACCacheFiles, with the device's own file names pinned in tests. (Hush) - Removing a download clears the song, not just the index — removal goes through the pinned-or-not discard, so the next play fetches the track again from the configured source priority rather than being answered by the copy that was just deleted. (Hush)
- What is already on this device is served first — a user download or a cached playback file owns its track before any resolver runs (
LocalPlaybackFiles), the download wins over the cache, and a file that is missing or empty is not ownership: serving a truncated file is worse than a slow start. (Hush) - The player cache is measured on its own — trimming the streaming cache counted the song-cache root, which also holds the SpotiFLAC files and the download cache, so it evicted streamed bytes to pay for storage it does not own. It now measures its own folder. (Hush)
Playback and the Player
- Audio Sources is one screen with two engines — YouTube and SpotiFLAC are collapsible sections carrying their own settings, stream quality moved under YouTube (where the engine it applies to lives), and the SpotiFLAC screen's duplicate cache size and clear-cache rows are gone: they are the Storage screen's settings, and the SpotiFLAC cache's usage is now reported there beside the limit it shares. (Hush)
- Every long label scrolls continuously —
basicMarquee()stops after three passes, which leaves a long title frozen part way through and reads as a broken label.Modifier.hushMarquee()pins the iteration count and delays, and a build task (verifyMarqueeRouting) fails the build if a call site drifts back to the bare default — a one-word edit no compiler or lint check can see. (Hush) - The player's buttons feel like buttons — press motion was rebuilt on pure curves of elapsed time (so it can be checked without a device) and applied across the player, the mini player, the queue header, list rows and the menus, including like, download, shuffle and the overflow. A probe had to be built to prove the ring is really drawn outside the button's bounds, because
screencapon the reporting device takes 1347ms as PNG and 613ms as raw pixels while the ring lives 520ms — two earlier measurements answered "no ring" from a frame taken after the effect had gone. (Hush) - PulseMatrix is on by default, on the Neon theme — the codec row is on by default too; a stored value still beats either default, so an upgrade that never touched the setting gets the feature and anyone who switched it off keeps their choice. Minimising and restoring the player no longer stops the visualiser. (Hush)
Stream Resolution
- Clients this device cannot use are moved to the back, not dropped — a client whose formats are
signatureCipherneeds YouTube's own player JavaScript to decipher, and when YouTube rotates it ahead of the bundled extractor every ciphered candidate fails (Could not find deobfuscation function with any of the known patterns) — while WEB_REMIX leads the sweep whenever login and Web PoTokens are available, so every playback paid for four ciphered candidates before reaching a client that needs no deciphering. An address being refused (LOGIN_REQUIRED— measured: five of fifteen client families, on a VPN egress, while others served the same tracks seconds later) costs a request each on every resolve too. Both are remembered per family, both marks expire, and nothing is dropped (StreamClientAvailability). (Hush) - A refused address earns a retry — a sweep that ends because the address was challenged is not the same as a client having no answer for the track, and without the distinction every dead sweep looked alike: the track was abandoned and the queue moved on, which is the "songs skip for no reason" symptom.
StreamSweepPolicyretries twice (2s and 6s) only for the address kind. (Hush)
Performance and Older Devices
- The device tier is decided by the heap, not only the flag — a car head unit on Android 11 usually ships ≥1.5 GB without
ro.config.low_ram, so everyisLowRamDevice()branch skipped it while its per-process heap was capped at 128–192 MB — the cap, not the flag, decides when the system kills the process.DevicePerformanceclassifies on both (LOW_RAMat ≤128 MB,CONSTRAINEDat ≤192 MB) and the cadences for word-synced lyrics, the position ticker and the visualiser slow down on the weaker tiers only; the reporting phone reportsheapgrowthlimit=256mand staysSTANDARD, byte-for-byte the timing it had. (Hush) - Memory pressure no longer destroys the warm playback engine —
TRIM_MEMORY_RUNNING_LOWis 10 andTRIM_MEMORY_UI_HIDDENis 20, so the "user left" branch was unreachable and ordinary foreground pressure was treated as a departure: artwork was freed and the BotGuard engine was destroyed. The next playback then rebuilt a WebView from scratch — which is where "the first song takes ages on weak devices" comes from. Artwork is freed at any pressure, the engine only atUI_HIDDENand above, as a tested pure function. (Hush) - The launcher icon is no longer rewritten on every launch — the icon aliases were written twice plus a launcher broadcast on every cold start, even when the state was already correct, and those are package-manager writes rather than cache updates. Verified on device: two consecutive cold launches now log zero icon updates. (Hush)
- Lists stopped re-querying the database per frame —
collectAsStatekeys its collector on the flow instance, so a flow built inside a composable is re-collected on every recomposition, and for a Room flow that re-registers with the invalidation tracker and re-runs the query: the player was re-querying on every frame it recomposed.rememberFlowandrememberDownloadare applied to the player's surfaces, the menus, the rows' badges and the media-info panel, and the second implementation of "one row's download entry" is gone. (Hush) - Crossfade and pre-warm are gated on the device tier — crossfade's second player is the largest single allocation the app can make (renderers and buffers, tens of megabytes) and was built on a device capped at 128–192 MB just to blend two tracks; the BotGuard pre-warm was eagerly bootstrapping a WebView on every launch of such a device, which is the documented cause of launch-time OOMs. (Hush)
- The benchmark harness measures instead of guessing — it polled
dumpsys windowwhile force-stopping asynchronously, so it timed its own poll and credited launches of a process it had never killed: it reportedprocess=TIMEOUTandprocess=0ms. It now waits for the pid to genuinely disappear, times witham start -W, and labels an absent readingn/a. (Hush)
Android Auto
- The switches were not read by anything —
AndroidAutoYouTubePlaylistsKeywas referenced only in the settings file (the YouTube playlists were there because the code never looked at the switch), the "Visible sections" order was never consulted by the library service, and "Quick-add destination" was a constants entry with no session command, no handler and no button. All three are wired now, and a debug library session makes the whole tree checkable without a car:scripts/android-auto-browse-smoke.shconnects the app's ownMediaBrowserto its ownMusicServiceand writes the tree to a file. Measured: root 9 entries with sections honoured, YouTube switch off → 0online_playlistentries, quick-add advertised on connect with a custom layout of Like / Repeat / Shuffle / Add to playlist. (Hush)
Waze
- The first play from Waze works after a relaunch — the bridge is the player Waze sees, so it has to answer one question honestly: is music playing right now? It learns that only from Hush's snapshots, and when Hush's process was killed with a track playing, the bridge kept advertising
PLAYING— frozen at the same position — indefinitely. Waze derives its transport button from that state, so it drew a pause button for music that was not playing: a tap sent a pause to a silent player and nothing happened, and the only way to start music from Waze was to start it from Hush first. The silence is now the signal — Hush publishes about once a second while playing, so a gap of several seconds while the bridge claims playback means the player is gone, not briefly quiet (WazePlaybackFreshnessPolicy, unit-tested). (Hush) - The bridge updates in place — the shim is signed the way the app is, so an installed bridge can be replaced by a newer one instead of needing an uninstall first, and a repair removes the old package through
PackageInstaller.uninstallwith its status callback (one confirmation rather than a trip to App Info, which stays as the fallback). An install interrupted by Play Protect is detected and the user is guided to "Scan app" or to a retry instead of being shown a bare failure. (Hush) - Uninstall refreshes the row — the bridge list updated after an install and only after leaving and returning to the screen otherwise; both directions now report their outcome. (Hush)
- The whole lifecycle is a script —
scripts/waze-bridge-lifecycle-smoke.shdrives detect → repair → install → update in place → uninstall through the debug receiver and reports pass or fail, distinguishing a Play Protect block from a real failure so a release can be gated on it. (Hush)
Release Engineering
- Hand-written containers cannot ship —
verifyDownloadNamingruns on everyassemble*and fails the build when an extension is handed to the naming path as a literal, the exact shape of the shipped bug that stamped an MP4/AAC file.flac.verifyMarqueeRoutingdoes the same for marquee call sites, andNewApiis fatal on bothappandwaze-shim. All three are proven by reinstating the bug and watching the task fail. (Hush) - Device seams for flows that need hardware — the debug-only receivers gained download, remove-download, source-row, source-test and renewal operations, an Android Auto library browser, and
hush://route?to=<route>to open a settings screen from a shell. None of it exists in a release build. (Hush) - Semantics probe — on the reporting phone
uiautomatorsees the mini player and the bottom bars but no node for Home, Search or Library — not even thescrollablenode aLazyColumnalways carries — while those screens render and respond to taps, which is why scripted checks of them silently do nothing instead of failing.scripts/semantics-probe.shruns an in-process Compose semantics probe inside the app (debug only) so the loss can be attributed to the app's own tree or to the platform's accessibility bridge, rather than guessed at from outside. (Hush)
Housekeeping
- Version bumped to 13.14.5 (versionCode 175). The Waze shims derive their version from the app's, so they move with it. (Hush)
- Unit tests pass (802 tests, all green), including new suites for the player-cache layout migration, the cache file naming rules, the Android Auto playlist selection, the provider stall policy, the extension upgrade decision and the format row's provenance. (Hush)
- Lint
fossMobileUniversalDebug: 0 errors, withNewApifatal andabortOnErrortrue inappandwaze-shim;fossandgmsboth compile. (Hush) - Verified on a connected NE2211: a cached SpotiFLAC file survives a force-stop and relaunch and is served locally with no provider contacted; the legacy player-cache spans and shard directories are swept from the song-cache root once, leaving the SpotiFLAC folder and the download cache untouched; Media3 now deletes only inside its own
player-cache/folder; noFATAL EXCEPTIONacross the runs. (Hush) - Note for testers: the SpotiFLAC gateway was answering HTTP 429 during this release's verification (
Playback uses YouTube until it lifts), so a fresh provider download could not be exercised end to end; the cache fix was proven with a real 29 MB FLAC placed at a seeded cache entry, which is the exact path that was broken. (Hush)
Upstream credits
Hush is built on ArchiveTune and combines features, fixes, and UI from several open-source YouTube Music clients—including Metrolist, Vivi Music, and Echo Music. Those projects are credited below; their licenses and copyright notices are preserved in source.
Thank you to the maintainers and contributors of every project listed above.