diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f4a18b9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+# Only server/stemd.Dockerfile builds from the repo root; keep the venv and APKs out of its context.
+*
+!server/stemd.py
+!server/stemd-requirements.txt
+!stemroller/*.py
+!stemroller/worker/*.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..762fb91
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# Python
+.venv/
+__pycache__/
+*.pyc
+
+# Android / Gradle
+android/.gradle/
+android/.kotlin/
+android/build/
+android/app/build/
+android/local.properties
+android/captures/
+.idea/
+*.iml
+
+# Built and third-party APKs (publish builds as GitHub Releases instead)
+*.apk
+*.aab
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..30cb5e5
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,149 @@
+# StemRoller for Android
+
+A Plex music player for the OnePlus 15 that plays the stems stemd separates on your
+unraid server: per-stem faders, mute/solo, karaoke-style presets, a master volume drawn
+over a Plexamp-style waveform seek bar, a 10-band graphic EQ plus Equalizer APO-style
+parametric filters with per-device profiles, and an
+iOS look in Black, Dark or White (flat colours, no gradients).
+
+## Install
+
+`../StemRoller.apk` is a release build signed with the debug key, ready to sideload:
+
+```sh
+adb install -r ../StemRoller.apk
+```
+
+## First run
+
+1. Start stemserve on the unraid box (`../server/README.md`). It serves
+ `/mnt/user/Music/Stems` on port 8765.
+2. In the app: **Library → Connect** (or Settings → Plex). Enter the 4-character code at
+ plex.tv/link, then pick the **Music** library (`/media/Music/Actual music/Flacs`).
+3. Settings → Stem Server should say "Online" with a song count. The default address is
+ `http://192.168.86.2:8765`.
+
+Stems are the norm: stemd on the server separates the whole library on its own, and the app
+checks stemserve for new stems every minute. So the exceptions are what's marked: songs show
+**NO STEMS**, and albums and artists show **NO STEMS** or **7/10 STEMS** until they're done. Those
+songs play the original file from Plex, so the whole library works in the meantime.
+
+Hold any song, album, artist or playlist for: **Add to Front of Queue**, **Add to End of Queue**,
+**Download** (or Remove Download), **Add to Playlist** (a playlist can go into another one) and,
+while any of it lacks stems, **Stem Now**: stemd on the server separates those songs next, ahead
+of the rest of the library, and they switch to stems within a minute or so of finishing.
+
+The tabs are Library, Playlists, Search, Downloads and Settings. Library's home shows **On This
+Day**: albums released on today's date in earlier years (or, on a quiet day, the few days either
+side). **Downloads** lists albums, sorted by download order, artist or release date. The
+**Equalizer** is in Settings.
+
+The Equalizer has the 10 graphic bands plus **Parametric Filters**, as in Equalizer APO: peak,
+low/high shelf, low/high pass (6–48 dB/oct), band pass, notch and all pass, each with
+frequency, gain and Q, on both channels or just left or right, and switchable one by one.
+Drag a filter's handle on its curve, use the sliders, or tap a value to type it. Each
+profile has its own manual preamp; anti-clipping adds whatever else the curve needs. The
+Filters page's ••• menu pastes an Equalizer APO config from the clipboard (AutoEQ's
+`ParametricEQ.txt` for your headphones works as-is) and copies the profile out as one.
+
+The seek bar's waveform is scaled to each song's own range (its loud moments fill the bar,
+its quiet parts sit low), and the mixer's level meters follow what's audible at display rate.
+
+## How it fits together
+
+| Piece | What it does |
+|---|---|
+| `plex/` | Library, search, playlists (create/add/remove/reorder/rename), artwork, play reporting |
+| `stems/` | stemserve's index; matches Plex file paths to stem folders by suffix + duration |
+| `cache/` | Sparse block cache: streams with HTTP ranges, seeks anywhere, keeps what it fetched; downloads are pinned copies |
+| `engine/` | One decoder thread per stem → sample-locked mixer → EQ (auto anti-clip preamp) → master → limiter → one AudioTrack. Gapless |
+| `eq/` | Profiles (graphic bands, parametric filters, preamp), linked to output devices (speaker, wired, each Bluetooth headset, USB DAC); automatic or manual; APO config import/export |
+| `playback/` | Queue, shuffle/repeat, audio focus, mix presets; Media3 library session for notification, lock screen, Bluetooth and Android Auto |
+| `auto/` | Android Auto: browse tree, voice search, home-network probe, artwork provider |
+| `stems/StemCoverage.kt` | Which albums and artists have stems: a slim list of every track's path, matched to the index |
+| `ui/` | Compose, hand-built iOS components; per-tab navigation stacks driven by predictive back |
+
+The EQ's anti-clip preamp is the negative of the curve's highest point (evaluated on the
+actual biquads), so a full-scale signal at the boosted frequency still peaks at 0 dBFS.
+
+## Android Auto
+
+Built for the 2016 MX-5's Mazda Connect retrofit: an 800×480 screen with touch disabled,
+driven by the commander knob (turn, nudge, press, Back) and the steering-wheel buttons.
+Auto draws a media app's screens itself; the app supplies a browse tree and buttons, so
+that's what is shaped for the knob (`auto/`, `playback/PlaybackService.kt`):
+
+- **Four tabs** (all Auto shows): Playlists, Albums, Artists, Downloads. Away from home
+ Downloads comes first.
+- **Shallow and short.** Every list of songs starts with **Shuffle all**; lists longer than
+ 40 split into A–Z folders (big letter tiles), so the knob never has to crawl through
+ hundreds of rows. Albums are a grid (the art identifies them); everything else is a list.
+ Songs with stems say **Stems** under the title.
+- **Now playing:** previous / play / next stay put (and the wheel's seek buttons work).
+ Beside them, in priority order: **Vocals** on/off (karaoke), **Mix** (cycles Full →
+ Karaoke → A Cappella → No Drums → Rhythm → No Guitar → No Bass), then shuffle and repeat;
+ whatever doesn't fit beside the transport goes behind Auto's overflow button (on the
+ Miata's 800×480 all four fit). The current mix
+ shows on the artist line ("Black Sabbath · Karaoke"), since a button press gets no other
+ feedback. Songs without stems just get shuffle and repeat, and say "No stems" in the lists. The same buttons appear on the
+ phone's lock-screen controls.
+- **Voice:** hold the wheel's talk button: "play Paranoid on StemRoller" (a playlist,
+ artist, album or song; artists shuffle). There's no keyboard while driving, so Auto's
+ search button is voice too.
+- **Offline:** in the car the phone is on mobile data and Plex/stemserve are LAN-only. The
+ app probes them (1.5 s, re-checked on network changes) and when they're unreachable reads
+ library pages from cache only and lists **only songs that will play** (downloaded, or
+ fully cached); downloaded songs carry Auto's downloaded mark. Art comes from a local
+ `content://` provider fed by what downloads already cached.
+
+### Setting up the car (once)
+
+Auto hides sideloaded apps until you allow them:
+
+1. Phone Settings → search "Android Auto" → scroll to the bottom and tap **Version** ten
+ times → OK.
+2. ⋮ menu → **Developer settings** → tick **Unknown sources**.
+3. Plug into the Miata. StemRoller is in Auto's app launcher (the grid of icons).
+
+Download what you want for the drive while on home Wi-Fi.
+
+### Testing without the car
+
+Google's Desktop Head Unit runs Auto on the desktop. `config/rotary.ini` is exactly the
+Miata: 800×480, touch off, rotary controller on. On the phone: Auto developer settings ⋮ →
+**Start head unit server**; then:
+
+```sh
+adb forward tcp:5277 tcp:5277
+~/Android/Sdk/extras/google/auto/desktop-head-unit -c ~/Android/Sdk/extras/google/auto/config/rotary.ini
+```
+
+(It needs libc++, which this host lacks; here it runs in the `dhu-headless` podman image.)
+
+## Display
+
+The app asks for the panel's fastest mode at its full resolution (1272×2772 at 165 Hz on
+the OnePlus 15) and Compose renders at that vsync. The footer of Settings shows the active
+refresh rate; turn High Refresh Rate off to let the system choose.
+
+## Build
+
+JDK 21 and the Android SDK (`local.properties` points at `~/Android/Sdk`):
+
+```sh
+JAVA_HOME=~/.local/opt/jdk-21.0.12.1+1 ./gradlew assembleRelease # app/build/outputs/apk/release/
+JAVA_HOME=~/.local/opt/jdk-21.0.12.1+1 ./gradlew testDebugUnitTest # stem matching, EQ/limiter maths, block cache
+```
+
+The cache and index tests stream real stems from a stemserve on `localhost:8765` and
+are skipped when it isn't running.
+
+Debug builds include a hook for screenshots and back-gesture testing in the emulator
+(`app/src/debug`); release builds don't.
+
+## Limits
+
+- Plex can't hold an empty playlist, so new playlists start from a song, the queue or a
+ selection.
+- Streaming needs the LAN (Plex and stemserve are plain HTTP on 192.168.86.2). Downloads
+ and anything already streamed play offline.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..d9a530a
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,71 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ namespace = "com.stemroller.mobile"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.stemroller.mobile"
+ minSdk = 33
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ // Personal sideload build: sign with the debug key so it installs without a keystore.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ buildFeatures {
+ compose = true
+ }
+ packaging {
+ resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.process)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.foundation)
+ implementation(libs.androidx.compose.animation)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ implementation(libs.androidx.media3.session)
+ implementation(libs.androidx.media3.common)
+ implementation(libs.androidx.palette)
+ implementation(libs.okhttp)
+ implementation(libs.coil.compose)
+ implementation(libs.coil.network.okhttp)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.coroutines.guava)
+ testImplementation(libs.junit)
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..7c714e8
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,10 @@
+# kotlinx.serialization keeps its generated serializers via its own consumer rules.
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.**
+-keep,includedescriptorclasses class com.stemroller.mobile.**$$serializer { *; }
+-keepclassmembers class com.stemroller.mobile.** {
+ *** Companion;
+}
+-keepclasseswithmembers class com.stemroller.mobile.** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..b77fef7
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/debug/java/com/stemroller/mobile/DebugCapture.kt b/android/app/src/debug/java/com/stemroller/mobile/DebugCapture.kt
new file mode 100644
index 0000000..71a09f0
--- /dev/null
+++ b/android/app/src/debug/java/com/stemroller/mobile/DebugCapture.kt
@@ -0,0 +1,71 @@
+package com.stemroller.mobile
+
+import android.app.Activity
+import android.app.Application
+import android.content.BroadcastReceiver
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.os.Bundle
+import androidx.activity.BackEventCompat
+import androidx.activity.ComponentActivity
+import java.io.File
+import java.lang.ref.WeakReference
+
+/**
+ * `adb shell am broadcast -a com.stemroller.mobile.CAPTURE -p com.stemroller.mobile`
+ * writes files/capture.png: the current activity drawn in software.
+ *
+ * `... -a com.stemroller.mobile.BACK --es phase start|progress|commit|cancel --ef p 0.4`
+ * feeds the predictive back callbacks the way a system swipe would.
+ */
+class DebugCapture : ContentProvider() {
+ private var current = WeakReference(null)
+
+ override fun onCreate(): Boolean {
+ val app = context!!.applicationContext as Application
+ app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
+ override fun onActivityResumed(a: Activity) { current = WeakReference(a) }
+ override fun onActivityCreated(a: Activity, b: Bundle?) {}
+ override fun onActivityStarted(a: Activity) {}
+ override fun onActivityPaused(a: Activity) {}
+ override fun onActivityStopped(a: Activity) {}
+ override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
+ override fun onActivityDestroyed(a: Activity) {}
+ })
+ app.registerReceiver(object : BroadcastReceiver() {
+ override fun onReceive(c: Context, i: Intent) {
+ val a = current.get() ?: return
+ val v = a.window.decorView
+ val bmp = Bitmap.createBitmap(v.width, v.height, Bitmap.Config.ARGB_8888)
+ v.draw(Canvas(bmp))
+ File(a.filesDir, "capture.png").outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ }
+ }, IntentFilter("com.stemroller.mobile.CAPTURE"), Context.RECEIVER_EXPORTED)
+ app.registerReceiver(object : BroadcastReceiver() {
+ override fun onReceive(c: Context, i: Intent) {
+ val a = current.get() as? ComponentActivity ?: return
+ val d = a.onBackPressedDispatcher
+ val p = i.getFloatExtra("p", 0f)
+ val e = BackEventCompat(p * 600f, 1300f, p, BackEventCompat.EDGE_LEFT)
+ when (i.getStringExtra("phase")) {
+ "start" -> d.dispatchOnBackStarted(e)
+ "progress" -> d.dispatchOnBackProgressed(e)
+ "commit" -> d.onBackPressed()
+ "cancel" -> d.dispatchOnBackCancelled()
+ }
+ }
+ }, IntentFilter("com.stemroller.mobile.BACK"), Context.RECEIVER_EXPORTED)
+ return true
+ }
+
+ override fun query(u: android.net.Uri, p: Array?, s: String?, a: Array?, o: String?) = null
+ override fun getType(u: android.net.Uri) = null
+ override fun insert(u: android.net.Uri, v: ContentValues?) = null
+ override fun delete(u: android.net.Uri, s: String?, a: Array?) = 0
+ override fun update(u: android.net.Uri, v: ContentValues?, s: String?, a: Array?) = 0
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0a98b57
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/stemroller/mobile/App.kt b/android/app/src/main/java/com/stemroller/mobile/App.kt
new file mode 100644
index 0000000..a73a093
--- /dev/null
+++ b/android/app/src/main/java/com/stemroller/mobile/App.kt
@@ -0,0 +1,134 @@
+package com.stemroller.mobile
+
+import android.app.Application
+import android.content.pm.ApplicationInfo
+import coil3.ImageLoader
+import coil3.PlatformContext
+import coil3.SingletonImageLoader
+import coil3.disk.DiskCache
+import coil3.disk.directory
+import coil3.memory.MemoryCache
+import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import coil3.request.allowHardware
+import coil3.request.crossfade
+import com.stemroller.mobile.auto.HomeReach
+import com.stemroller.mobile.cache.Downloads
+import com.stemroller.mobile.cache.MediaCache
+import com.stemroller.mobile.core.Connectivity
+import com.stemroller.mobile.core.Settings
+import com.stemroller.mobile.eq.EqController
+import com.stemroller.mobile.playback.PlaybackManager
+import com.stemroller.mobile.plex.PlexClient
+import com.stemroller.mobile.stems.StemCoverage
+import com.stemroller.mobile.stems.StemServer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import okhttp3.Cache
+import okhttp3.CacheControl
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import java.io.File
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+class App : Application(), SingletonImageLoader.Factory {
+ lateinit var graph: Graph
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ graph = Graph(this)
+ }
+
+ override fun newImageLoader(context: PlatformContext): ImageLoader =
+ ImageLoader.Builder(context)
+ .components { add(OkHttpNetworkFetcherFactory(callFactory = { graph.imageHttp })) }
+ .memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.2).build() }
+ .diskCache { DiskCache.Builder().directory(File(cacheDir, "art")).maxSizeBytes(512L * 1024 * 1024).build() }
+ .crossfade(false)
+ // Debug builds draw the UI to a software canvas for screenshots, which can't take hardware bitmaps.
+ .allowHardware(applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0)
+ .build()
+
+ companion object {
+ lateinit var instance: App
+ private set
+ val graph: Graph get() = instance.graph
+ }
+}
+
+/** Hand-wired singletons; there are few enough that a DI framework isn't worth it. */
+class Graph(app: App) {
+ val packageName: String = app.packageName
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ val settings = Settings(app)
+ val net = Connectivity(app)
+
+ /** Library calls: cached on disk and served from there when the server is unreachable. */
+ val apiHttp: OkHttpClient = OkHttpClient.Builder()
+ .cache(Cache(File(app.cacheDir, "http"), 64L * 1024 * 1024))
+ .connectTimeout(6, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .addInterceptor(offlineFallback)
+ .addNetworkInterceptor(cacheable)
+ .build()
+
+ /** Media ranges; MediaCache is the cache. Long read timeout covers server-side Opus transcodes. */
+ val mediaHttp: OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(8, TimeUnit.SECONDS)
+ .readTimeout(90, TimeUnit.SECONDS)
+ .build()
+
+ val imageHttp: OkHttpClient = mediaHttp.newBuilder().readTimeout(20, TimeUnit.SECONDS).build()
+
+ val plex = PlexClient(apiHttp, settings)
+
+ /** Is Plex/stemserve answering? Asked by the car UI before every browse. */
+ val reach = HomeReach(mediaHttp, settings, net, scope)
+
+ /** The car's Plex: identical, except that away from home it reads the disk cache without trying the network. */
+ val carPlex = PlexClient(
+ apiHttp.newBuilder().addInterceptor { chain ->
+ val req = chain.request()
+ if (reach.lastKnown || req.method != "GET") chain.proceed(req)
+ else chain.proceed(req.newBuilder().cacheControl(CacheControl.FORCE_CACHE).build())
+ }.build(),
+ settings,
+ )
+ val stems = StemServer(apiHttp, settings, app.filesDir, scope)
+ val cache = MediaCache(File(app.noBackupFilesDir, "media"), mediaHttp) {
+ settings.cacheLimitGb.value.toLong() * 1024 * 1024 * 1024
+ }
+ val eq = EqController(app)
+ val playback = PlaybackManager(app, settings, plex, stems, cache, eq, scope)
+ val downloads = Downloads(app, settings, plex, stems, cache, net, scope)
+ val coverage = StemCoverage(plex, stems, settings, app.filesDir, scope)
+
+ private companion object {
+ /** Plex sends no cache headers; store everything, always revalidate. */
+ val cacheable = Interceptor { chain ->
+ val resp = chain.proceed(chain.request())
+ if (resp.header("Cache-Control") == null && chain.request().method == "GET") {
+ resp.newBuilder().removeHeader("Pragma").header("Cache-Control", "public, max-age=0").build()
+ } else resp
+ }
+
+ /** On network failure, answer GETs from the disk cache if it has them. */
+ val offlineFallback = Interceptor { chain ->
+ val req = chain.request()
+ try {
+ chain.proceed(req)
+ } catch (e: IOException) {
+ if (req.method != "GET") throw e
+ val cached = chain.proceed(req.newBuilder().cacheControl(CacheControl.FORCE_CACHE).build())
+ if (cached.code == 504) {
+ cached.close()
+ throw e
+ }
+ cached
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/stemroller/mobile/MainActivity.kt b/android/app/src/main/java/com/stemroller/mobile/MainActivity.kt
new file mode 100644
index 0000000..8c4f579
--- /dev/null
+++ b/android/app/src/main/java/com/stemroller/mobile/MainActivity.kt
@@ -0,0 +1,97 @@
+package com.stemroller.mobile
+
+import android.Manifest
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import androidx.activity.ComponentActivity
+import androidx.activity.SystemBarStyle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
+import com.google.common.util.concurrent.ListenableFuture
+import com.stemroller.mobile.core.ThemeMode
+import com.stemroller.mobile.playback.PlaybackService
+import com.stemroller.mobile.ui.StemApp
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+ private var controller: ListenableFuture? = null
+ private val openNowPlaying = mutableIntStateOf(0)
+ private val notifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val g = App.graph
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ combine(g.settings.theme.flow, g.settings.highRefreshRate.flow) { t, hz -> t to hz }.collect { (theme, hz) ->
+ val light = theme == ThemeMode.White
+ enableEdgeToEdge(
+ statusBarStyle = if (light) SystemBarStyle.light(0, 0) else SystemBarStyle.dark(0),
+ navigationBarStyle = if (light) SystemBarStyle.light(0, 0) else SystemBarStyle.dark(0),
+ )
+ window.isNavigationBarContrastEnforced = false
+ applyRefreshRate(hz)
+ }
+ }
+ }
+ if (intent?.getBooleanExtra(EXTRA_NOW_PLAYING, false) == true) openNowPlaying.intValue++
+ setContent { StemApp(openNowPlaying.intValue) }
+ if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ notifications.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ if (intent.getBooleanExtra(EXTRA_NOW_PLAYING, false)) openNowPlaying.intValue++
+ }
+
+ /**
+ * Asks for the panel's fastest mode at its full resolution: on a OnePlus 15 that's
+ * 1272x2772 at 165 Hz. Compose then renders at whatever vsync that gives us.
+ */
+ private fun applyRefreshRate(high: Boolean) {
+ val d = display ?: return
+ val modes = d.supportedModes
+ val maxPixels = modes.maxOf { it.physicalWidth * it.physicalHeight }
+ val best = modes.filter { it.physicalWidth * it.physicalHeight == maxPixels }.maxByOrNull { it.refreshRate }
+ window.attributes = window.attributes.apply {
+ preferredDisplayModeId = if (high && best != null) best.modeId else 0
+ preferredRefreshRate = if (high && best != null) best.refreshRate else 0f
+ }
+ if (Build.VERSION.SDK_INT >= 35) {
+ // An explicit rate, not CATEGORY_HIGH: OxygenOS maps the "high" category to 90 Hz.
+ window.decorView.requestedFrameRate = if (high && best != null) best.refreshRate else View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
+ window.frameRateBoostOnTouchEnabled = high
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ // A connected controller keeps the session service bound, so playback starts it in the foreground.
+ controller = MediaController.Builder(this, SessionToken(this, ComponentName(this, PlaybackService::class.java))).buildAsync()
+ App.graph.eq.refreshRoute()
+ }
+
+ override fun onStop() {
+ controller?.let { MediaController.releaseFuture(it) }
+ controller = null
+ super.onStop()
+ }
+
+ companion object {
+ const val EXTRA_NOW_PLAYING = "now_playing"
+ }
+}
diff --git a/android/app/src/main/java/com/stemroller/mobile/auto/CarArtProvider.kt b/android/app/src/main/java/com/stemroller/mobile/auto/CarArtProvider.kt
new file mode 100644
index 0000000..1de0d9c
--- /dev/null
+++ b/android/app/src/main/java/com/stemroller/mobile/auto/CarArtProvider.kt
@@ -0,0 +1,124 @@
+package com.stemroller.mobile.auto
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import coil3.SingletonImageLoader
+import com.stemroller.mobile.App
+import okhttp3.Request
+import java.io.File
+import java.io.FileNotFoundException
+import java.security.MessageDigest
+
+/**
+ * Album art for Android Auto, which only loads `content://` artwork. Serves a local copy,
+ * filled from the art the phone UI already cached (downloads warm it at 600 px, so offline
+ * downloads have art in the car) or, failing that, fetched from Plex once.
+ *
+ * Also draws the A–Z folder tiles: without art, Auto puts a warning triangle in their place.
+ */
+class CarArtProvider : ContentProvider() {
+ private val dir by lazy { File(context!!.cacheDir, "car-art").apply { mkdirs() } }
+
+ override fun onCreate() = true
+
+ override fun getType(uri: Uri) = if (uri.path == "/$LETTER") "image/png" else "image/jpeg"
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
+ if (uri.path == "/$LETTER") return letterTile(uri.getQueryParameter(LETTER) ?: "#")
+ val thumb = uri.getQueryParameter(THUMB) ?: throw FileNotFoundException(uri.toString())
+ val file = File(dir, sha1(thumb) + ".jpg")
+ if (!file.exists() || file.length() == 0L) fill(thumb, file)
+ if (!file.exists()) throw FileNotFoundException(thumb)
+ file.setLastModified(System.currentTimeMillis())
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
+ }
+
+ private fun fill(thumb: String, file: File) {
+ val g = App.graph
+ val tmp = File(dir, file.name + ".tmp")
+ val disk = SingletonImageLoader.get(context!!).diskCache
+ for (px in PHONE_SIZES) {
+ val url = g.plex.artUrl(thumb, px) ?: continue
+ val snap = disk?.openSnapshot(url) ?: continue
+ try {
+ snap.data.toFile().copyTo(tmp, overwrite = true)
+ } finally {
+ snap.close()
+ }
+ if (tmp.renameTo(file)) return trim()
+ }
+ if (!g.reach.lastKnown) return
+ val url = g.plex.artUrl(thumb, PX) ?: return
+ runCatching {
+ g.imageHttp.newCall(Request.Builder().url(url).build()).execute().use { r ->
+ if (!r.isSuccessful) return
+ tmp.outputStream().use { out -> r.body!!.byteStream().copyTo(out) }
+ }
+ if (tmp.renameTo(file)) trim()
+ }
+ tmp.delete()
+ }
+
+ /** A big white letter on a flat dark tile, readable at a glance on the 800×480 screen. */
+ private fun letterTile(letter: String): ParcelFileDescriptor {
+ val l = letter.take(1).uppercase().ifEmpty { "#" }
+ val file = File(dir, "letter-${l.first().code}.png")
+ if (!file.exists() || file.length() == 0L) {
+ val bmp = Bitmap.createBitmap(LETTER_PX, LETTER_PX, Bitmap.Config.ARGB_8888)
+ val c = Canvas(bmp)
+ c.drawColor(Color.rgb(0x2C, 0x2C, 0x2E))
+ val p = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ textSize = LETTER_PX * 0.55f
+ textAlign = Paint.Align.CENTER
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+ }
+ val y = LETTER_PX / 2f - (p.descent() + p.ascent()) / 2f
+ c.drawText(l, LETTER_PX / 2f, y, p)
+ val tmp = File(dir, file.name + ".tmp")
+ tmp.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ bmp.recycle()
+ tmp.renameTo(file)
+ }
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
+ }
+
+ private fun trim() {
+ val files = dir.listFiles { f -> f.name.endsWith(".jpg") } ?: return
+ if (files.size <= MAX_FILES) return
+ files.sortedBy { it.lastModified() }.take(files.size - MAX_FILES).forEach { it.delete() }
+ }
+
+ override fun query(uri: Uri, p: Array?, s: String?, a: Array?, o: String?) = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, s: String?, a: Array?) = 0
+ override fun update(uri: Uri, v: ContentValues?, s: String?, a: Array?) = 0
+
+ companion object {
+ const val AUTHORITY = "com.stemroller.mobile.carart"
+ private const val THUMB = "thumb"
+ private const val LETTER = "letter"
+ private const val LETTER_PX = 256
+ /** Comfortably sharp on the 480-pixel-tall Miata screen, and what downloads cache. */
+ const val PX = 600
+ private val PHONE_SIZES = intArrayOf(600, 720)
+ private const val MAX_FILES = 2000
+
+ fun uri(thumb: String?): Uri? =
+ if (thumb.isNullOrEmpty()) null
+ else Uri.Builder().scheme("content").authority(AUTHORITY).path("art").appendQueryParameter(THUMB, thumb).build()
+
+ /** The tile for an A–Z folder ("#" for numbers and symbols). */
+ fun letterUri(letter: String): Uri =
+ Uri.Builder().scheme("content").authority(AUTHORITY).path(LETTER).appendQueryParameter(LETTER, letter).build()
+
+ private fun sha1(s: String) = MessageDigest.getInstance("SHA-1").digest(s.toByteArray()).joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/android/app/src/main/java/com/stemroller/mobile/auto/CarLibrary.kt b/android/app/src/main/java/com/stemroller/mobile/auto/CarLibrary.kt
new file mode 100644
index 0000000..6221a53
--- /dev/null
+++ b/android/app/src/main/java/com/stemroller/mobile/auto/CarLibrary.kt
@@ -0,0 +1,299 @@
+package com.stemroller.mobile.auto
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.annotation.OptIn
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.session.MediaConstants
+import com.stemroller.mobile.Graph
+import com.stemroller.mobile.plex.Album
+import com.stemroller.mobile.plex.Artist
+import com.stemroller.mobile.plex.Playlist
+import com.stemroller.mobile.plex.Track
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/** What a pick or a voice request turns into: a queue, where to start, and whether to shuffle it. */
+class Resolved(val tracks: List