Skip to content

Wip: Fix/library memory and shuffle anr - #1437

Open
mmarca-tech wants to merge 5 commits into
OxygenCobalt:devfrom
mmarca-tech:fix/library-memory-and-shuffle-anr
Open

Wip: Fix/library memory and shuffle anr#1437
mmarca-tech wants to merge 5 commits into
OxygenCobalt:devfrom
mmarca-tech:fix/library-memory-and-shuffle-anr

Conversation

@mmarca-tech

@mmarca-tech mmarca-tech commented Aug 7, 2026

Copy link
Copy Markdown

What is it?

  • Bugfix (user facing)

Description of changes

  • image: sample composite cover decodes

BitmapFactory.decodeStream with no BitmapFactory.Options. That decodes album art at its native resolution, and it holds four of them live simultaneously before compositing. A 2000×2000 cover is 16MB as ARGB_8888, so one artist thumbnail could transiently need 64MB+. This runs for every artist, genre, and playlist row concurrently across Coil's dispatcher while you scroll. The handed the full-resolution bitmap straight back as the list thumbnail.

  • playback: fix quadratic queue unscrambling

Shuffling a library-sized queue means roughly N²/4 element moves on the main thread. It's not a cold path either: resolveQueue() calls it on every ack while shuffled, including every automatic track transition. Switched to an ArrayDeque with addFirst/addLast, which is O(1) amortized.

  • musikr: release cache mapping after loading

DBCache.kt memorizes selectAllSongs() into a Map so a load doesn't issue a query per file but never releases it. I added a release() called from cleanup(), which terminates every load; it repopulates lazily if another load starts.

Fixes the following issues

#957

APK testing

https://drive.proton.me/urls/0GFWF4DK00#h3UwSGnRo2iS

Due diligence

@foss-

foss- commented Aug 7, 2026

Copy link
Copy Markdown
  1. installed debug apk
  2. setup music folder
  3. initial scan (10 minutes)
  4. can reliably trigger an app hang by scrolling songs up and down using the sidebar scrollbar / Issue can also be triggered by tapping shuffle

@mmarca-tech
mmarca-tech force-pushed the fix/library-memory-and-shuffle-anr branch 2 times, most recently from 38811cc to 588dde0 Compare August 8, 2026 14:58
mmarca-tech and others added 5 commits August 8, 2026 18:15
Two independent causes, both measured on device with a scripted
scrollbar drag.

scrollToThumbOffset ended in scrollBy, which does not jump -- it asks
the layout manager to travel that distance, laying out and binding every
row it passes over. One pixel of thumb travel maps to hundreds of pixels
of content on a large library, so a single touch event could lay out
thousands of rows. Frame traces put 2.5s of a 2529ms frame between
HandleInputStart and AnimationStart, entirely inside touch handling
before any drawing began. Keep scrollBy for the small deltas a slow drag
produces so fine positioning stays exact, and jump straight to the
target row past a couple of screenfuls.

That left the bottleneck in measure/layout rather than input. Coil
defaults its fetch and decode contexts to Dispatchers.IO, which is 64
threads wide, so flinging past a row per frame let dozens of cover
decodes run at once and take every core with them. No individual decode
is slow; the UI thread simply never got scheduled. Both stages now share
one bounded dispatcher, capping total concurrent cover work.

  janky frames  28 -> 3 -> 0.26%
  worst frame   2529ms -> 828ms -> 12ms (99th percentile)

Disabling cover loading outright scored the same as the bounded
dispatcher, so between them these account for all of the measured jank.

Also memoize the fast scroll popup text against the position it came
from, rather than re-deriving it on every draw pass (it re-read the sort
from SharedPreferences and could allocate a Calendar or MeasureFormat).
This one is a cleanup, not a fix: measurement ruled it out as a cause of
the jank above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aimed at the OutOfMemoryError and ANR reports from users with large
libraries. Each of these was found by reading the code against the
reported stack traces; none could be reproduced on a 693-song test
library, so treat the reasoning rather than any measurement as the
justification.

CoverCompositionFetcher decoded source covers with a bare
decodeStream, so artist/genre/playlist art was decoded at native
resolution and four were held at once before compositing. A 2000x2000
cover is 16MB as ARGB_8888, and these run concurrently while a list
scrolls. Decode bounds first and sample to the composition size; nothing
above it was ever drawn.

DBCache memoized the entire cache table so a load would not issue a
query per file, but never released it, and the holder is a singleton --
a second complete copy of every tag of every song retained for the
lifetime of the process. Release it once cleanup ends the load.

MediaSessionHolder resolved a description for every song in the queue
and handed the whole list to setQueue, which parcels it to the system.
Shuffling all songs makes that the entire library, on the main thread,
on every queue mutation, large enough to risk TransactionTooLargeException.
Publish a window around the current song and let it follow the index;
ids stay absolute so skip-to-item still addresses the right song.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThemeOverlay.Auxio.UncheckableIconButton defined colorContainer and
colorOnContainer but not the Checked/Unchecked variants. Material's icon
button color selectors reference those regardless of whether the button
can actually be checked, and the missing attributes do not fall back
gracefully: inflating the ColorStateList throws
UnsupportedOperationException, which ResourcesCompat catches and logs
with a full stack trace -- on the main thread, every time one of these
buttons is laid out. ThemeOverlay.Auxio.Button.PrimaryTonal right below
it already defined the full set.

Caught while tracing a multi-second UI freeze. In the eight seconds the
main thread was blocked, ResourcesCompat accounted for 8856 of roughly
10000 log lines from the app, from 144 separate throw-and-dump events.
Over the whole capture it was 68304 lines from 1168 events.

Defining the full set takes that to zero, verified by reproducing the
same interaction:

  ResourcesCompat log lines        68304 -> 0
  ColorStateList inflation errors    584 -> 0

This affects release builds too; the logging is not debug-gated. Note
the failed inflation also meant these buttons were falling back to
framework default colors rather than the intended ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WidgetComponent asked for Size.ORIGINAL, so every widget update decoded
the full-resolution album art, ran three transformations over it, and
marshalled the result into a RemoteViews bound for the launcher. The
widget draws that cover at a couple of hundred pixels -- the default
widget is 180x100dp -- so nearly all of it was wasted.

update() runs on almost every playback event, including
onProgressionChanged, so this fires constantly. Shuffling repeatedly
produced 164 updates in one capture and drove total PSS past 1GB while
the Java heap stayed under 130MB, because bitmap memory is native rather
than Java. MediaSessionHolder already clamps its own bitmap via
getBitmapDimensionLimit; the widget path never did.

Bound it to 512px. Measured over ten rapid shuffles:

  PSS growth       +175MB -> +7MB
  Graphics growth   +53MB -> unchanged
  janky frames      14.76% -> 0.61%

The corner radius applied to this bitmap is an absolute pixel value
sized for the widget, so bounding the bitmap also makes that radius land
at roughly its intended proportion instead of being invisibly small on a
multi-thousand-pixel cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Queue ownership moves from ExoPlayer into ExoPlaybackStateHolder. The
player now only ever holds a window of up to 51 MediaItems centered on
the current song, slid as playback advances; the full queue lives in
plain heap/mapping/index fields that resolveQueue() snapshots, in the
same representation RawQueue always used.

Handing the player a MediaItem per queue entry was the largest single
consumer behind the large-library OutOfMemoryErrors: measured at ~77MB
of Java heap for a 40k-song "play from all" queue, transiently doubled
on every reshuffle, on top of a library model that already runs ~100MB
at that size. Playback buffers allocate from the same heap, which is why
the reported traces died inside PlaylistTimeline construction and
shouldContinueLoading.

Measured on device with the library inflated to 40194 songs:

  steady heap after shuffle-all   118MB -> 53MB (baseline 47MB)
  transient peak                  159MB -> 83MB
  after skips + 4 reshuffles      219MB -> 89MB

Notes on the mechanics:
- Queues that fit entirely in the window behave exactly as before,
  including native repeat handling.
- On larger queues RepeatMode.ALL is emulated by letting the window wrap
  circularly around the queue edges, so skips and automatic advances
  cross the seam without the player ever repeating on its own.
- Window slides append/remove at the edges only, so gapless preloading
  of the next song survives ordinary playback. Queue mutations rebuild
  the window around the current item without interrupting it.
- BetterShuffleOrder and the timeline unscrambling walk existed only to
  read the queue back out of the player, and are deleted; the shuffle
  mapping is now generated and owned directly.

Verified on device: shuffle-all, 30 rapid skips across the window edge,
prev, and state persistence across process death all behave; the media
session queue index tracked exactly through all of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mmarca-tech
mmarca-tech force-pushed the fix/library-memory-and-shuffle-anr branch from ccc38f8 to 4b34339 Compare August 8, 2026 15:15
@swedneck

swedneck commented Aug 8, 2026

Copy link
Copy Markdown

You should read #1325, I doubt this is going to be merged as it's authored by Claude.

@foss-

foss- commented Aug 8, 2026

Copy link
Copy Markdown

For what it's worth, the PR addresses long standing OOM crashes and hangs which have been in the app since at least june 2024 #957 (comment)

@mmarca-tech

Copy link
Copy Markdown
Author

@swedneck its still a work in progress, I'm trying to see which possible solutions can be applied on large music stacks with the least intromission of code possible , also we are taking some time to test it out with @foss- that has a +40k song list.

If you see the commits I'm not doing any crazy llm bloating

  • Reducing the pixels of covers that already constrained by the size of the canvas we are pulling full pixels for a 180x180 / 512px
  • Doing some lazy loading for the scrolling so we just build on the go
  • Cleaning up some logs when tapping shuffle
  • not giving the Exoplayer the whole queue ( This is the trickier and where I might need to do some corrections if pointed as this is where my gap of codebase knowdlege is lacking)

However, if it is not wanted I will drop it for sure. Do not want to disturb.

@redrumthebum

Copy link
Copy Markdown

trying this out, will report back 🙏🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants